From 16cc26632e5282275c5c22af09c4bcfb14b086af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20J=C3=A4derberg?= Date: Mon, 2 Feb 2026 14:28:14 +0000 Subject: [PATCH] Merged in chore/refactor-trpc-booking-routes (pull request #3510) feat(BOOK-750): refactor booking endpoints * WIP * wip * wip * parse dates in UTC * wip * no more errors * Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/refactor-trpc-booking-routes * . * cleanup * import named z from zod * fix(BOOK-750): updateBooking api endpoint expects dateOnly, we passed ISO date Approved-by: Anton Gunnarsson --- .../MyStay/GuestDetails/index.tsx | 10 +- .../MyStay/accessBooking.test.ts | 6 +- .../HotelReservation/MyStay/accessBooking.ts | 4 +- apps/scandic-web/providers/MyStay.tsx | 8 +- .../EnterDetails/Payment/PaymentClient.tsx | 80 ++--- .../lib/pages/PaymentCallbackPage.tsx | 8 +- packages/trpc/lib/errors.ts | 7 + packages/trpc/lib/routers/booking/input.ts | 21 +- .../booking/mutation/addPackagesRoute.ts | 42 +++ .../booking/mutation/cancelBookingRoute.ts | 51 +++ .../routers/booking/mutation/create/index.ts | 95 ------ .../mutation/createBookingRoute/index.ts | 46 +++ .../{create => createBookingRoute}/schema.ts | 118 ++----- .../booking/mutation/createRefIdRoute.ts | 19 ++ .../booking/mutation/guaranteeBookingRoute.ts | 43 +++ .../lib/routers/booking/mutation/index.ts | 264 ++------------ .../booking/mutation/priceChangeRoute.ts | 24 ++ .../mutation/validatePartnerPayment.ts | 2 +- packages/trpc/lib/routers/booking/query.ts | 323 +----------------- .../routers/booking/query/findBookingRoute.ts | 87 +++++ .../routers/booking/query/getBookingRoute.ts | 84 +++++ .../booking/query/getBookingStatusRoute.ts | 33 ++ .../query/getLinkedReservationsRoute.ts | 34 ++ packages/trpc/lib/routers/booking/utils.ts | 171 ---------- .../lib/routers/contentstack/schemas/alert.ts | 2 +- .../booking/addPackageToBooking/index.ts | 60 ++++ .../services/booking/cancelBooking/index.ts | 76 +++++ .../services/booking/cancelBooking/schema.ts | 56 +++ .../services/booking/createBooking/index.ts | 88 +++++ .../services/booking/createBooking/schema.ts | 84 +++++ .../lib/services/booking/findBooking/index.ts | 81 +++++ .../lib/services/booking/getBooking/index.ts | 59 ++++ .../booking/getBooking/schema.ts} | 90 +++-- .../booking/getBookingStatus/index.ts | 65 ++++ .../booking/getBookingStatus/schema.ts | 61 ++++ .../booking/guaranteeBooking/index.ts | 63 ++++ .../booking/linkedReservations/index.ts | 64 ++++ .../lib/services/booking/priceChange/index.ts | 41 +++ packages/trpc/lib/services/booking/schema.ts | 84 +++++ .../schema/bookingReservationStatusSchema.ts | 22 ++ .../lib/services/booking/updateBooking.ts | 80 +++++ .../trpc/lib/types/bookingConfirmation.ts | 3 +- packages/trpc/lib/types/entry.ts | 2 +- packages/trpc/package.json | 1 + 44 files changed, 1621 insertions(+), 1041 deletions(-) create mode 100644 packages/trpc/lib/routers/booking/mutation/addPackagesRoute.ts create mode 100644 packages/trpc/lib/routers/booking/mutation/cancelBookingRoute.ts delete mode 100644 packages/trpc/lib/routers/booking/mutation/create/index.ts create mode 100644 packages/trpc/lib/routers/booking/mutation/createBookingRoute/index.ts rename packages/trpc/lib/routers/booking/mutation/{create => createBookingRoute}/schema.ts (55%) create mode 100644 packages/trpc/lib/routers/booking/mutation/createRefIdRoute.ts create mode 100644 packages/trpc/lib/routers/booking/mutation/guaranteeBookingRoute.ts create mode 100644 packages/trpc/lib/routers/booking/mutation/priceChangeRoute.ts create mode 100644 packages/trpc/lib/routers/booking/query/findBookingRoute.ts create mode 100644 packages/trpc/lib/routers/booking/query/getBookingRoute.ts create mode 100644 packages/trpc/lib/routers/booking/query/getBookingStatusRoute.ts create mode 100644 packages/trpc/lib/routers/booking/query/getLinkedReservationsRoute.ts delete mode 100644 packages/trpc/lib/routers/booking/utils.ts create mode 100644 packages/trpc/lib/services/booking/addPackageToBooking/index.ts create mode 100644 packages/trpc/lib/services/booking/cancelBooking/index.ts create mode 100644 packages/trpc/lib/services/booking/cancelBooking/schema.ts create mode 100644 packages/trpc/lib/services/booking/createBooking/index.ts create mode 100644 packages/trpc/lib/services/booking/createBooking/schema.ts create mode 100644 packages/trpc/lib/services/booking/findBooking/index.ts create mode 100644 packages/trpc/lib/services/booking/getBooking/index.ts rename packages/trpc/lib/{routers/booking/output.ts => services/booking/getBooking/schema.ts} (93%) create mode 100644 packages/trpc/lib/services/booking/getBookingStatus/index.ts create mode 100644 packages/trpc/lib/services/booking/getBookingStatus/schema.ts create mode 100644 packages/trpc/lib/services/booking/guaranteeBooking/index.ts create mode 100644 packages/trpc/lib/services/booking/linkedReservations/index.ts create mode 100644 packages/trpc/lib/services/booking/priceChange/index.ts create mode 100644 packages/trpc/lib/services/booking/schema.ts create mode 100644 packages/trpc/lib/services/booking/schema/bookingReservationStatusSchema.ts create mode 100644 packages/trpc/lib/services/booking/updateBooking.ts diff --git a/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx index eed717ecc..0f0809224 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/GuestDetails/index.tsx @@ -1,4 +1,5 @@ "use client" + import { zodResolver } from "@hookform/resolvers/zod" import { usePathname, useRouter } from "next/navigation" import { useState } from "react" @@ -25,7 +26,7 @@ import ModifyContact from "../ModifyContact" import styles from "./guestDetails.module.css" -import type { Guest } from "@scandic-hotels/trpc/routers/booking/output" +import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation" import { type ModifyContactSchema, @@ -34,9 +35,9 @@ import { import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay" import type { SafeUser } from "@/types/user" -interface GuestDetailsProps { +type GuestDetailsProps = { refId: string - guest: Guest + guest: BookingConfirmation["booking"]["guest"] isCancelled: boolean user: SafeUser } @@ -76,6 +77,7 @@ export default function GuestDetails({ const isFirstStep = currentStep === MODAL_STEPS.INITIAL const isMemberBooking = + !!user?.membership?.membershipNumber && guest.membershipNumber === user?.membership?.membershipNumber const updateGuest = trpc.booking.update.useMutation({ @@ -196,7 +198,7 @@ export default function GuestDetails({ {guest.firstName} {guest.lastName}

- {isMemberBooking && user.membership && ( + {isMemberBooking && user?.membership && (

{intl.formatMessage( diff --git a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts index bb99926cf..477acd4ce 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts +++ b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.test.ts @@ -8,7 +8,7 @@ import accessBooking, { } from "./accessBooking" import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue" -import type { Guest } from "@scandic-hotels/trpc/routers/booking/output" +import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation" import type { SafeUser } from "@/types/user" @@ -201,7 +201,7 @@ const badAuthenticatedUser: SafeUser = { profilingConsentUpdateDate: undefined, } -const loggedOutGuest: Guest = { +const loggedOutGuest: BookingConfirmation["booking"]["guest"] = { email: "logged+out@scandichotels.com", firstName: "Anonymous", lastName: "Booking", @@ -210,7 +210,7 @@ const loggedOutGuest: Guest = { countryCode: "SE", } -const loggedInGuest: Guest = { +const loggedInGuest: BookingConfirmation["booking"]["guest"] = { email: "logged+in@scandichotels.com", firstName: "Authenticated", lastName: "Booking", diff --git a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.ts b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.ts index 7ad9f7d7e..bd8b8f31d 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.ts +++ b/apps/scandic-web/components/HotelReservation/MyStay/accessBooking.ts @@ -1,5 +1,5 @@ import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue" -import type { Guest } from "@scandic-hotels/trpc/routers/booking/output" +import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation" import type { SafeUser } from "@/types/user" @@ -15,7 +15,7 @@ export { * Whether a request can access a confirmed booking or not. */ function accessBooking( - guest: Guest, + guest: BookingConfirmation["booking"]["guest"], lastName: string, user: SafeUser | null, cookie: string = "" diff --git a/apps/scandic-web/providers/MyStay.tsx b/apps/scandic-web/providers/MyStay.tsx index 0bb923739..0e3369f0c 100644 --- a/apps/scandic-web/providers/MyStay.tsx +++ b/apps/scandic-web/providers/MyStay.tsx @@ -13,22 +13,20 @@ import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkele import { MyStayContext } from "@/contexts/MyStay" import type { Lang } from "@scandic-hotels/common/constants/language" -import type { - BookingConfirmation, - BookingConfirmationSchema, -} from "@scandic-hotels/trpc/types/bookingConfirmation" +import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation" import type { RoomCategories } from "@scandic-hotels/trpc/types/hotel" import type { CreditCard } from "@scandic-hotels/trpc/types/user" import type { Packages } from "@/types/components/myPages/myStay/ancillaries" import type { MyStayStore } from "@/types/contexts/my-stay" +import type { getLinkedReservations } from "@/lib/trpc/memoizedRequests" interface MyStayProviderProps { bookingConfirmation: BookingConfirmation breakfastPackages: Packages | null isLoggedIn?: boolean lang: Lang - linkedReservationsPromise: Promise + linkedReservationsPromise: ReturnType refId: string roomCategories: RoomCategories savedCreditCards: CreditCard[] | null diff --git a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx index 32d1655d8..b4f30e6e5 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx @@ -52,7 +52,7 @@ import { getPaymentHeadingConfig } from "./utils" import styles from "./payment.module.css" -import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema" +import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/input" import type { CreditCard } from "@scandic-hotels/trpc/types/user" import type { PriceChangeData } from "../PriceChangeData" @@ -128,45 +128,46 @@ export default function PaymentClient({ const initiateBooking = trpc.booking.create.useMutation({ onSuccess: (result) => { - if (result) { - if ("error" in result) { - const queryParams = new URLSearchParams(searchParams.toString()) - queryParams.set("errorCode", result.cause) - window.history.replaceState( - {}, - "", - `${pathname}?${queryParams.toString()}` - ) - handlePaymentError(result.cause) - return - } - - const { booking } = result - const mainRoom = booking.rooms[0] - - if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) { - clearBookingWidgetState() - // Cookie is used by Booking Confirmation page to validate that the user came from payment callback - // eslint-disable-next-line react-hooks/immutability - document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict` - const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}` - router.push(confirmationUrl) - return - } - - setRefId(mainRoom.refId) - - const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata) - if (hasPriceChange) { - const priceChangeData = booking.rooms - .map((room) => room.priceChangedMetadata || null) - .filter(isNotNull) - setPriceChangeData(priceChangeData) - } else { - setIsPollingForBookingStatus(true) - } - } else { + if (!result) { handlePaymentError("No confirmation number") + return + } + + if ("error" in result) { + const queryParams = new URLSearchParams(searchParams.toString()) + queryParams.set("errorCode", result.cause) + window.history.replaceState( + {}, + "", + `${pathname}?${queryParams.toString()}` + ) + handlePaymentError(result.cause) + return + } + + const { booking } = result + const mainRoom = booking.rooms[0] + + if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) { + clearBookingWidgetState() + // Cookie is used by Booking Confirmation page to validate that the user came from payment callback + // eslint-disable-next-line react-hooks/immutability + document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict` + const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}` + router.push(confirmationUrl) + return + } + + setRefId(mainRoom.refId) + + const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata) + if (hasPriceChange) { + const priceChangeData = booking.rooms + .map((room) => room.priceChangedMetadata || null) + .filter(isNotNull) + setPriceChangeData(priceChangeData) + } else { + setIsPollingForBookingStatus(true) } }, onError: (error) => { @@ -419,6 +420,7 @@ export default function PaymentClient({ } ), } + initiateBooking.mutate(payload) }, [ diff --git a/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx b/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx index f120850db..b5651cd26 100644 --- a/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx +++ b/packages/booking-flow/lib/pages/PaymentCallbackPage.tsx @@ -9,7 +9,7 @@ import { import { logger } from "@scandic-hotels/common/logger" import { getServiceToken } from "@scandic-hotels/common/tokenManager" import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode" -import { getBooking } from "@scandic-hotels/trpc/routers/booking/utils" +import { getBooking } from "@scandic-hotels/trpc/services/booking/getBooking" import { encrypt } from "@scandic-hotels/trpc/utils/encryption" import { BookingFlowConfig } from "../bookingFlowConfig/bookingFlowConfig" @@ -18,7 +18,7 @@ import { HandleSuccessCallback } from "../components/EnterDetails/Payment/Paymen import { serverClient } from "../trpc" import type { Lang } from "@scandic-hotels/common/constants/language" -import type { CreateBookingSchema } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema" +import type { BookingStatus } from "@scandic-hotels/trpc/services/booking/getBookingStatus" import type { NextSearchParams } from "../types" @@ -99,7 +99,7 @@ export async function PaymentCallbackPage({ notFound() } - const booking = await getBooking(confirmationNumber, lang, token) + const booking = await getBooking({ confirmationNumber, lang }, token) const refId = booking?.refId const caller = await serverClient() @@ -156,7 +156,7 @@ function HandleBookingStatusError({ config, status, }: { - booking: CreateBookingSchema | null + booking: BookingStatus | null confirmationNumber?: string returnUrl: string config: BookingFlowConfig diff --git a/packages/trpc/lib/errors.ts b/packages/trpc/lib/errors.ts index fc1acf724..c75f811e6 100644 --- a/packages/trpc/lib/errors.ts +++ b/packages/trpc/lib/errors.ts @@ -79,6 +79,13 @@ export function unprocessableContent(cause?: TRPCCause, message: string = "") { }) } +export function badGatewayError(cause?: TRPCCause, message: string = "") { + return new TRPCError({ + code: "BAD_GATEWAY", + cause: harmonizeCause(cause, message), + }) +} + export function internalServerError(cause?: TRPCCause, message: string = "") { return new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/packages/trpc/lib/routers/booking/input.ts b/packages/trpc/lib/routers/booking/input.ts index 95906c893..dba05e9d3 100644 --- a/packages/trpc/lib/routers/booking/input.ts +++ b/packages/trpc/lib/routers/booking/input.ts @@ -30,20 +30,6 @@ export const cancelBookingsInput = z.object({ language: z.nativeEnum(Lang), }) -export const guaranteeBookingInput = z.object({ - card: z - .object({ - alias: z.string(), - expiryDate: z.string(), - cardType: z.string(), - }) - .optional(), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), - success: z.string().nullable(), - error: z.string().nullable(), - cancel: z.string().nullable(), -}) - export const createRefIdInput = z.object({ confirmationNumber: z .string() @@ -63,7 +49,7 @@ export const updateBookingInput = z.object({ countryCode: z.string().optional(), }) .optional(), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), + language: z.nativeEnum(Lang), }) // Query @@ -85,7 +71,4 @@ export const findBookingInput = z.object({ }) export type LinkedReservationsInput = z.input - -export const getBookingStatusInput = z.object({ - lang: z.nativeEnum(Lang).optional(), -}) +export type { CreateBookingInput } from "./mutation/createBookingRoute/schema" diff --git a/packages/trpc/lib/routers/booking/mutation/addPackagesRoute.ts b/packages/trpc/lib/routers/booking/mutation/addPackagesRoute.ts new file mode 100644 index 000000000..531185219 --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/addPackagesRoute.ts @@ -0,0 +1,42 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" + +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { addPackageToBooking } from "../../../services/booking/addPackageToBooking" + +const addPackageInput = z.object({ + ancillaryComment: z.string(), + ancillaryDeliveryTime: z.string().nullish(), + packages: z.array( + z.object({ + code: z.string(), + quantity: z.number(), + comment: z.string().optional(), + }) + ), + language: z.nativeEnum(Lang), +}) +const refIdPlugin = createRefIdPlugin() + +export const addPackagesRoute = safeProtectedServiceProcedure + .input(addPackageInput) + .concat(refIdPlugin.toConfirmationNumber) + .use(async ({ ctx, next }) => { + const token = await ctx.getScandicUserToken() + return next({ + ctx: { + token, + }, + }) + }) + .mutation(async function ({ ctx, input }) { + const { confirmationNumber } = ctx + const { language, refId, ...body } = input + + return await addPackageToBooking( + { confirmationNumber, lang: language, ...body }, + ctx.token ?? ctx.serviceToken + ) + }) diff --git a/packages/trpc/lib/routers/booking/mutation/cancelBookingRoute.ts b/packages/trpc/lib/routers/booking/mutation/cancelBookingRoute.ts new file mode 100644 index 000000000..e619674d3 --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/cancelBookingRoute.ts @@ -0,0 +1,51 @@ +import { createLogger } from "@scandic-hotels/common/logger/createLogger" + +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { cancelBooking } from "../../../services/booking/cancelBooking" +import { cancelBookingsInput } from "../input" + +const bookingLogger = createLogger("trpc.booking.cancelBooking") +const refIdPlugin = createRefIdPlugin() +export const cancelBookingRoute = safeProtectedServiceProcedure + .input(cancelBookingsInput) + .concat(refIdPlugin.toConfirmationNumbers) + .use(async ({ ctx, next }) => { + const token = await ctx.getScandicUserToken() + + return next({ + ctx: { + token, + }, + }) + }) + .mutation(async function ({ ctx, input }) { + const { confirmationNumbers } = ctx + const { language } = input + + const token = ctx.token ?? ctx.serviceToken + const responses = await Promise.allSettled( + confirmationNumbers.map((confirmationNumber) => + cancelBooking({ confirmationNumber, language }, token) + ) + ) + + const cancelledRoomsSuccessfully: (string | null)[] = [] + for (const [idx, response] of responses.entries()) { + if (response.status === "fulfilled") { + if (response.value) { + cancelledRoomsSuccessfully.push(confirmationNumbers[idx]) + continue + } + } else { + bookingLogger.error( + `Cancelling booking failed for confirmationNumber: ${confirmationNumbers[idx]}`, + response.reason + ) + } + + cancelledRoomsSuccessfully.push(null) + } + + return cancelledRoomsSuccessfully + }) diff --git a/packages/trpc/lib/routers/booking/mutation/create/index.ts b/packages/trpc/lib/routers/booking/mutation/create/index.ts deleted file mode 100644 index 38ae73558..000000000 --- a/packages/trpc/lib/routers/booking/mutation/create/index.ts +++ /dev/null @@ -1,95 +0,0 @@ -import "server-only" - -import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" -import { createCounter } from "@scandic-hotels/common/telemetry" - -import * as api from "../../../../api" -import { safeProtectedServiceProcedure } from "../../../../procedures" -import { encrypt } from "../../../../utils/encryption" -import { createBookingInput, createBookingSchema } from "./schema" - -export const create = safeProtectedServiceProcedure - .input(createBookingInput) - .use(async ({ ctx, next }) => { - const token = await ctx.getScandicUserToken() - - 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({ - 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 ?? ctx.serviceToken}`, - } - - const includePartnerSpecific = - inputWithoutLang.payment?.paymentMethod === - PaymentMethodEnum.PartnerPoints - if (includePartnerSpecific) { - const session = await ctx.auth() - const token = session?.token.access_token - if (!token) { - throw new Error( - "Cannot create booking with partner points without partner token" - ) - } - - inputWithoutLang.partnerSpecific = { - eurobonusAccessToken: session?.token.access_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/createBookingRoute/index.ts b/packages/trpc/lib/routers/booking/mutation/createBookingRoute/index.ts new file mode 100644 index 000000000..49c2ba60c --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/createBookingRoute/index.ts @@ -0,0 +1,46 @@ +import "server-only" + +import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" + +import { safeProtectedServiceProcedure } from "../../../../procedures" +import { createBooking } from "../../../../services/booking/createBooking" +import { encrypt } from "../../../../utils/encryption" +import { createBookingInput } from "./schema" +export const createBookingRoute = safeProtectedServiceProcedure + .input(createBookingInput) + .use(async ({ ctx, next }) => { + const token = await ctx.getScandicUserToken() + + return next({ + ctx: { + token, + }, + }) + }) + .mutation(async function ({ ctx, input }) { + if (input.payment?.paymentMethod === PaymentMethodEnum.PartnerPoints) { + const session = await ctx.auth() + const token = session?.token.access_token + if (!token) { + throw new Error( + "Cannot create booking with partner points without partner token" + ) + } + + input.partnerSpecific = { + eurobonusAccessToken: session?.token.access_token, + } + } + + const booking = await createBooking(input, ctx.token ?? ctx.serviceToken) + if ("error" in booking) { + return { ...booking } + } + + const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry + + return { + booking, + sig: encrypt(expire.toString()), + } + }) diff --git a/packages/trpc/lib/routers/booking/mutation/create/schema.ts b/packages/trpc/lib/routers/booking/mutation/createBookingRoute/schema.ts similarity index 55% rename from packages/trpc/lib/routers/booking/mutation/create/schema.ts rename to packages/trpc/lib/routers/booking/mutation/createBookingRoute/schema.ts index 4165d0ee1..ed37db4bd 100644 --- a/packages/trpc/lib/routers/booking/mutation/create/schema.ts +++ b/packages/trpc/lib/routers/booking/mutation/createBookingRoute/schema.ts @@ -1,11 +1,31 @@ import { z } from "zod" import { Lang } from "@scandic-hotels/common/constants/language" +import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" -import { langToApiLang } from "../../../../constants/apiLang" import { ChildBedTypeEnum } from "../../../../enums/childBedTypeEnum" -import { calculateRefId } from "../../../../utils/refId" -import { guestSchema } from "../../output" + +const paymentSchema = z.object({ + paymentMethod: z.nativeEnum(PaymentMethodEnum), + 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(), +}) const roomsSchema = z .array( @@ -75,28 +95,6 @@ const roomsSchema = z }) }) -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(), @@ -104,78 +102,10 @@ export const createBookingInput = z.object({ checkOutDate: z.string(), rooms: roomsSchema, payment: paymentSchema.optional(), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), + language: z.nativeEnum(Lang), partnerSpecific: z .object({ eurobonusAccessToken: z.string(), }) .optional(), }) - -export const createBookingSchema = z - .object({ - data: z.object({ - attributes: z.object({ - reservationStatus: z.string(), - guest: guestSchema.optional(), - paymentUrl: z.string().nullable().optional(), - paymentMethod: 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, - paymentMethod: d.data.attributes.paymentMethod, - 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, - })) -export type CreateBookingSchema = z.infer diff --git a/packages/trpc/lib/routers/booking/mutation/createRefIdRoute.ts b/packages/trpc/lib/routers/booking/mutation/createRefIdRoute.ts new file mode 100644 index 000000000..76fbb9663 --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/createRefIdRoute.ts @@ -0,0 +1,19 @@ +import { serverErrorByStatus } from "../../../errors" +import { serviceProcedure } from "../../../procedures" +import { encrypt } from "../../../utils/encryption" +import { createRefIdInput } from "../input" + +export const createRefIdRoute = serviceProcedure + .input(createRefIdInput) + .mutation(async function ({ input }) { + const { confirmationNumber, lastName } = input + const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`) + + if (!encryptedRefId) { + throw serverErrorByStatus(422, "Was not able to encrypt ref id") + } + + return { + refId: encryptedRefId, + } + }) diff --git a/packages/trpc/lib/routers/booking/mutation/guaranteeBookingRoute.ts b/packages/trpc/lib/routers/booking/mutation/guaranteeBookingRoute.ts new file mode 100644 index 000000000..9f1bebdc3 --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/guaranteeBookingRoute.ts @@ -0,0 +1,43 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" + +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { guaranteeBooking } from "../../../services/booking/guaranteeBooking" + +const guaranteeBookingInput = z.object({ + card: z + .object({ + alias: z.string(), + expiryDate: z.string(), + cardType: z.string(), + }) + .optional(), + language: z.nativeEnum(Lang), + success: z.string().nullable(), + error: z.string().nullable(), + cancel: z.string().nullable(), +}) + +const refIdPlugin = createRefIdPlugin() +export const guaranteeBookingRoute = safeProtectedServiceProcedure + .input(guaranteeBookingInput) + .concat(refIdPlugin.toConfirmationNumber) + .use(async ({ ctx, next }) => { + const token = await ctx.getScandicUserToken() + + return next({ + ctx: { + token, + }, + }) + }) + .mutation(async function ({ ctx, input }) { + const { confirmationNumber } = ctx + const { language, refId, ...body } = input + + const token = ctx.token ?? ctx.serviceToken + + return guaranteeBooking({ confirmationNumber, language, ...body }, token) + }) diff --git a/packages/trpc/lib/routers/booking/mutation/index.ts b/packages/trpc/lib/routers/booking/mutation/index.ts index 2d7ba4ab7..a418389d0 100644 --- a/packages/trpc/lib/routers/booking/mutation/index.ts +++ b/packages/trpc/lib/routers/booking/mutation/index.ts @@ -1,226 +1,34 @@ -import { createLogger } from "@scandic-hotels/common/logger/createLogger" +import { dt } from "@scandic-hotels/common/dt" 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 { updateBooking } from "../../../services/booking/updateBooking" import { - addPackageInput, - cancelBookingsInput, - guaranteeBookingInput, removePackageInput, resendConfirmationInput, updateBookingInput, } from "../input" -import { bookingConfirmationSchema } from "../output" -import { cancelBooking } from "../utils" -import { createBookingSchema } from "./create/schema" -import { create } from "./create" +import { addPackagesRoute } from "./addPackagesRoute" +import { cancelBookingRoute } from "./cancelBookingRoute" +import { createBookingRoute } from "./createBookingRoute" +import { createRefIdRoute } from "./createRefIdRoute" +import { guaranteeBookingRoute } from "./guaranteeBookingRoute" +import { priceChangeRoute } from "./priceChangeRoute" import { validatePartnerPayment } from "./validatePartnerPayment" const refIdPlugin = createRefIdPlugin() -const bookingLogger = createLogger("trpc.booking") export const bookingMutationRouter = router({ - create, + create: createBookingRoute, + createRefId: createRefIdRoute, validatePartnerPayment, - priceChange: safeProtectedServiceProcedure - .concat(refIdPlugin.toConfirmationNumber) - .use(async ({ ctx, next }) => { - const token = await ctx.getScandicUserToken() - - return next({ - ctx: { - token, - }, - }) - }) - .mutation(async function ({ ctx }) { - const { confirmationNumber } = ctx - - const priceChangeCounter = createCounter("trpc.booking.price-change") - const metricsPriceChange = priceChangeCounter.init({ confirmationNumber }) - - metricsPriceChange.start() - - const token = ctx.token ?? ctx.serviceToken - const headers = { - Authorization: `Bearer ${token}`, - } - - const apiResponse = await api.put( - api.endpoints.v1.Booking.priceChange(confirmationNumber), - { - headers, - } - ) - - if (!apiResponse.ok) { - await metricsPriceChange.httpError(apiResponse) - return null - } - - const apiJson = await apiResponse.json() - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsPriceChange.validationError(verifiedData.error) - return null - } - - metricsPriceChange.success() - - return verifiedData.data - }), - cancel: safeProtectedServiceProcedure - .input(cancelBookingsInput) - .concat(refIdPlugin.toConfirmationNumbers) - .use(async ({ ctx, next }) => { - const token = await ctx.getScandicUserToken() - - return next({ - ctx: { - token, - }, - }) - }) - .mutation(async function ({ ctx, input }) { - const { confirmationNumbers } = ctx - const { language } = input - - const token = ctx.token ?? ctx.serviceToken - const responses = await Promise.allSettled( - confirmationNumbers.map((confirmationNumber) => - cancelBooking(confirmationNumber, language, token) - ) - ) - - const cancelledRoomsSuccessfully: (string | null)[] = [] - for (const [idx, response] of responses.entries()) { - if (response.status === "fulfilled") { - if (response.value) { - cancelledRoomsSuccessfully.push(confirmationNumbers[idx]) - continue - } - } else { - bookingLogger.error( - `Cancelling booking failed for confirmationNumber: ${confirmationNumbers[idx]}`, - response.reason - ) - } - - cancelledRoomsSuccessfully.push(null) - } - - return cancelledRoomsSuccessfully - }), - packages: safeProtectedServiceProcedure - .input(addPackageInput) - .concat(refIdPlugin.toConfirmationNumber) - .use(async ({ ctx, next }) => { - const token = await ctx.getScandicUserToken() - return next({ - ctx: { - token, - }, - }) - }) - .mutation(async function ({ ctx, input }) { - const { confirmationNumber } = ctx - const { language, refId, ...body } = input - - const addPackageCounter = createCounter("trpc.booking.package.add") - const metricsAddPackage = addPackageCounter.init({ - confirmationNumber, - language, - }) - - metricsAddPackage.start() - - const token = ctx.token ?? ctx.serviceToken - const headers = { - Authorization: `Bearer ${token}`, - } - - const apiResponse = await api.post( - api.endpoints.v1.Booking.packages(confirmationNumber), - { - headers, - body: body, - }, - { language } - ) - - if (!apiResponse.ok) { - await metricsAddPackage.httpError(apiResponse) - return null - } - - const apiJson = await apiResponse.json() - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsAddPackage.validationError(verifiedData.error) - return null - } - - metricsAddPackage.success() - - return verifiedData.data - }), - guarantee: safeProtectedServiceProcedure - .input(guaranteeBookingInput) - .concat(refIdPlugin.toConfirmationNumber) - .use(async ({ ctx, next }) => { - const token = await ctx.getScandicUserToken() - - return next({ - ctx: { - token, - }, - }) - }) - .mutation(async function ({ ctx, input }) { - const { confirmationNumber } = ctx - const { language, refId, ...body } = input - - const guaranteeBookingCounter = createCounter("trpc.booking.guarantee") - const metricsGuaranteeBooking = guaranteeBookingCounter.init({ - confirmationNumber, - language, - }) - - metricsGuaranteeBooking.start() - - const token = ctx.token ?? ctx.serviceToken - const headers = { - Authorization: `Bearer ${token}`, - } - - const apiResponse = await api.put( - api.endpoints.v1.Booking.guarantee(confirmationNumber), - { - headers, - body: body, - }, - { language } - ) - - if (!apiResponse.ok) { - await metricsGuaranteeBooking.httpError(apiResponse) - return null - } - - const apiJson = await apiResponse.json() - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsGuaranteeBooking.validationError(verifiedData.error) - return null - } - - metricsGuaranteeBooking.success() - - return verifiedData.data - }), + priceChange: priceChangeRoute, + cancel: cancelBookingRoute, + packages: addPackagesRoute, + guarantee: guaranteeBookingRoute, update: safeProtectedServiceProcedure .input(updateBookingInput) .concat(refIdPlugin.toConfirmationNumber) @@ -235,43 +43,21 @@ export const bookingMutationRouter = router({ }) .mutation(async function ({ ctx, input }) { const { confirmationNumber } = ctx - const { language, refId, ...body } = input - - const updateBookingCounter = createCounter("trpc.booking.update") - const metricsUpdateBooking = updateBookingCounter.init({ - confirmationNumber, - language, - }) - - metricsUpdateBooking.start() + const { language, refId, ...rest } = input const token = ctx.token ?? ctx.serviceToken - const apiResponse = await api.put( - api.endpoints.v1.Booking.booking(confirmationNumber), + + return updateBooking( { - body, - headers: { - Authorization: `Bearer ${token}`, - }, + confirmationNumber, + lang: language, + checkInDate: rest.checkInDate ? dt.utc(rest.checkInDate) : undefined, + checkOutDate: rest.checkOutDate + ? dt.utc(rest.checkOutDate) + : undefined, + guest: rest.guest, }, - { language } + token ) - - if (!apiResponse.ok) { - await metricsUpdateBooking.httpError(apiResponse) - return null - } - - const apiJson = await apiResponse.json() - - const verifiedData = bookingConfirmationSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsUpdateBooking.validationError(verifiedData.error) - return null - } - - metricsUpdateBooking.success() - - return verifiedData.data }), removePackage: safeProtectedServiceProcedure .input(removePackageInput) diff --git a/packages/trpc/lib/routers/booking/mutation/priceChangeRoute.ts b/packages/trpc/lib/routers/booking/mutation/priceChangeRoute.ts new file mode 100644 index 000000000..3effe6e64 --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/priceChangeRoute.ts @@ -0,0 +1,24 @@ +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { priceChange } from "../../../services/booking/priceChange" +const refIdPlugin = createRefIdPlugin() + +export const priceChangeRoute = safeProtectedServiceProcedure + .concat(refIdPlugin.toConfirmationNumber) + .use(async ({ ctx, next }) => { + const token = await ctx.getScandicUserToken() + + return next({ + ctx: { + token, + }, + }) + }) + .mutation(async function ({ ctx }) { + const { confirmationNumber } = ctx + + return await priceChange( + { confirmationNumber }, + ctx.token ?? ctx.serviceToken + ) + }) diff --git a/packages/trpc/lib/routers/booking/mutation/validatePartnerPayment.ts b/packages/trpc/lib/routers/booking/mutation/validatePartnerPayment.ts index 6e2824efc..e75ec9b3a 100644 --- a/packages/trpc/lib/routers/booking/mutation/validatePartnerPayment.ts +++ b/packages/trpc/lib/routers/booking/mutation/validatePartnerPayment.ts @@ -1,6 +1,6 @@ import "server-only" -import z from "zod" +import { z } from "zod" import { createCounter } from "@scandic-hotels/common/telemetry" diff --git a/packages/trpc/lib/routers/booking/query.ts b/packages/trpc/lib/routers/booking/query.ts index 1628c4370..4b01defec 100644 --- a/packages/trpc/lib/routers/booking/query.ts +++ b/packages/trpc/lib/routers/booking/query.ts @@ -1,319 +1,12 @@ -import { createCounter } from "@scandic-hotels/common/telemetry" - import { router } from "../.." -import * as api from "../../api" -import { - badRequestError, - extractResponseDetails, - notFoundError, - serverErrorByStatus, -} from "../../errors" -import { createRefIdPlugin } from "../../plugins/refIdToConfirmationNumber" -import { - safeProtectedServiceProcedure, - serviceProcedure, -} from "../../procedures" -import { toApiLang } from "../../utils" -import { encrypt } from "../../utils/encryption" -import { isValidSession } from "../../utils/session" -import { getHotelPageUrls } from "../contentstack/hotelPage/utils" -import { getHotel } from "../hotels/services/getHotel" -import { createBookingSchema } from "./mutation/create/schema" -import { getHotelRoom } from "./helpers" -import { - createRefIdInput, - findBookingInput, - getBookingInput, - getBookingStatusInput, - getLinkedReservationsInput, -} from "./input" -import { findBooking, getBooking } from "./utils" - -const refIdPlugin = createRefIdPlugin() +import { findBookingRoute } from "./query/findBookingRoute" +import { getBookingRoute } from "./query/getBookingRoute" +import { getBookingStatusRoute } from "./query/getBookingStatusRoute" +import { getLinkedReservationsRoute } from "./query/getLinkedReservationsRoute" export const bookingQueryRouter = router({ - get: safeProtectedServiceProcedure - .input(getBookingInput) - .concat(refIdPlugin.toConfirmationNumber) - .use(async ({ ctx, input, next }) => { - const lang = input.lang ?? ctx.lang - const token = await ctx.getScandicUserToken() - - return next({ - ctx: { - lang, - token, - }, - }) - }) - .query(async function ({ ctx }) { - const { confirmationNumber, lang, token, serviceToken } = ctx - - const getBookingCounter = createCounter("trpc.booking.get") - const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) - - metricsGetBooking.start() - - const booking = await getBooking( - confirmationNumber, - lang, - token ?? serviceToken - ) - - if (!booking) { - metricsGetBooking.dataError( - `Fail to get booking data for ${confirmationNumber}`, - { confirmationNumber } - ) - return null - } - - const [hotelData, hotelPages] = await Promise.all([ - getHotel( - { - hotelId: booking.hotelId, - isCardOnlyPayment: false, - language: lang, - }, - serviceToken - ), - getHotelPageUrls(lang), - ]) - const hotelPage = hotelPages.find( - (page) => page.hotelId === booking.hotelId - ) - - if (!hotelData) { - metricsGetBooking.dataError( - `Failed to get hotel data for ${booking.hotelId}`, - { - hotelId: booking.hotelId, - } - ) - throw notFoundError({ - message: "Hotel data not found", - errorDetails: { hotelId: booking.hotelId }, - }) - } - - metricsGetBooking.success() - - return { - ...hotelData, - url: hotelPage?.url || null, - booking, - room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode), - } - }), - findBooking: safeProtectedServiceProcedure - .input(findBookingInput) - .use(async ({ ctx, input, next }) => { - const lang = input.lang ?? ctx.lang - const token = isValidSession(ctx.session) - ? ctx.session.token.access_token - : ctx.serviceToken - - return next({ - ctx: { - lang, - token, - }, - }) - }) - .query(async function ({ - ctx, - input: { confirmationNumber, lastName, firstName, email }, - }) { - const { lang, token, serviceToken } = ctx - const findBookingCounter = createCounter("trpc.booking.findBooking") - const metricsFindBooking = findBookingCounter.init({ confirmationNumber }) - - metricsFindBooking.start() - - const booking = await findBooking( - confirmationNumber, - lang, - token, - lastName, - firstName, - email - ) - - if (!booking) { - metricsFindBooking.dataError( - `Fail to find booking data for ${confirmationNumber}`, - { confirmationNumber } - ) - return null - } - - const [hotelData, hotelPages] = await Promise.all([ - getHotel( - { - hotelId: booking.hotelId, - isCardOnlyPayment: false, - language: lang, - }, - serviceToken - ), - getHotelPageUrls(lang), - ]) - const hotelPage = hotelPages.find( - (page) => page.hotelId === booking.hotelId - ) - - if (!hotelData) { - metricsFindBooking.dataError( - `Failed to find hotel data for ${booking.hotelId}`, - { - hotelId: booking.hotelId, - } - ) - - throw notFoundError({ - message: "Hotel data not found", - errorDetails: { hotelId: booking.hotelId }, - }) - } - - metricsFindBooking.success() - - return { - ...hotelData, - url: hotelPage?.url || null, - booking, - room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode), - } - }), - linkedReservations: safeProtectedServiceProcedure - .input(getLinkedReservationsInput) - .concat(refIdPlugin.toConfirmationNumber) - .use(async ({ ctx, input, next }) => { - const lang = input.lang ?? ctx.lang - const token = isValidSession(ctx.session) - ? ctx.session.token.access_token - : ctx.serviceToken - - return next({ - ctx: { - lang, - token, - }, - }) - }) - .query(async function ({ ctx }) { - const { confirmationNumber, lang, token } = ctx - - const getLinkedReservationsCounter = createCounter( - "trpc.booking.linkedReservations" - ) - const metricsGetLinkedReservations = getLinkedReservationsCounter.init({ - confirmationNumber, - }) - - metricsGetLinkedReservations.start() - - const booking = await getBooking(confirmationNumber, lang, token) - - if (!booking) { - return [] - } - - const linkedReservationsResults = await Promise.allSettled( - booking.linkedReservations.map((linkedReservation) => - getBooking(linkedReservation.confirmationNumber, lang, token) - ) - ) - - const linkedReservations = [] - for (const linkedReservationsResult of linkedReservationsResults) { - if (linkedReservationsResult.status === "fulfilled") { - if (linkedReservationsResult.value) { - linkedReservations.push(linkedReservationsResult.value) - } else { - metricsGetLinkedReservations.dataError( - `Unexpected value for linked reservation` - ) - } - } else { - metricsGetLinkedReservations.dataError( - `Failed to get linked reservation` - ) - } - } - - metricsGetLinkedReservations.success() - - return linkedReservations - }), - status: serviceProcedure - .input(getBookingStatusInput) - .concat(refIdPlugin.toConfirmationNumber) - .query(async function ({ ctx, input }) { - const lang = input.lang ?? ctx.lang - const { confirmationNumber } = ctx - const language = toApiLang(lang) - - const getBookingStatusCounter = createCounter("trpc.booking.status") - const metricsGetBookingStatus = getBookingStatusCounter.init({ - confirmationNumber, - }) - - metricsGetBookingStatus.start() - - const apiResponse = await api.get( - api.endpoints.v1.Booking.status(confirmationNumber), - { - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - }, - { - language, - } - ) - - if (!apiResponse.ok) { - await metricsGetBookingStatus.httpError(apiResponse) - throw serverErrorByStatus( - apiResponse.status, - await extractResponseDetails(apiResponse), - "getBookingStatus failed" - ) - } - - const apiJson = await apiResponse.json() - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsGetBookingStatus.validationError(verifiedData.error) - - throw badRequestError({ - message: "Invalid booking data", - errorDetails: verifiedData.error.formErrors, - }) - } - - metricsGetBookingStatus.success() - - const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry - - return { - booking: verifiedData.data, - sig: encrypt(expire.toString()), - } - }), - createRefId: serviceProcedure - .input(createRefIdInput) - .mutation(async function ({ input }) { - const { confirmationNumber, lastName } = input - const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`) - - if (!encryptedRefId) { - throw serverErrorByStatus(422, "Was not able to encrypt ref id") - } - - return { - refId: encryptedRefId, - } - }), + get: getBookingRoute, + findBooking: findBookingRoute, + linkedReservations: getLinkedReservationsRoute, + status: getBookingStatusRoute, }) diff --git a/packages/trpc/lib/routers/booking/query/findBookingRoute.ts b/packages/trpc/lib/routers/booking/query/findBookingRoute.ts new file mode 100644 index 000000000..5518819a3 --- /dev/null +++ b/packages/trpc/lib/routers/booking/query/findBookingRoute.ts @@ -0,0 +1,87 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { notFoundError } from "../../../errors" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { findBooking } from "../../../services/booking/findBooking" +import { isValidSession } from "../../../utils/session" +import { getHotelPageUrls } from "../../contentstack/hotelPage/utils" +import { getHotel } from "../../hotels/services/getHotel" +import { getHotelRoom } from "../helpers" +import { findBookingInput } from "../input" + +export const findBookingRoute = safeProtectedServiceProcedure + .input(findBookingInput) + .use(async ({ ctx, input, next }) => { + const lang = input.lang ?? ctx.lang + const token = isValidSession(ctx.session) + ? ctx.session.token.access_token + : ctx.serviceToken + + return next({ + ctx: { + lang, + token, + }, + }) + }) + .query(async function ({ + ctx, + input: { confirmationNumber, lastName, firstName, email }, + }) { + const { lang, token, serviceToken } = ctx + const findBookingCounter = createCounter("trpc.booking.findBooking") + const metricsFindBooking = findBookingCounter.init({ confirmationNumber }) + + metricsFindBooking.start() + + const booking = await findBooking( + { confirmationNumber, lang, lastName, firstName, email }, + token + ) + + if (!booking) { + metricsFindBooking.dataError( + `Fail to find booking data for ${confirmationNumber}`, + { confirmationNumber } + ) + return null + } + + const [hotelData, hotelPages] = await Promise.all([ + getHotel( + { + hotelId: booking.hotelId, + isCardOnlyPayment: false, + language: lang, + }, + serviceToken + ), + getHotelPageUrls(lang), + ]) + const hotelPage = hotelPages.find( + (page) => page.hotelId === booking.hotelId + ) + + if (!hotelData) { + metricsFindBooking.dataError( + `Failed to find hotel data for ${booking.hotelId}`, + { + hotelId: booking.hotelId, + } + ) + + throw notFoundError({ + message: "Hotel data not found", + errorDetails: { hotelId: booking.hotelId }, + }) + } + + metricsFindBooking.success() + + return { + ...hotelData, + url: hotelPage?.url || null, + booking, + room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode), + } + }) diff --git a/packages/trpc/lib/routers/booking/query/getBookingRoute.ts b/packages/trpc/lib/routers/booking/query/getBookingRoute.ts new file mode 100644 index 000000000..b59dec601 --- /dev/null +++ b/packages/trpc/lib/routers/booking/query/getBookingRoute.ts @@ -0,0 +1,84 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { notFoundError } from "../../../errors" +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { getBooking } from "../../../services/booking/getBooking" +import { getHotelPageUrls } from "../../contentstack/hotelPage/utils" +import { getHotel } from "../../hotels/services/getHotel" +import { getHotelRoom } from "../helpers" +import { getBookingInput } from "../input" + +const refIdPlugin = createRefIdPlugin() +export const getBookingRoute = safeProtectedServiceProcedure + .input(getBookingInput) + .concat(refIdPlugin.toConfirmationNumber) + .use(async ({ ctx, input, next }) => { + const lang = input.lang ?? ctx.lang + const token = await ctx.getScandicUserToken() + + return next({ + ctx: { + lang, + token, + }, + }) + }) + .query(async function ({ ctx }) { + const { confirmationNumber, lang, token, serviceToken } = ctx + + const getBookingCounter = createCounter("trpc.booking.get") + const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) + + metricsGetBooking.start() + + const booking = await getBooking( + { confirmationNumber, lang }, + token ?? serviceToken + ) + + if (!booking) { + metricsGetBooking.dataError( + `Fail to get booking data for ${confirmationNumber}`, + { confirmationNumber } + ) + return null + } + + const [hotelData, hotelPages] = await Promise.all([ + getHotel( + { + hotelId: booking.hotelId, + isCardOnlyPayment: false, + language: lang, + }, + serviceToken + ), + getHotelPageUrls(lang), + ]) + const hotelPage = hotelPages.find( + (page) => page.hotelId === booking.hotelId + ) + + if (!hotelData) { + metricsGetBooking.dataError( + `Failed to get hotel data for ${booking.hotelId}`, + { + hotelId: booking.hotelId, + } + ) + throw notFoundError({ + message: "Hotel data not found", + errorDetails: { hotelId: booking.hotelId }, + }) + } + + metricsGetBooking.success() + + return { + ...hotelData, + url: hotelPage?.url || null, + booking, + room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode), + } + }) diff --git a/packages/trpc/lib/routers/booking/query/getBookingStatusRoute.ts b/packages/trpc/lib/routers/booking/query/getBookingStatusRoute.ts new file mode 100644 index 000000000..5a7191654 --- /dev/null +++ b/packages/trpc/lib/routers/booking/query/getBookingStatusRoute.ts @@ -0,0 +1,33 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" + +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { getBookingStatus } from "../../../services/booking/getBookingStatus" +import { encrypt } from "../../../utils/encryption" + +const getBookingStatusInput = z.object({ + lang: z.nativeEnum(Lang).optional(), +}) + +const refIdPlugin = createRefIdPlugin() +export const getBookingStatusRoute = safeProtectedServiceProcedure + .input(getBookingStatusInput) + .concat(refIdPlugin.toConfirmationNumber) + .query(async function ({ ctx, input }) { + const lang = input.lang ?? ctx.lang + const { confirmationNumber } = ctx + + const booking = await getBookingStatus( + { confirmationNumber, lang }, + ctx.serviceToken + ) + + const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry + + return { + booking, + sig: encrypt(expire.toString()), + } + }) diff --git a/packages/trpc/lib/routers/booking/query/getLinkedReservationsRoute.ts b/packages/trpc/lib/routers/booking/query/getLinkedReservationsRoute.ts new file mode 100644 index 000000000..d6d00f82d --- /dev/null +++ b/packages/trpc/lib/routers/booking/query/getLinkedReservationsRoute.ts @@ -0,0 +1,34 @@ +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { getLinkedReservations } from "../../../services/booking/linkedReservations" +import { isValidSession } from "../../../utils/session" +import { getLinkedReservationsInput } from "../input" + +const refIdPlugin = createRefIdPlugin() +export const getLinkedReservationsRoute = safeProtectedServiceProcedure + .input(getLinkedReservationsInput) + .concat(refIdPlugin.toConfirmationNumber) + .use(async ({ ctx, input, next }) => { + const lang = input.lang ?? ctx.lang + const token = isValidSession(ctx.session) + ? ctx.session.token.access_token + : ctx.serviceToken + + return next({ + ctx: { + lang, + token, + }, + }) + }) + .query(async function ({ ctx }) { + const { confirmationNumber, lang, token } = ctx + + return getLinkedReservations( + { + confirmationNumber, + lang, + }, + token + ) + }) diff --git a/packages/trpc/lib/routers/booking/utils.ts b/packages/trpc/lib/routers/booking/utils.ts deleted file mode 100644 index 11b922652..000000000 --- a/packages/trpc/lib/routers/booking/utils.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createCounter } from "@scandic-hotels/common/telemetry" - -import * as api from "../../api" -import { - badRequestError, - extractResponseDetails, - serverErrorByStatus, -} from "../../errors" -import { toApiLang } from "../../utils" -import { createBookingSchema } from "./mutation/create/schema" -import { bookingConfirmationSchema } from "./output" - -import type { Lang } from "@scandic-hotels/common/constants/language" - -export async function getBooking( - confirmationNumber: string, - lang: Lang, - token: string -) { - const getBookingCounter = createCounter("booking.get") - const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) - - metricsGetBooking.start() - - const apiResponse = await api.get( - api.endpoints.v1.Booking.booking(confirmationNumber), - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - { language: toApiLang(lang) } - ) - - if (!apiResponse.ok) { - await metricsGetBooking.httpError(apiResponse) - - // If the booking is not found, return null. - // This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them. - if (apiResponse.status === 404) { - return null - } - - throw serverErrorByStatus( - apiResponse.status, - await extractResponseDetails(apiResponse), - "getBooking failed" - ) - } - - const apiJson = await apiResponse.json() - const booking = bookingConfirmationSchema.safeParse(apiJson) - if (!booking.success) { - metricsGetBooking.validationError(booking.error) - throw badRequestError() - } - - metricsGetBooking.success() - - return booking.data -} - -export async function findBooking( - confirmationNumber: string, - lang: Lang, - token: string, - lastName?: string, - firstName?: string, - email?: string -) { - const findBookingCounter = createCounter("booking.find") - const metricsGetBooking = findBookingCounter.init({ - confirmationNumber, - lastName, - firstName, - email, - }) - - metricsGetBooking.start() - - const apiResponse = await api.post( - api.endpoints.v1.Booking.find(confirmationNumber), - { - headers: { - Authorization: `Bearer ${token}`, - }, - body: { - lastName, - firstName, - email, - }, - }, - { language: toApiLang(lang) } - ) - - if (!apiResponse.ok) { - await metricsGetBooking.httpError(apiResponse) - - // If the booking is not found, return null. - // This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them. - if (apiResponse.status === 400 || apiResponse.status === 404) { - return null - } - - throw serverErrorByStatus( - apiResponse.status, - await extractResponseDetails(apiResponse), - "findBooking failed" - ) - } - - const apiJson = await apiResponse.json() - const booking = bookingConfirmationSchema.safeParse(apiJson) - if (!booking.success) { - metricsGetBooking.validationError(booking.error) - throw badRequestError() - } - - metricsGetBooking.success() - - return booking.data -} - -export async function cancelBooking( - confirmationNumber: string, - language: Lang, - token: string -) { - const cancelBookingCounter = createCounter("booking.cancel") - const metricsCancelBooking = cancelBookingCounter.init({ - confirmationNumber, - language, - }) - - metricsCancelBooking.start() - - const headers = { - Authorization: `Bearer ${token}`, - } - - const booking = await getBooking(confirmationNumber, language, token) - if (!booking) { - metricsCancelBooking.noDataError({ confirmationNumber }) - return null - } - const { firstName, lastName, email } = booking.guest - const apiResponse = await api.remove( - api.endpoints.v1.Booking.cancel(confirmationNumber), - { - headers, - body: { firstName, lastName, email }, - }, - { language: toApiLang(language) } - ) - - if (!apiResponse.ok) { - await metricsCancelBooking.httpError(apiResponse) - return null - } - - const apiJson = await apiResponse.json() - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsCancelBooking.validationError(verifiedData.error) - return null - } - - metricsCancelBooking.success() - - return verifiedData.data -} diff --git a/packages/trpc/lib/routers/contentstack/schemas/alert.ts b/packages/trpc/lib/routers/contentstack/schemas/alert.ts index ebe083549..15c20aee7 100644 --- a/packages/trpc/lib/routers/contentstack/schemas/alert.ts +++ b/packages/trpc/lib/routers/contentstack/schemas/alert.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { z } from "zod" import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" diff --git a/packages/trpc/lib/services/booking/addPackageToBooking/index.ts b/packages/trpc/lib/services/booking/addPackageToBooking/index.ts new file mode 100644 index 000000000..4f97f19a9 --- /dev/null +++ b/packages/trpc/lib/services/booking/addPackageToBooking/index.ts @@ -0,0 +1,60 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { toApiLang } from "../../../utils" +import { bookingConfirmationSchema } from "../getBooking/schema" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function addPackageToBooking( + { + lang, + ...input + }: { + confirmationNumber: string + lang: Lang + packages: { + code: string + quantity: number + comment?: string | undefined + }[] + ancillaryComment: string + ancillaryDeliveryTime?: string | null | undefined + }, + token: string +) { + const addPackageCounter = createCounter("trpc.booking.package.add") + const metricsAddPackage = addPackageCounter.init({ + confirmationNumber: input.confirmationNumber, + language: lang, + }) + + metricsAddPackage.start() + + const apiResponse = await api.post( + api.endpoints.v1.Booking.packages(input.confirmationNumber), + { + headers: { + Authorization: `Bearer ${token}`, + }, + body: input, + }, + { language: toApiLang(lang) } + ) + + if (!apiResponse.ok) { + await metricsAddPackage.httpError(apiResponse) + return null + } + + const apiJson = await apiResponse.json() + const verifiedData = bookingConfirmationSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsAddPackage.validationError(verifiedData.error) + return null + } + + metricsAddPackage.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/services/booking/cancelBooking/index.ts b/packages/trpc/lib/services/booking/cancelBooking/index.ts new file mode 100644 index 000000000..ff40d52cd --- /dev/null +++ b/packages/trpc/lib/services/booking/cancelBooking/index.ts @@ -0,0 +1,76 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { + badGatewayError, + extractResponseDetails, + serverErrorByStatus, +} from "../../../errors" +import { toApiLang } from "../../../utils" +import { getBooking } from "../getBooking" +import { cancelBookingSchema } from "./schema" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +import type { CancelBooking } from "./schema" + +export async function cancelBooking( + { + confirmationNumber, + language, + }: { confirmationNumber: string; language: Lang }, + token: string +): Promise { + const cancelBookingCounter = createCounter("booking.cancel") + const metricsCancelBooking = cancelBookingCounter.init({ + confirmationNumber, + language, + }) + + metricsCancelBooking.start() + + const headers = { + Authorization: `Bearer ${token}`, + } + + const booking = await getBooking( + { confirmationNumber, lang: language }, + token + ) + if (!booking) { + metricsCancelBooking.noDataError({ confirmationNumber }) + return null + } + const { firstName, lastName, email } = booking.guest + const apiResponse = await api.remove( + api.endpoints.v1.Booking.cancel(confirmationNumber), + { + headers, + body: { firstName, lastName, email }, + }, + { language: toApiLang(language) } + ) + + if (!apiResponse.ok) { + await metricsCancelBooking.httpError(apiResponse) + throw serverErrorByStatus( + apiResponse.status, + await extractResponseDetails(apiResponse), + `cancelBooking failed for ${confirmationNumber}` + ) + } + + const apiJson = await apiResponse.json() + const verifiedData = cancelBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsCancelBooking.validationError(verifiedData.error) + throw badGatewayError({ + message: "Invalid response from cancelBooking", + errorDetails: { validationError: verifiedData.error }, + }) + } + + metricsCancelBooking.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/services/booking/cancelBooking/schema.ts b/packages/trpc/lib/services/booking/cancelBooking/schema.ts new file mode 100644 index 000000000..04b1d9927 --- /dev/null +++ b/packages/trpc/lib/services/booking/cancelBooking/schema.ts @@ -0,0 +1,56 @@ +import { z } from "zod" + +import { bookingReservationStatusSchema } from "../schema/bookingReservationStatusSchema" + +export type CancelBooking = z.infer +export const cancelBookingSchema = z + .object({ + data: z.object({ + attributes: z.object({ + reservationStatus: bookingReservationStatusSchema, + paymentUrl: z.string().nullable().optional(), + paymentMethod: 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(), + }), + }) + .transform((apiResponse) => { + return { + id: apiResponse.data.id, + type: apiResponse.data.type, + reservationStatus: apiResponse.data.attributes.reservationStatus, + paymentUrl: apiResponse.data.attributes.paymentUrl, + paymentMethod: apiResponse.data.attributes.paymentMethod, + rooms: apiResponse.data.attributes.rooms, + errors: apiResponse.data.attributes.errors, + } + }) diff --git a/packages/trpc/lib/services/booking/createBooking/index.ts b/packages/trpc/lib/services/booking/createBooking/index.ts new file mode 100644 index 000000000..7722374c9 --- /dev/null +++ b/packages/trpc/lib/services/booking/createBooking/index.ts @@ -0,0 +1,88 @@ +import "server-only" + +import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { + badGatewayError, + extractResponseDetails, + serverErrorByStatus, +} from "../../../errors" +import { toApiLang } from "../../../utils" +import { createBookingSchema } from "./schema" + +import type { CreateBookingInput } from "../../../routers/booking/mutation/createBookingRoute/schema" + +export async function createBooking(input: CreateBookingInput, token: string) { + validateInputData(input) + + const createBookingCounter = createCounter("trpc.booking.create") + + const metricsCreateBooking = createBookingCounter.init({ + ...input, + rooms: input.rooms.map(({ guest, ...room }) => { + const { becomeMember, membershipNumber } = guest + return { ...room, guest: { becomeMember, membershipNumber } } + }), + }) + + metricsCreateBooking.start() + + const apiResponse = await api.post( + api.endpoints.v1.Booking.bookings, + { + headers: { + Authorization: `Bearer ${token}`, + }, + body: input, + }, + { language: toApiLang(input.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 + } + + throw serverErrorByStatus( + apiResponse.status, + await extractResponseDetails(apiResponse), + "createBooking failed" + ) + } + + const apiJson = await apiResponse.json() + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsCreateBooking.validationError(verifiedData.error) + throw badGatewayError({ + message: "Invalid response from createBooking", + errorDetails: { validationError: verifiedData.error }, + }) + } + + metricsCreateBooking.success() + + return verifiedData.data +} + +function validateInputData(input: CreateBookingInput) { + if (!input.payment) { + return + } + + if (input.payment.paymentMethod !== PaymentMethodEnum.PartnerPoints) { + return + } + + if (!input.partnerSpecific?.eurobonusAccessToken) { + throw new Error( + "Missing partnerSpecific data for PartnerPoints payment method" + ) + } +} diff --git a/packages/trpc/lib/services/booking/createBooking/schema.ts b/packages/trpc/lib/services/booking/createBooking/schema.ts new file mode 100644 index 000000000..4394bd965 --- /dev/null +++ b/packages/trpc/lib/services/booking/createBooking/schema.ts @@ -0,0 +1,84 @@ +import { z } from "zod" + +import { + nullableStringEmailValidator, + nullableStringValidator, +} from "@scandic-hotels/common/utils/zod/stringValidator" + +import { calculateRefId } from "../../../utils/refId" + +const guestSchema = z.object({ + email: nullableStringEmailValidator, + firstName: nullableStringValidator, + lastName: nullableStringValidator, + membershipNumber: nullableStringValidator, + phoneNumber: nullableStringValidator, + countryCode: nullableStringValidator, +}) + +export const createBookingSchema = z + .object({ + data: z.object({ + attributes: z.object({ + reservationStatus: z.string(), + guest: guestSchema.optional(), + paymentUrl: z.string().nullable().optional(), + paymentMethod: 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, + paymentMethod: d.data.attributes.paymentMethod, + 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/services/booking/findBooking/index.ts b/packages/trpc/lib/services/booking/findBooking/index.ts new file mode 100644 index 000000000..2756671a9 --- /dev/null +++ b/packages/trpc/lib/services/booking/findBooking/index.ts @@ -0,0 +1,81 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { + badRequestError, + extractResponseDetails, + serverErrorByStatus, +} from "../../../errors" +import { toApiLang } from "../../../utils" +import { bookingConfirmationSchema } from "../getBooking/schema" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function findBooking( + { + confirmationNumber, + lang, + lastName, + firstName, + email, + }: { + confirmationNumber: string + lang: Lang + lastName?: string + firstName?: string + email?: string + }, + token: string +) { + const findBookingCounter = createCounter("booking.find") + const metricsGetBooking = findBookingCounter.init({ + confirmationNumber, + lastName, + firstName, + email, + }) + + metricsGetBooking.start() + + const apiResponse = await api.post( + api.endpoints.v1.Booking.find(confirmationNumber), + { + headers: { + Authorization: `Bearer ${token}`, + }, + body: { + lastName, + firstName, + email, + }, + }, + { language: toApiLang(lang) } + ) + + if (!apiResponse.ok) { + await metricsGetBooking.httpError(apiResponse) + + // If the booking is not found, return null. + // This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them. + if (apiResponse.status === 400 || apiResponse.status === 404) { + return null + } + + throw serverErrorByStatus( + apiResponse.status, + await extractResponseDetails(apiResponse), + "findBooking failed" + ) + } + + const apiJson = await apiResponse.json() + const booking = bookingConfirmationSchema.safeParse(apiJson) + if (!booking.success) { + metricsGetBooking.validationError(booking.error) + throw badRequestError() + } + + metricsGetBooking.success() + + return booking.data +} diff --git a/packages/trpc/lib/services/booking/getBooking/index.ts b/packages/trpc/lib/services/booking/getBooking/index.ts new file mode 100644 index 000000000..c1935754a --- /dev/null +++ b/packages/trpc/lib/services/booking/getBooking/index.ts @@ -0,0 +1,59 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { + badRequestError, + extractResponseDetails, + serverErrorByStatus, +} from "../../../errors" +import { toApiLang } from "../../../utils" +import { bookingConfirmationSchema } from "./schema" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function getBooking( + { confirmationNumber, lang }: { confirmationNumber: string; lang: Lang }, + token: string +) { + const getBookingCounter = createCounter("booking.get") + const metricsGetBooking = getBookingCounter.init({ confirmationNumber }) + + metricsGetBooking.start() + + const apiResponse = await api.get( + api.endpoints.v1.Booking.booking(confirmationNumber), + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + { language: toApiLang(lang) } + ) + + if (!apiResponse.ok) { + await metricsGetBooking.httpError(apiResponse) + + // If the booking is not found, return null. + // This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them. + if (apiResponse.status === 404) { + return null + } + + throw serverErrorByStatus( + apiResponse.status, + await extractResponseDetails(apiResponse), + "getBooking failed" + ) + } + + const apiJson = await apiResponse.json() + const booking = bookingConfirmationSchema.safeParse(apiJson) + if (!booking.success) { + metricsGetBooking.validationError(booking.error) + throw badRequestError() + } + + metricsGetBooking.success() + + return booking.data +} diff --git a/packages/trpc/lib/routers/booking/output.ts b/packages/trpc/lib/services/booking/getBooking/schema.ts similarity index 93% rename from packages/trpc/lib/routers/booking/output.ts rename to packages/trpc/lib/services/booking/getBooking/schema.ts index ef8674fc1..10ccba680 100644 --- a/packages/trpc/lib/routers/booking/output.ts +++ b/packages/trpc/lib/services/booking/getBooking/schema.ts @@ -8,57 +8,18 @@ import { nullableStringValidator, } from "@scandic-hotels/common/utils/zod/stringValidator" -import { BookingStatusEnum } from "../../enums/bookingStatus" -import { BreakfastPackageEnum } from "../../enums/breakfast" -import { ChildBedTypeEnum } from "../../enums/childBedTypeEnum" -import { calculateRefId } from "../../utils/refId" +import { BookingStatusEnum } from "../../../enums/bookingStatus" +import { BreakfastPackageEnum } from "../../../enums/breakfast" +import { ChildBedTypeEnum } from "../../../enums/childBedTypeEnum" +import { calculateRefId } from "../../../utils/refId" +import { bookingReservationStatusSchema } from "../schema/bookingReservationStatusSchema" -export const guestSchema = z.object({ - email: nullableStringEmailValidator, - firstName: nullableStringValidator, - lastName: nullableStringValidator, - membershipNumber: nullableStringValidator, - phoneNumber: nullableStringValidator, - countryCode: nullableStringValidator, -}) - -export type Guest = z.output - -// QUERY const childBedPreferencesSchema = z.object({ bedType: z.nativeEnum(ChildBedTypeEnum), quantity: z.number().int(), code: z.string().nullable().default(""), }) -const priceSchema = z.object({ - currency: z.nativeEnum(CurrencyEnum).default(CurrencyEnum.Unknown), - totalPrice: z.number().nullish(), - totalUnit: z.number().int().nullish(), - unit: z.number().int().nullish(), - unitPrice: z.number(), -}) - -export const packageSchema = z - .object({ - code: nullableStringValidator, - comment: z.string().nullish(), - description: nullableStringValidator, - price: priceSchema, - type: z.string().nullish(), - }) - .transform((packageData) => ({ - code: packageData.code, - comment: packageData.comment, - currency: packageData.price.currency, - description: packageData.description, - totalPrice: packageData.price.totalPrice ?? 0, - totalUnit: packageData.price.totalUnit ?? 0, - type: packageData.type, - unit: packageData.price.unit ?? 0, - unitPrice: packageData.price.unitPrice, - })) - const ancillarySchema = z .object({ comment: z.string().default(""), @@ -82,7 +43,16 @@ const rateDefinitionSchema = z.object({ isCampaignRate: z.boolean().default(false), }) -export const linkedReservationSchema = z.object({ +const guestSchema = z.object({ + email: nullableStringEmailValidator, + firstName: nullableStringValidator, + lastName: nullableStringValidator, + membershipNumber: nullableStringValidator, + phoneNumber: nullableStringValidator, + countryCode: nullableStringValidator, +}) + +const linkedReservationSchema = z.object({ confirmationNumber: z.string().default(""), hotelId: z.string().default(""), checkinDate: z.string(), @@ -137,6 +107,34 @@ const linksSchema = z.object({ .nullable(), }) +const priceSchema = z.object({ + currency: z.nativeEnum(CurrencyEnum).default(CurrencyEnum.Unknown), + totalPrice: z.number().nullish(), + totalUnit: z.number().int().nullish(), + unit: z.number().int().nullish(), + unitPrice: z.number(), +}) + +export const packageSchema = z + .object({ + code: nullableStringValidator, + comment: z.string().nullish(), + description: nullableStringValidator, + price: priceSchema, + type: z.string().nullish(), + }) + .transform((packageData) => ({ + code: packageData.code, + comment: packageData.comment, + currency: packageData.price.currency, + description: packageData.description, + totalPrice: packageData.price.totalPrice ?? 0, + totalUnit: packageData.price.totalUnit ?? 0, + type: packageData.type, + unit: packageData.price.unit ?? 0, + unitPrice: packageData.price.unitPrice, + })) + export const bookingConfirmationSchema = z .object({ data: z.object({ @@ -174,7 +172,7 @@ export const bookingConfirmationSchema = z multiRoom: z.boolean(), packages: z.array(packageSchema).default([]), rateDefinition: rateDefinitionSchema, - reservationStatus: z.string().nullable().default(""), + reservationStatus: bookingReservationStatusSchema, roomPoints: z.number(), roomPointType: z .enum(["Scandic", "EuroBonus"]) diff --git a/packages/trpc/lib/services/booking/getBookingStatus/index.ts b/packages/trpc/lib/services/booking/getBookingStatus/index.ts new file mode 100644 index 000000000..a71e434fe --- /dev/null +++ b/packages/trpc/lib/services/booking/getBookingStatus/index.ts @@ -0,0 +1,65 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { + badRequestError, + extractResponseDetails, + serverErrorByStatus, +} from "../../../errors" +import { toApiLang } from "../../../utils" +import { bookingStatusSchema } from "./schema" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +import type { BookingStatus } from "./schema" +export type { BookingStatus } from "./schema" + +export async function getBookingStatus( + { confirmationNumber, lang }: { confirmationNumber: string; lang: Lang }, + serviceToken: string +): Promise { + const language = toApiLang(lang) + + const getBookingStatusCounter = createCounter("trpc.booking.status") + const metricsGetBookingStatus = getBookingStatusCounter.init({ + confirmationNumber, + }) + + metricsGetBookingStatus.start() + + const apiResponse = await api.get( + api.endpoints.v1.Booking.status(confirmationNumber), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + { + language, + } + ) + + if (!apiResponse.ok) { + await metricsGetBookingStatus.httpError(apiResponse) + throw serverErrorByStatus( + apiResponse.status, + await extractResponseDetails(apiResponse), + "getBookingStatus failed" + ) + } + + const apiJson = await apiResponse.json() + const verifiedData = bookingStatusSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsGetBookingStatus.validationError(verifiedData.error) + + throw badRequestError({ + message: "Invalid booking data", + errorDetails: verifiedData.error.formErrors, + }) + } + + metricsGetBookingStatus.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/services/booking/getBookingStatus/schema.ts b/packages/trpc/lib/services/booking/getBookingStatus/schema.ts new file mode 100644 index 000000000..e0508b598 --- /dev/null +++ b/packages/trpc/lib/services/booking/getBookingStatus/schema.ts @@ -0,0 +1,61 @@ +import { z } from "zod" + +import { calculateRefId } from "../../../utils/refId" +import { bookingReservationStatusSchema } from "../schema/bookingReservationStatusSchema" + +export type BookingStatus = z.infer +export const bookingStatusSchema = z + .object({ + data: z.object({ + attributes: z.object({ + reservationStatus: bookingReservationStatusSchema, + paymentUrl: z.string().nullable().optional(), + paymentMethod: 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(), + }), + }) + .transform((d) => ({ + id: d.data.id, + type: d.data.type, + reservationStatus: d.data.attributes.reservationStatus, + paymentUrl: d.data.attributes.paymentUrl, + paymentMethod: d.data.attributes.paymentMethod, + errors: d.data.attributes.errors, + rooms: d.data.attributes.rooms.map((room) => { + const lastName = "" + return { + ...room, + refId: calculateRefId(room.confirmationNumber, lastName), + } + }), + })) diff --git a/packages/trpc/lib/services/booking/guaranteeBooking/index.ts b/packages/trpc/lib/services/booking/guaranteeBooking/index.ts new file mode 100644 index 000000000..ad2154d86 --- /dev/null +++ b/packages/trpc/lib/services/booking/guaranteeBooking/index.ts @@ -0,0 +1,63 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { langToApiLang } from "../../../constants/apiLang" +import { slimBookingSchema } from "../schema" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function guaranteeBooking( + { + confirmationNumber, + language, + success, + error, + cancel, + card, + }: { + confirmationNumber: string + language: Lang + success: string | null + error: string | null + cancel: string | null + card?: { alias: string; expiryDate: string; cardType: string } + }, + token: string +) { + const guaranteeBookingCounter = createCounter("trpc.booking.guarantee") + const metricsGuaranteeBooking = guaranteeBookingCounter.init({ + confirmationNumber, + language, + }) + + metricsGuaranteeBooking.start() + + const headers = { + Authorization: `Bearer ${token}`, + } + + const apiResponse = await api.put( + api.endpoints.v1.Booking.guarantee(confirmationNumber), + { + headers, + body: { success, error, cancel, card }, + }, + { language: langToApiLang[language] } + ) + + if (!apiResponse.ok) { + await metricsGuaranteeBooking.httpError(apiResponse) + return null + } + + const apiJson = await apiResponse.json() + const verifiedData = slimBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsGuaranteeBooking.validationError(verifiedData.error) + return null + } + + metricsGuaranteeBooking.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/services/booking/linkedReservations/index.ts b/packages/trpc/lib/services/booking/linkedReservations/index.ts new file mode 100644 index 000000000..f59135181 --- /dev/null +++ b/packages/trpc/lib/services/booking/linkedReservations/index.ts @@ -0,0 +1,64 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { getBooking } from "../getBooking" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function getLinkedReservations( + { + confirmationNumber, + lang, + }: { + confirmationNumber: string + lang: Lang + }, + token: string +): Promise>>[]> { + const getLinkedReservationsCounter = createCounter( + "booking.linkedReservations" + ) + + const metricsGetLinkedReservations = getLinkedReservationsCounter.init({ + confirmationNumber, + }) + + metricsGetLinkedReservations.start() + + const booking = await getBooking({ confirmationNumber, lang }, token) + + if (!booking) { + return [] + } + + const linkedReservationsResults = await Promise.allSettled( + booking.linkedReservations.map((linkedReservation) => + getBooking( + { confirmationNumber: linkedReservation.confirmationNumber, lang }, + token + ) + ) + ) + + const linkedReservations: NonNullable< + Awaited> + >[] = [] + for (const linkedReservationsResult of linkedReservationsResults) { + if (linkedReservationsResult.status !== "fulfilled") { + metricsGetLinkedReservations.dataError(`Failed to get linked reservation`) + continue + } + + if (!linkedReservationsResult.value) { + metricsGetLinkedReservations.dataError( + `Unexpected value for linked reservation` + ) + continue + } + + linkedReservations.push(linkedReservationsResult.value) + } + + metricsGetLinkedReservations.success() + + return linkedReservations +} diff --git a/packages/trpc/lib/services/booking/priceChange/index.ts b/packages/trpc/lib/services/booking/priceChange/index.ts new file mode 100644 index 000000000..69d1404d8 --- /dev/null +++ b/packages/trpc/lib/services/booking/priceChange/index.ts @@ -0,0 +1,41 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { slimBookingSchema } from "../schema" + +export async function priceChange( + { confirmationNumber }: { confirmationNumber: string }, + token: string +) { + const priceChangeCounter = createCounter("trpc.booking.price-change") + const metricsPriceChange = priceChangeCounter.init({ confirmationNumber }) + + metricsPriceChange.start() + + const headers = { + Authorization: `Bearer ${token}`, + } + + const apiResponse = await api.put( + api.endpoints.v1.Booking.priceChange(confirmationNumber), + { + headers, + } + ) + + if (!apiResponse.ok) { + await metricsPriceChange.httpError(apiResponse) + return null + } + + const apiJson = await apiResponse.json() + const verifiedData = slimBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsPriceChange.validationError(verifiedData.error) + return null + } + + metricsPriceChange.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/services/booking/schema.ts b/packages/trpc/lib/services/booking/schema.ts new file mode 100644 index 000000000..2686b1be7 --- /dev/null +++ b/packages/trpc/lib/services/booking/schema.ts @@ -0,0 +1,84 @@ +import { z } from "zod" + +import { + nullableStringEmailValidator, + nullableStringValidator, +} from "@scandic-hotels/common/utils/zod/stringValidator" + +import { calculateRefId } from "../../utils/refId" + +const guestSchema = z.object({ + email: nullableStringEmailValidator, + firstName: nullableStringValidator, + lastName: nullableStringValidator, + membershipNumber: nullableStringValidator, + phoneNumber: nullableStringValidator, + countryCode: nullableStringValidator, +}) + +export const slimBookingSchema = z + .object({ + data: z.object({ + attributes: z.object({ + reservationStatus: z.string(), + guest: guestSchema.optional(), + paymentUrl: z.string().nullable().optional(), + paymentMethod: 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, + paymentMethod: d.data.attributes.paymentMethod, + 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/services/booking/schema/bookingReservationStatusSchema.ts b/packages/trpc/lib/services/booking/schema/bookingReservationStatusSchema.ts new file mode 100644 index 000000000..6613e2c55 --- /dev/null +++ b/packages/trpc/lib/services/booking/schema/bookingReservationStatusSchema.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +export const bookingReservationStatusSchema = z + .enum([ + "CreatedInOhip", + "PendingAcceptPriceChange", + "PendingGuarantee", + "PendingPayment", + "PaymentRegistered", + "PaymentAuthorized", + "ConfirmedInOhip", + "PaymentSucceeded", + "PaymentFailed", + "PaymentError", + "PaymentCancelled", + "BookingCompleted", + "Cancelled", + "CheckedOut", + "PendingMembership", + "Unknown", + ]) + .catch("Unknown") diff --git a/packages/trpc/lib/services/booking/updateBooking.ts b/packages/trpc/lib/services/booking/updateBooking.ts new file mode 100644 index 000000000..0d91dd7cc --- /dev/null +++ b/packages/trpc/lib/services/booking/updateBooking.ts @@ -0,0 +1,80 @@ +import { type Dayjs, dt } from "@scandic-hotels/common/dt" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../api" +import { langToApiLang } from "../../constants/apiLang" +import { + badGatewayError, + extractResponseDetails, + serverErrorByStatus, +} from "../../errors" +import { bookingConfirmationSchema } from "./getBooking/schema" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function updateBooking( + input: { + confirmationNumber: string + lang: Lang + checkInDate: Dayjs | Date | undefined + checkOutDate: Dayjs | Date | undefined + guest?: { + email?: string | undefined + phoneNumber?: string | undefined + countryCode?: string | undefined + } + }, + token: string +) { + const updateBookingCounter = createCounter("booking.update") + const metricsUpdateBooking = updateBookingCounter.init({ + confirmationNumber: input.confirmationNumber, + language: input.lang, + }) + + metricsUpdateBooking.start() + const body = { + checkInDate: input.checkInDate + ? dt(input.checkInDate).format("YYYY-MM-DD") + : undefined, + checkOutDate: input.checkOutDate + ? dt(input.checkOutDate).format("YYYY-MM-DD") + : undefined, + guest: input.guest, + } + + const apiResponse = await api.put( + api.endpoints.v1.Booking.booking(input.confirmationNumber), + { + body, + headers: { + Authorization: `Bearer ${token}`, + }, + }, + { language: langToApiLang[input.lang] } + ) + + if (!apiResponse.ok) { + await metricsUpdateBooking.httpError(apiResponse) + throw serverErrorByStatus( + apiResponse.status, + await extractResponseDetails(apiResponse), + "updateBooking failed for " + input.confirmationNumber + ) + } + + const apiJson = await apiResponse.json() + + const verifiedData = bookingConfirmationSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsUpdateBooking.validationError(verifiedData.error) + throw badGatewayError({ + message: "Invalid response from updateBooking", + errorDetails: { validationError: verifiedData.error }, + }) + } + + metricsUpdateBooking.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/types/bookingConfirmation.ts b/packages/trpc/lib/types/bookingConfirmation.ts index dd0761bd9..9364a79a2 100644 --- a/packages/trpc/lib/types/bookingConfirmation.ts +++ b/packages/trpc/lib/types/bookingConfirmation.ts @@ -3,9 +3,8 @@ import type { z } from "zod" import type { bookingConfirmationSchema, packageSchema, -} from "../routers/booking/output" +} from "../services/booking/getBooking/schema" import type { HotelData, Room } from "./hotel" - export interface BookingConfirmationSchema extends z.output< typeof bookingConfirmationSchema > {} diff --git a/packages/trpc/lib/types/entry.ts b/packages/trpc/lib/types/entry.ts index f81fb221d..331206d4f 100644 --- a/packages/trpc/lib/types/entry.ts +++ b/packages/trpc/lib/types/entry.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { z } from "zod" const baseResolveSchema = z.object({ system: z.object({ diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 3765ddeac..48756f373 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -33,6 +33,7 @@ "./routers/autocomplete/*": "./lib/routers/autocomplete/*.ts", "./routers/navigation/*": "./lib/routers/navigation/*.ts", "./routers/appRouter": "./lib/routers/appRouter.ts", + "./services/*": "./lib/services/*/index.ts", "./enums/*": "./lib/enums/*.ts", "./types/*": "./lib/types/*.ts", "./constants/*": "./lib/constants/*.ts",