feat(SW-1717): rewrite select-rate to show all variants of rate-cards
This commit is contained in:
committed by
Michael Zetterberg
parent
adde77eaa9
commit
ebaea78fb3
@@ -45,7 +45,7 @@ export const roomsCombinedAvailabilityInputSchema = z.object({
|
||||
rateCode: z.string().optional(),
|
||||
roomStayEndDate: z.string(),
|
||||
roomStayStartDate: z.string(),
|
||||
redemption: z.boolean().optional(),
|
||||
redemption: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
export const selectedRoomAvailabilityInputSchema = z.object({
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from "zod"
|
||||
|
||||
import { toLang } from "@/server/utils"
|
||||
|
||||
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||
|
||||
import { occupancySchema } from "./schemas/availability/occupancy"
|
||||
import { productTypeSchema } from "./schemas/availability/productType"
|
||||
import { citySchema } from "./schemas/city"
|
||||
@@ -23,6 +25,7 @@ import { roomConfigurationSchema } from "./schemas/roomAvailability/configuratio
|
||||
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
import type {
|
||||
AdditionalData,
|
||||
City,
|
||||
@@ -101,20 +104,6 @@ export const hotelsAvailabilitySchema = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
function everyRateHasBreakfastIncluded(
|
||||
product: Product,
|
||||
rateDefinitions: RateDefinition[],
|
||||
userType: "member" | "public"
|
||||
) {
|
||||
const rateDefinition = rateDefinitions.find(
|
||||
(rd) => rd.rateCode === product[userType]?.rateCode
|
||||
)
|
||||
if (!rateDefinition) {
|
||||
return false
|
||||
}
|
||||
return rateDefinition.breakfastIncluded
|
||||
}
|
||||
|
||||
function getRate(rate: RateDefinition) {
|
||||
switch (rate.cancellationRule) {
|
||||
case "CancellableBefore6PM":
|
||||
@@ -124,7 +113,9 @@ function getRate(rate: RateDefinition) {
|
||||
case "NotCancellable":
|
||||
return "save"
|
||||
default:
|
||||
console.info(`Should never happen!`)
|
||||
console.info(
|
||||
`Unknown cancellationRule [${rate.cancellationRule}]. This should never happen!`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -151,10 +142,11 @@ function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
|
||||
return statusLookup[a.status] - statusLookup[b.status]
|
||||
}
|
||||
|
||||
const baseRoomsCombinedAvailabilitySchema = z
|
||||
export const roomsAvailabilitySchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
bookingCode: nullableStringValidator,
|
||||
checkInDate: z.string(),
|
||||
checkOutDate: z.string(),
|
||||
hotelId: z.number(),
|
||||
@@ -204,159 +196,213 @@ const baseRoomsCombinedAvailabilitySchema = z
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
.transform(({ data: { attributes } }) => {
|
||||
const rateDefinitions = attributes.rateDefinitions
|
||||
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
|
||||
// @ts-expect-error - index of cancellationRule TS
|
||||
acc[val.rateCode] = cancellationRules[val.cancellationRule]
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
function transformRoomConfigs({
|
||||
data: { attributes },
|
||||
}: typeof baseRoomsCombinedAvailabilitySchema._type) {
|
||||
const rateDefinitions = attributes.rateDefinitions
|
||||
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
|
||||
// @ts-expect-error - index of cancellationRule TS
|
||||
acc[val.rateCode] = cancellationRules[val.cancellationRule]
|
||||
return acc
|
||||
}, {})
|
||||
function getProductRateCode(product: Product) {
|
||||
if ("corporateCheque" in product) {
|
||||
return product.corporateCheque.rateCode
|
||||
}
|
||||
if ("redemption" in product && product.redemption) {
|
||||
return product.redemption.rateCode
|
||||
}
|
||||
if ("voucher" in product) {
|
||||
return product.voucher.rateCode
|
||||
}
|
||||
if ("public" in product && product.public) {
|
||||
return product.public.rateCode
|
||||
}
|
||||
if ("member" in product && product.member) {
|
||||
return product.member.rateCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const roomConfigurations = attributes.roomConfigurations
|
||||
.map((room) => {
|
||||
if (room.products.length) {
|
||||
room.breakfastIncludedInAllRatesMember = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
|
||||
)
|
||||
room.breakfastIncludedInAllRatesPublic = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
|
||||
)
|
||||
function sortProductsBasedOnCancellationRule(a: Product, b: Product) {
|
||||
// @ts-expect-error - index
|
||||
const lookUpA = cancellationRuleLookup[getProductRateCode(a)]
|
||||
// @ts-expect-error - index
|
||||
const lookUpB = cancellationRuleLookup[getProductRateCode(b)]
|
||||
return lookUpA - lookUpB
|
||||
}
|
||||
|
||||
room.products = room.products.map((product) => {
|
||||
const publicRate = product.public
|
||||
if (publicRate?.rateCode) {
|
||||
const publicRateDefinition = rateDefinitions.find(
|
||||
(rateDefinition) =>
|
||||
rateDefinition.rateCode === publicRate.rateCode
|
||||
)
|
||||
if (publicRateDefinition) {
|
||||
const rate = getRate(publicRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function findRateDefintion(rateCode: string) {
|
||||
return rateDefinitions.find(
|
||||
(rateDefinition) => rateDefinition.rateCode === rateCode
|
||||
)
|
||||
}
|
||||
|
||||
const memberRate = product.member
|
||||
if (memberRate?.rateCode) {
|
||||
const memberRateDefinition = rateDefinitions.find(
|
||||
(rate) => rate.rateCode === memberRate.rateCode
|
||||
)
|
||||
if (memberRateDefinition) {
|
||||
const rate = getRate(memberRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const voucherRate = product.voucher
|
||||
if (voucherRate?.rateCode) {
|
||||
const voucherRateDefinition = rateDefinitions.find(
|
||||
(rate) => rate.rateCode === voucherRate.rateCode
|
||||
)
|
||||
if (voucherRateDefinition) {
|
||||
const rate = getRate(voucherRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chequeRate = product.bonusCheque
|
||||
if (chequeRate?.rateCode) {
|
||||
const chequeRateDefinition = rateDefinitions.find(
|
||||
(rate) => rate.rateCode === chequeRate.rateCode
|
||||
)
|
||||
if (chequeRateDefinition) {
|
||||
const rate = getRate(chequeRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return product
|
||||
})
|
||||
|
||||
// CancellationRule is the same for public and member per product
|
||||
// Sorting to guarantee order based on rate
|
||||
room.products = room.products.sort(
|
||||
(a, b) =>
|
||||
// @ts-expect-error - index
|
||||
cancellationRuleLookup[a.public?.rateCode || a.member?.rateCode] -
|
||||
// @ts-expect-error - index
|
||||
cancellationRuleLookup[b.public?.rateCode || b.member?.rateCode]
|
||||
)
|
||||
function getRateDetails(product: Product) {
|
||||
let rateCode = ""
|
||||
if ("corporateCheque" in product) {
|
||||
rateCode = product.corporateCheque.rateCode
|
||||
} else if ("redemption" in product && product.redemption) {
|
||||
rateCode = product.redemption.rateCode
|
||||
} else if ("voucher" in product && product.voucher) {
|
||||
rateCode = product.voucher.rateCode
|
||||
} else if ("public" in product && product.public) {
|
||||
rateCode = product.public.rateCode
|
||||
} else if ("member" in product && product.member) {
|
||||
rateCode = product.member.rateCode
|
||||
}
|
||||
|
||||
return room
|
||||
})
|
||||
.sort(sortRoomConfigs)
|
||||
if (!rateCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
roomConfigurations,
|
||||
}
|
||||
}
|
||||
const rateDefinition = findRateDefintion(rateCode)
|
||||
|
||||
export const roomsAvailabilitySchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
checkInDate: z.string(),
|
||||
checkOutDate: z.string(),
|
||||
hotelId: z.number(),
|
||||
mustBeGuaranteed: z.boolean().optional(),
|
||||
occupancy: occupancySchema.optional(),
|
||||
rateDefinitions: z.array(rateDefinitionSchema),
|
||||
roomConfigurations: z.array(roomConfigurationSchema),
|
||||
}),
|
||||
relationships: relationshipsSchema.optional(),
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
.transform(transformRoomConfigs)
|
||||
if (!rateDefinition) {
|
||||
return null
|
||||
}
|
||||
|
||||
export const roomsCombinedAvailabilitySchema =
|
||||
baseRoomsCombinedAvailabilitySchema.transform(transformRoomConfigs)
|
||||
const rate = getRate(rateDefinition)
|
||||
if (!rate) {
|
||||
return null
|
||||
}
|
||||
|
||||
export const redemptionRoomsCombinedAvailabilitySchema =
|
||||
baseRoomsCombinedAvailabilitySchema.transform((data) => {
|
||||
// In Redemption, rates are always Flex terms
|
||||
data.data.attributes.roomConfigurations =
|
||||
data.data.attributes.roomConfigurations
|
||||
.map((room) => {
|
||||
room.products = room.products.map((product) => {
|
||||
product.rate = "flex"
|
||||
product.isFlex = true
|
||||
return product
|
||||
})
|
||||
return room
|
||||
})
|
||||
.sort(
|
||||
// @ts-expect-error - array indexing
|
||||
(a, b) => statusLookup[a.status] - statusLookup[b.status]
|
||||
)
|
||||
product.breakfastIncluded = rateDefinition.breakfastIncluded
|
||||
product.rate = rate
|
||||
product.rateDefinition = rateDefinition
|
||||
|
||||
return transformRoomConfigs(data)
|
||||
return product
|
||||
}
|
||||
|
||||
const roomConfigurations = attributes.roomConfigurations
|
||||
.map((room) => {
|
||||
if (room.products.length) {
|
||||
const breakfastIncluded = []
|
||||
const breakfastIncludedMember = []
|
||||
for (const product of room.products) {
|
||||
if ("corporateCheque" in product) {
|
||||
const rateDetails = getRateDetails(product)
|
||||
if (rateDetails) {
|
||||
breakfastIncluded.push(rateDetails.breakfastIncluded)
|
||||
room.code.push({
|
||||
...rateDetails,
|
||||
corporateCheque: product.corporateCheque,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ("voucher" in product) {
|
||||
const rateDetails = getRateDetails(product)
|
||||
if (rateDetails) {
|
||||
breakfastIncluded.push(rateDetails.breakfastIncluded)
|
||||
room.code.push({
|
||||
...rateDetails,
|
||||
voucher: product.voucher,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Redemption is an array
|
||||
if (Array.isArray(product)) {
|
||||
if (product.length) {
|
||||
for (const redemption of product) {
|
||||
const rateDetails = getRateDetails(redemption)
|
||||
if (rateDetails) {
|
||||
breakfastIncluded.push(rateDetails.breakfastIncluded)
|
||||
room.redemptions.push({
|
||||
...redemption,
|
||||
...rateDetails,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
("member" in product && product.member) ||
|
||||
("public" in product && product.public)
|
||||
) {
|
||||
const memberRate = product.member
|
||||
const publicRate = product.public
|
||||
const rateCode = publicRate?.rateCode ?? memberRate?.rateCode
|
||||
const rateDetails = getRateDetails(product)
|
||||
const rateDetailsMember = getRateDetails({
|
||||
...product,
|
||||
public: null,
|
||||
})
|
||||
if (rateDetailsMember) {
|
||||
breakfastIncludedMember.push(
|
||||
rateDetailsMember.breakfastIncluded
|
||||
)
|
||||
}
|
||||
if (rateDetails && rateCode) {
|
||||
const rateDefinition = findRateDefintion(rateCode)
|
||||
if (rateDefinition) {
|
||||
switch (rateDefinition.rateType) {
|
||||
case RateTypeEnum.PublicPromotion:
|
||||
room.campaign.push({
|
||||
...rateDetails,
|
||||
member: memberRate,
|
||||
public: publicRate,
|
||||
})
|
||||
break
|
||||
case RateTypeEnum.Regular:
|
||||
room.regular.push({
|
||||
...rateDetails,
|
||||
member: memberRate,
|
||||
public: publicRate,
|
||||
})
|
||||
break
|
||||
default:
|
||||
room.code.push({
|
||||
...rateDetails,
|
||||
member: memberRate,
|
||||
public: publicRate,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
room.breakfastIncludedInAllRates =
|
||||
!!breakfastIncluded.length && breakfastIncluded.every(Boolean)
|
||||
room.breakfastIncludedInAllRatesMember =
|
||||
!!breakfastIncludedMember.length &&
|
||||
breakfastIncludedMember.every(Boolean)
|
||||
|
||||
// CancellationRule is the same for public and member per product
|
||||
// Sorting to guarantee order based on rate
|
||||
room.campaign.sort(sortProductsBasedOnCancellationRule)
|
||||
room.code.sort(sortProductsBasedOnCancellationRule)
|
||||
room.redemptions.sort(sortProductsBasedOnCancellationRule)
|
||||
room.regular.sort(sortProductsBasedOnCancellationRule)
|
||||
|
||||
const hasCampaignProducts = room.campaign.length
|
||||
const hasCodeProducts = room.code.length
|
||||
const hasRedemptionProducts = room.redemptions.length
|
||||
const hasRegularProducts = room.regular.length
|
||||
if (
|
||||
!hasCampaignProducts &&
|
||||
!hasCodeProducts &&
|
||||
!hasRedemptionProducts &&
|
||||
!hasRegularProducts
|
||||
) {
|
||||
room.status = AvailabilityEnum.NotAvailable
|
||||
}
|
||||
}
|
||||
|
||||
return room
|
||||
})
|
||||
.sort(sortRoomConfigs)
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
roomConfigurations,
|
||||
}
|
||||
})
|
||||
|
||||
export const ratesSchema = z.array(rateSchema)
|
||||
@@ -507,7 +553,7 @@ export const ancillaryPackagesSchema = z
|
||||
description: item.descriptions.html,
|
||||
imageUrl: item.images[0]?.imageSizes.small,
|
||||
price: {
|
||||
totalPrice: item.variants.ancillary.price.totalPrice,
|
||||
total: item.variants.ancillary.price.totalPrice,
|
||||
currency: item.variants.ancillary.price.currency,
|
||||
},
|
||||
points: item.variants.ancillaryLoyalty?.points,
|
||||
|
||||
@@ -3,11 +3,10 @@ import { Lang } from "@/constants/languages"
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { badRequestError } from "@/server/errors/trpc"
|
||||
import { badRequestError, unauthorizedError } from "@/server/errors/trpc"
|
||||
import {
|
||||
contentStackBaseWithServiceProcedure,
|
||||
protectedProcedure,
|
||||
protectedServiceProcedure,
|
||||
publicProcedure,
|
||||
router,
|
||||
safeProtectedServiceProcedure,
|
||||
@@ -52,9 +51,7 @@ import {
|
||||
hotelSchema,
|
||||
packagesSchema,
|
||||
ratesSchema,
|
||||
redemptionRoomsCombinedAvailabilitySchema,
|
||||
roomsAvailabilitySchema,
|
||||
roomsCombinedAvailabilitySchema,
|
||||
} from "./output"
|
||||
import tempRatesData from "./tempRatesData.json"
|
||||
import {
|
||||
@@ -65,6 +62,7 @@ import {
|
||||
getHotelIdsByCountry,
|
||||
getHotelsByHotelIds,
|
||||
getLocations,
|
||||
getSelectedRoomAvailability,
|
||||
} from "./utils"
|
||||
|
||||
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
@@ -75,8 +73,6 @@ import type { HotelDataWithUrl } from "@/types/hotel"
|
||||
import type {
|
||||
HotelsAvailabilityInputSchema,
|
||||
HotelsByHotelIdsAvailabilityInputSchema,
|
||||
RoomsCombinedAvailabilityInputSchema,
|
||||
SelectedRoomAvailabilitySchema,
|
||||
} from "@/types/trpc/routers/hotel/availability"
|
||||
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
|
||||
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
|
||||
@@ -472,353 +468,6 @@ export const getHotelsAvailabilityByHotelIds = async (
|
||||
)
|
||||
}
|
||||
|
||||
async function getRoomsCombinedAvailability(
|
||||
input: RoomsCombinedAvailabilityInputSchema,
|
||||
token: string // Either service token or user access token in case of redemption search
|
||||
) {
|
||||
const { lang } = input
|
||||
const apiLang = toApiLang(lang)
|
||||
const {
|
||||
adultsCount,
|
||||
bookingCode,
|
||||
childArray,
|
||||
hotelId,
|
||||
rateCode,
|
||||
roomStayEndDate,
|
||||
roomStayStartDate,
|
||||
redemption,
|
||||
} = input
|
||||
|
||||
const metricsData = {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adultsCount,
|
||||
childArray: childArray ? JSON.stringify(childArray) : undefined,
|
||||
bookingCode,
|
||||
}
|
||||
|
||||
metrics.roomsCombinedAvailability.counter.add(1, metricsData)
|
||||
|
||||
console.info(
|
||||
"api.hotels.roomsCombinedAvailability start",
|
||||
JSON.stringify({ query: { hotelId, params: metricsData } })
|
||||
)
|
||||
|
||||
const availabilityResponses = await Promise.allSettled(
|
||||
adultsCount.map(async (adultCount: number, idx: number) => {
|
||||
const kids = childArray?.[idx]
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults: adultCount,
|
||||
...(kids?.length && {
|
||||
children: generateChildrenString(kids),
|
||||
}),
|
||||
...(bookingCode && { bookingCode }),
|
||||
...(redemption && { isRedemption: "true" }),
|
||||
language: apiLang,
|
||||
}
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.hotel(hotelId.toString()),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
|
||||
console.error("Failed API call", { params, text })
|
||||
return { error: "http_error", details: text }
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const validateAvailabilityData = redemption
|
||||
? redemptionRoomsCombinedAvailabilitySchema.safeParse(apiJson)
|
||||
: roomsCombinedAvailabilitySchema.safeParse(apiJson)
|
||||
|
||||
if (!validateAvailabilityData.success) {
|
||||
console.error("Validation error", {
|
||||
params,
|
||||
error: validateAvailabilityData.error,
|
||||
})
|
||||
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
|
||||
return {
|
||||
error: "validation_error",
|
||||
details: validateAvailabilityData.error,
|
||||
}
|
||||
}
|
||||
|
||||
if (rateCode) {
|
||||
validateAvailabilityData.data.mustBeGuaranteed =
|
||||
validateAvailabilityData.data.rateDefinitions.find(
|
||||
(rate) => rate.rateCode === rateCode
|
||||
)?.mustBeGuaranteed
|
||||
}
|
||||
|
||||
return validateAvailabilityData.data
|
||||
})
|
||||
)
|
||||
metrics.roomsCombinedAvailability.success.add(1, metricsData)
|
||||
return availabilityResponses.map((availability) => {
|
||||
if (availability.status === "fulfilled") {
|
||||
return availability.value
|
||||
}
|
||||
return {
|
||||
details: availability.reason,
|
||||
error: "request_failure",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getRoomAvailability = async (
|
||||
input: SelectedRoomAvailabilitySchema,
|
||||
lang: Lang,
|
||||
token: string, // Either service token or user access token in case of redemption search
|
||||
serviceToken?: string // In Redemption we need serviceToken for hotel api call
|
||||
) => {
|
||||
const {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
rateCode,
|
||||
counterRateCode,
|
||||
roomTypeCode,
|
||||
redemption,
|
||||
inputLang,
|
||||
} = input
|
||||
|
||||
const language = inputLang ?? lang
|
||||
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
...(children && { children }),
|
||||
...(bookingCode && { bookingCode }),
|
||||
...(redemption && { isRedemption: "true" }),
|
||||
language: toApiLang(language),
|
||||
}
|
||||
|
||||
metrics.selectedRoomAvailability.counter.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
})
|
||||
console.info(
|
||||
"api.hotels.selectedRoomAvailability start",
|
||||
JSON.stringify({ query: { hotelId, params } })
|
||||
)
|
||||
const apiResponseAvailability = await api.get(
|
||||
api.endpoints.v1.Availability.hotel(hotelId.toString()),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponseAvailability.ok) {
|
||||
const text = await apiResponseAvailability.text()
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponseAvailability.status,
|
||||
statusText: apiResponseAvailability.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.hotels.selectedRoomAvailability error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: {
|
||||
status: apiResponseAvailability.status,
|
||||
statusText: apiResponseAvailability.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw new Error("Failed to fetch selected room availability")
|
||||
}
|
||||
const apiJsonAvailability = await apiResponseAvailability.json()
|
||||
const validateAvailabilityData =
|
||||
roomsAvailabilitySchema.safeParse(apiJsonAvailability)
|
||||
if (!validateAvailabilityData.success) {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validateAvailabilityData.error),
|
||||
})
|
||||
console.error(
|
||||
"api.hotels.selectedRoomAvailability validation error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: validateAvailabilityData.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
const hotelData = await getHotel(
|
||||
{
|
||||
hotelId,
|
||||
isCardOnlyPayment: false,
|
||||
language,
|
||||
},
|
||||
serviceToken ?? token
|
||||
)
|
||||
|
||||
const rooms = validateAvailabilityData.data.roomConfigurations
|
||||
const selectedRoom = rooms.find((room) => room.roomTypeCode === roomTypeCode)
|
||||
|
||||
if (!selectedRoom) {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
roomTypeCode,
|
||||
error_type: "not_found",
|
||||
error: `Couldn't find selected room with input: ${roomTypeCode}`,
|
||||
})
|
||||
console.error("No matching room found")
|
||||
return null
|
||||
}
|
||||
|
||||
const availableRoomsInCategory = rooms.filter(
|
||||
(room) => room.roomType === selectedRoom?.roomType
|
||||
)
|
||||
|
||||
const rateTypes = selectedRoom.products.find(
|
||||
(rate) =>
|
||||
rate.public?.rateCode === rateCode ||
|
||||
rate.member?.rateCode === rateCode ||
|
||||
rate.redemptions?.find((r) => r?.rateCode === rateCode) ||
|
||||
rate.bonusCheque?.rateCode === rateCode ||
|
||||
rate.voucher?.rateCode === rateCode
|
||||
)
|
||||
|
||||
if (!rateTypes) {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
error_type: "not_found",
|
||||
error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`,
|
||||
})
|
||||
console.error("No matching rate found")
|
||||
return null
|
||||
}
|
||||
const rates = rateTypes
|
||||
|
||||
const rateDefinition = validateAvailabilityData.data.rateDefinitions.find(
|
||||
(rate) => rate.rateCode === rateCode
|
||||
)
|
||||
const memberRateDefinition =
|
||||
validateAvailabilityData.data.rateDefinitions.find(
|
||||
(rate) => rate.rateCode === counterRateCode
|
||||
)
|
||||
|
||||
const bedTypes = availableRoomsInCategory
|
||||
.map((availRoom) => {
|
||||
const matchingRoom = hotelData?.roomCategories
|
||||
?.find((room) =>
|
||||
room.roomTypes
|
||||
.map((roomType) => roomType.code)
|
||||
.includes(availRoom.roomTypeCode)
|
||||
)
|
||||
?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode)
|
||||
|
||||
if (matchingRoom) {
|
||||
return {
|
||||
description: matchingRoom.description,
|
||||
size: matchingRoom.mainBed.widthRange,
|
||||
value: matchingRoom.code,
|
||||
type: matchingRoom.mainBed.type,
|
||||
extraBed: matchingRoom.fixedExtraBed
|
||||
? {
|
||||
type: matchingRoom.fixedExtraBed.type,
|
||||
description: matchingRoom.fixedExtraBed.description,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((bed): bed is BedTypeSelection => Boolean(bed))
|
||||
|
||||
metrics.selectedRoomAvailability.success.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
})
|
||||
console.info(
|
||||
"api.hotels.selectedRoomAvailability success",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params: params },
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
bedTypes,
|
||||
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
|
||||
cancellationRule: rateDefinition?.cancellationRule,
|
||||
cancellationText: rateDefinition?.cancellationText ?? "",
|
||||
chequeRate: rates?.bonusCheque,
|
||||
isFlexRate:
|
||||
rateDefinition?.cancellationRule ===
|
||||
CancellationRuleEnum.CancellableBefore6PM,
|
||||
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
|
||||
memberRate: rates?.member,
|
||||
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
|
||||
publicRate: rates?.public,
|
||||
redemptionRate: rates?.redemptions?.find((r) => r?.rateCode === rateCode),
|
||||
rate: selectedRoom.products[0].rate,
|
||||
rateDefinitionTitle: rateDefinition?.title ?? "",
|
||||
rateDetails: rateDefinition?.generalTerms,
|
||||
// Send rate Title when it is a booking code rate
|
||||
rateTitle:
|
||||
rateDefinition?.rateType !== RateTypeEnum.Regular
|
||||
? rateDefinition?.title
|
||||
: undefined,
|
||||
rateType: rateDefinition?.rateType ?? "",
|
||||
selectedRoom,
|
||||
voucherRate: rates?.voucher,
|
||||
}
|
||||
}
|
||||
|
||||
export const hotelQueryRouter = router({
|
||||
availability: router({
|
||||
hotelsByCity: serviceProcedure
|
||||
@@ -847,33 +496,360 @@ export const hotelQueryRouter = router({
|
||||
return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
|
||||
}),
|
||||
|
||||
roomsCombinedAvailability: serviceProcedure
|
||||
roomsCombinedAvailability: safeProtectedServiceProcedure
|
||||
.input(roomsCombinedAvailabilityInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return getRoomsCombinedAvailability(input, ctx.serviceToken)
|
||||
}),
|
||||
roomsCombinedAvailabilityWithRedemption: protectedProcedure
|
||||
.input(roomsCombinedAvailabilityInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return getRoomsCombinedAvailability(
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
if (input.redemption) {
|
||||
if (ctx.session?.token.access_token) {
|
||||
return next({
|
||||
ctx: {
|
||||
token: ctx.session.token.access_token,
|
||||
},
|
||||
input,
|
||||
})
|
||||
}
|
||||
throw unauthorizedError()
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
token: ctx.serviceToken,
|
||||
},
|
||||
input,
|
||||
ctx.session.token.access_token
|
||||
})
|
||||
})
|
||||
.query(
|
||||
async ({
|
||||
ctx,
|
||||
input: {
|
||||
adultsCount,
|
||||
bookingCode,
|
||||
childArray,
|
||||
hotelId,
|
||||
lang,
|
||||
rateCode,
|
||||
redemption,
|
||||
roomStayEndDate,
|
||||
roomStayStartDate,
|
||||
},
|
||||
}) => {
|
||||
const apiLang = toApiLang(lang)
|
||||
|
||||
const metricsData = {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adultsCount,
|
||||
childArray: childArray ? JSON.stringify(childArray) : undefined,
|
||||
bookingCode,
|
||||
}
|
||||
|
||||
metrics.roomsCombinedAvailability.counter.add(1, metricsData)
|
||||
|
||||
console.info(
|
||||
"api.hotels.roomsCombinedAvailability start",
|
||||
JSON.stringify({ query: { hotelId, params: metricsData } })
|
||||
)
|
||||
|
||||
const availabilityResponses = await Promise.allSettled(
|
||||
adultsCount.map(async (adultCount: number, idx: number) => {
|
||||
const kids = childArray?.[idx]
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults: adultCount,
|
||||
...(kids?.length && {
|
||||
children: generateChildrenString(kids),
|
||||
}),
|
||||
...(bookingCode && { bookingCode }),
|
||||
language: apiLang,
|
||||
...(redemption ? { isRedemption: "true" } : {}),
|
||||
}
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.hotel(hotelId.toString()),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
|
||||
console.error("Failed API call", { params, text })
|
||||
return { error: "http_error", details: text }
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const validateAvailabilityData =
|
||||
roomsAvailabilitySchema.safeParse(apiJson)
|
||||
if (!validateAvailabilityData.success) {
|
||||
console.error("Validation error", {
|
||||
params,
|
||||
error: validateAvailabilityData.error,
|
||||
})
|
||||
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
|
||||
return {
|
||||
error: "validation_error",
|
||||
details: validateAvailabilityData.error,
|
||||
}
|
||||
}
|
||||
|
||||
if (rateCode) {
|
||||
validateAvailabilityData.data.mustBeGuaranteed =
|
||||
validateAvailabilityData.data.rateDefinitions.find(
|
||||
(rate) => rate.rateCode === rateCode
|
||||
)?.mustBeGuaranteed
|
||||
}
|
||||
|
||||
return validateAvailabilityData.data
|
||||
})
|
||||
)
|
||||
metrics.roomsCombinedAvailability.success.add(1, metricsData)
|
||||
|
||||
const data = availabilityResponses.map((availability) => {
|
||||
if (availability.status === "fulfilled") {
|
||||
return availability.value
|
||||
}
|
||||
return {
|
||||
details: availability.reason,
|
||||
error: "request_failure",
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
),
|
||||
room: safeProtectedServiceProcedure
|
||||
.input(selectedRoomAvailabilityInputSchema)
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
if (input.redemption) {
|
||||
if (ctx.session?.token.access_token) {
|
||||
return next({
|
||||
ctx: {
|
||||
token: ctx.session.token.access_token,
|
||||
},
|
||||
input,
|
||||
})
|
||||
}
|
||||
throw unauthorizedError()
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
token: ctx.serviceToken,
|
||||
},
|
||||
input,
|
||||
})
|
||||
})
|
||||
.query(async ({ input, ctx }) => {
|
||||
let selectedRoomData = await getSelectedRoomAvailability(
|
||||
input,
|
||||
toApiLang(ctx.lang),
|
||||
ctx.token
|
||||
)
|
||||
}),
|
||||
room: serviceProcedure
|
||||
.input(selectedRoomAvailabilityInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return getRoomAvailability(input, ctx.lang, ctx.serviceToken)
|
||||
}),
|
||||
roomWithRedemption: protectedServiceProcedure
|
||||
.input(selectedRoomAvailabilityInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return getRoomAvailability(
|
||||
input,
|
||||
ctx.lang,
|
||||
ctx.session.token.access_token,
|
||||
|
||||
const {
|
||||
adults,
|
||||
bookingCode,
|
||||
children,
|
||||
counterRateCode,
|
||||
hotelId,
|
||||
inputLang,
|
||||
roomStayEndDate,
|
||||
roomStayStartDate,
|
||||
roomTypeCode,
|
||||
} = input
|
||||
|
||||
if (!selectedRoomData) {
|
||||
// There is no way to differentiate if a rateCode
|
||||
// selected is a bookingCode rateCode or just a
|
||||
// regular rateCode, hence we need to make a second
|
||||
// request without the bookingCode if no availability
|
||||
// is found
|
||||
if (bookingCode) {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
roomTypeCode,
|
||||
error_type: "not_found",
|
||||
error: `Couldn't find selected room with input: ${roomTypeCode}`,
|
||||
})
|
||||
console.error(
|
||||
"No matching room found when making the request with bookingCode, attempting without"
|
||||
)
|
||||
|
||||
metrics.selectedRoomAvailability.counter.add(1, {
|
||||
adults,
|
||||
children,
|
||||
hotelId,
|
||||
roomStayEndDate,
|
||||
roomStayStartDate,
|
||||
})
|
||||
|
||||
const { bookingCode: extractedBookingCode, ...regularRatesInput } =
|
||||
input
|
||||
selectedRoomData = await getSelectedRoomAvailability(
|
||||
regularRatesInput,
|
||||
toApiLang(ctx.lang),
|
||||
ctx.token
|
||||
)
|
||||
|
||||
if (!selectedRoomData) {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
adults,
|
||||
children,
|
||||
hotelId,
|
||||
roomStayEndDate,
|
||||
roomStayStartDate,
|
||||
roomTypeCode,
|
||||
error_type: "not_found",
|
||||
error: `Couldn't find selected room with input: ${roomTypeCode}`,
|
||||
})
|
||||
console.error("No matching room found even without bookingCode")
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
roomTypeCode,
|
||||
error_type: "not_found",
|
||||
error: `Couldn't find selected room with input: ${roomTypeCode}`,
|
||||
})
|
||||
console.error("No matching room found")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const hotelData = await getHotel(
|
||||
{
|
||||
hotelId,
|
||||
isCardOnlyPayment: false,
|
||||
language: inputLang ?? ctx.lang,
|
||||
},
|
||||
ctx.serviceToken
|
||||
)
|
||||
|
||||
const {
|
||||
product,
|
||||
rateDefinition,
|
||||
rateDefinitions,
|
||||
rooms,
|
||||
selectedRoom,
|
||||
} = selectedRoomData
|
||||
|
||||
const availableRoomsInCategory = rooms.filter(
|
||||
(room) => room.roomType === selectedRoom?.roomType
|
||||
)
|
||||
|
||||
if (!product) {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
error_type: "not_found",
|
||||
error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`,
|
||||
})
|
||||
console.error("No matching rate found")
|
||||
return null
|
||||
}
|
||||
|
||||
let memberRateDefinition = undefined
|
||||
if ("member" in product) {
|
||||
memberRateDefinition = rateDefinitions.find(
|
||||
(rate) => rate.rateCode === counterRateCode
|
||||
)
|
||||
}
|
||||
|
||||
const bedTypes = availableRoomsInCategory
|
||||
.map((availRoom) => {
|
||||
const matchingRoom = hotelData?.roomCategories
|
||||
?.find((room) =>
|
||||
room.roomTypes
|
||||
.map((roomType) => roomType.code)
|
||||
.includes(availRoom.roomTypeCode)
|
||||
)
|
||||
?.roomTypes.find(
|
||||
(roomType) => roomType.code === availRoom.roomTypeCode
|
||||
)
|
||||
|
||||
if (matchingRoom) {
|
||||
return {
|
||||
description: matchingRoom.description,
|
||||
size: matchingRoom.mainBed.widthRange,
|
||||
value: matchingRoom.code,
|
||||
type: matchingRoom.mainBed.type,
|
||||
extraBed: matchingRoom.fixedExtraBed
|
||||
? {
|
||||
type: matchingRoom.fixedExtraBed.type,
|
||||
description: matchingRoom.fixedExtraBed.description,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((bed): bed is BedTypeSelection => Boolean(bed))
|
||||
|
||||
metrics.selectedRoomAvailability.success.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
})
|
||||
console.info(
|
||||
"api.hotels.selectedRoomAvailability success",
|
||||
JSON.stringify({
|
||||
query: {
|
||||
hotelId,
|
||||
params: {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
...(children && { children }),
|
||||
...(bookingCode && { bookingCode }),
|
||||
language: inputLang ?? ctx.lang,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
bedTypes,
|
||||
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
|
||||
cancellationRule: rateDefinition?.cancellationRule,
|
||||
cancellationText: rateDefinition?.cancellationText ?? "",
|
||||
isFlexRate:
|
||||
rateDefinition?.cancellationRule ===
|
||||
CancellationRuleEnum.CancellableBefore6PM,
|
||||
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
|
||||
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
|
||||
product,
|
||||
rate: product.rate,
|
||||
rateDefinitionTitle: rateDefinition?.title ?? "",
|
||||
rateDetails: rateDefinition?.generalTerms,
|
||||
// Send rate Title when it is a booking code rate
|
||||
rateTitle:
|
||||
rateDefinition?.rateType !== RateTypeEnum.Regular
|
||||
? rateDefinition?.title
|
||||
: undefined,
|
||||
rateType: rateDefinition?.rateType ?? "",
|
||||
selectedRoom,
|
||||
}
|
||||
}),
|
||||
hotelsByCityWithBookingCode: serviceProcedure
|
||||
.input(hotelsAvailabilityInputSchema)
|
||||
@@ -896,23 +872,15 @@ export const hotelQueryRouter = router({
|
||||
return null
|
||||
}
|
||||
|
||||
// Do not search for regular rates if voucher or corporate cheque codes
|
||||
const isVoucherOrChqRate =
|
||||
bookingCodeAvailabilityResponse?.availability.some(
|
||||
(hotel) =>
|
||||
hotel.productType?.bonusCheque || hotel.productType?.voucher
|
||||
)
|
||||
|
||||
// Get regular availability of hotels which don't have availability with booking code.
|
||||
const unavailableHotelIds = !isVoucherOrChqRate
|
||||
? bookingCodeAvailabilityResponse?.availability
|
||||
.filter((hotel) => {
|
||||
return hotel.status === "NotAvailable"
|
||||
})
|
||||
.flatMap((hotel) => {
|
||||
return hotel.hotelId
|
||||
})
|
||||
: null
|
||||
const unavailableHotelIds =
|
||||
bookingCodeAvailabilityResponse?.availability
|
||||
.filter((hotel) => {
|
||||
return hotel.status === "NotAvailable"
|
||||
})
|
||||
.flatMap((hotel) => {
|
||||
return hotel.hotelId
|
||||
})
|
||||
|
||||
// All hotels have availability with booking code no need to fetch regular prices.
|
||||
// return response as is without any filtering as below.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
productTypeChequeSchema,
|
||||
productTypeCorporateChequeSchema,
|
||||
productTypePointsSchema,
|
||||
productTypePriceSchema,
|
||||
productTypeVoucherSchema,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
export const productTypeSchema = z
|
||||
.object({
|
||||
bonusCheque: productTypeChequeSchema.optional(),
|
||||
bonusCheque: productTypeCorporateChequeSchema.optional(),
|
||||
public: productTypePriceSchema.optional(),
|
||||
member: productTypePriceSchema.optional(),
|
||||
redemptions: z.array(productTypePointsSchema).optional(),
|
||||
|
||||
@@ -1,63 +1,76 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
|
||||
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
|
||||
export const corporateChequeSchema = z
|
||||
.object({
|
||||
additionalPricePerStay: nullableNumberValidator,
|
||||
currency: z.nativeEnum(CurrencyEnum).nullish(),
|
||||
numberOfBonusCheques: nullableNumberValidator,
|
||||
})
|
||||
.transform((data) => ({
|
||||
additionalPricePerStay: data.additionalPricePerStay,
|
||||
currency: data.currency,
|
||||
numberOfCheques: data.numberOfBonusCheques,
|
||||
}))
|
||||
|
||||
export const redemptionSchema = z.object({
|
||||
additionalPricePerStay: nullableNumberValidator,
|
||||
currency: z.nativeEnum(CurrencyEnum).nullish(),
|
||||
pointsPerNight: nullableNumberValidator,
|
||||
pointsPerStay: nullableNumberValidator,
|
||||
})
|
||||
|
||||
export const priceSchema = z.object({
|
||||
currency: z.nativeEnum(CurrencyEnum),
|
||||
pricePerNight: z.coerce.number(),
|
||||
pricePerStay: z.coerce.number(),
|
||||
regularPricePerNight: z.coerce.number().optional(),
|
||||
regularPricePerStay: z.coerce.number().optional(),
|
||||
})
|
||||
|
||||
export const pointsSchema = z
|
||||
.object({
|
||||
currency: z.nativeEnum(CurrencyEnum).optional(),
|
||||
pricePerStay: z.coerce.number().optional(),
|
||||
pointsPerStay: z.coerce.number(),
|
||||
additionalPricePerStay: z.coerce.number().optional(),
|
||||
additionalPriceCurrency: z.nativeEnum(CurrencyEnum).optional(),
|
||||
})
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
additionalPriceCurrency: data.currency,
|
||||
currency: CurrencyEnum.POINTS,
|
||||
pricePerStay: data.pointsPerStay,
|
||||
price: data.pointsPerStay,
|
||||
additionalPrice: data.additionalPricePerStay,
|
||||
}))
|
||||
|
||||
export const voucherSchema = z.object({
|
||||
currency: z.nativeEnum(CurrencyEnum),
|
||||
pricePerStay: z.number(),
|
||||
})
|
||||
|
||||
export const chequeSchema = z.object({
|
||||
additionalPricePerStay: z.number().optional(),
|
||||
currency: z.nativeEnum(CurrencyEnum).optional(),
|
||||
numberOfBonusCheques: z.coerce.number(),
|
||||
omnibusPricePerNight: nullableNumberValidator,
|
||||
pricePerNight: nullableNumberValidator,
|
||||
pricePerStay: nullableNumberValidator,
|
||||
regularPricePerNight: nullableNumberValidator,
|
||||
regularPricePerStay: nullableNumberValidator,
|
||||
})
|
||||
|
||||
const partialPriceSchema = z.object({
|
||||
rateCode: z.string(),
|
||||
rateType: z.string().optional(),
|
||||
rateCode: nullableStringValidator,
|
||||
rateType: z.nativeEnum(RateTypeEnum).catch((err) => {
|
||||
const issue = err.error.issues[0]
|
||||
// This is necessary to handle cases were a
|
||||
// new `rateType` is added in the API that has
|
||||
// not yet been handled in web
|
||||
if (issue.code === "invalid_enum_value") {
|
||||
return issue.received.toString() as RateTypeEnum
|
||||
}
|
||||
return RateTypeEnum.Regular
|
||||
}),
|
||||
})
|
||||
|
||||
export const productTypePriceSchema = partialPriceSchema.extend({
|
||||
localPrice: priceSchema,
|
||||
requestedPrice: priceSchema.optional(),
|
||||
})
|
||||
export const productTypeCorporateChequeSchema = z
|
||||
.object({
|
||||
localPrice: corporateChequeSchema,
|
||||
requestedPrice: corporateChequeSchema.nullish(),
|
||||
})
|
||||
.merge(partialPriceSchema)
|
||||
|
||||
export const productTypePointsSchema = partialPriceSchema.extend({
|
||||
localPrice: pointsSchema,
|
||||
requestedPrice: pointsSchema.optional(),
|
||||
})
|
||||
export const productTypePriceSchema = z
|
||||
.object({
|
||||
localPrice: priceSchema,
|
||||
requestedPrice: priceSchema.nullish(),
|
||||
})
|
||||
.merge(partialPriceSchema)
|
||||
|
||||
export const productTypeVoucherSchema = partialPriceSchema.extend({
|
||||
numberOfVouchers: z.coerce.number(),
|
||||
})
|
||||
export const productTypePointsSchema = z
|
||||
.object({
|
||||
localPrice: redemptionSchema,
|
||||
requestedPrice: redemptionSchema.nullish(),
|
||||
})
|
||||
.merge(partialPriceSchema)
|
||||
|
||||
export const productTypeChequeSchema = partialPriceSchema.extend({
|
||||
localPrice: chequeSchema,
|
||||
requestedPrice: chequeSchema.optional(),
|
||||
})
|
||||
export const productTypeVoucherSchema = z
|
||||
.object({
|
||||
numberOfVouchers: nullableNumberValidator,
|
||||
})
|
||||
.merge(partialPriceSchema)
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { productSchema } from "./product"
|
||||
import {
|
||||
corporateChequeProduct,
|
||||
priceProduct,
|
||||
productSchema,
|
||||
redemptionProduct,
|
||||
voucherProduct,
|
||||
} from "./product"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import {
|
||||
AvailabilityEnum,
|
||||
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import {
|
||||
RoomPackageCodeEnum,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export const roomConfigurationSchema = z
|
||||
.object({
|
||||
breakfastIncludedInAllRatesMember: z.boolean().default(false),
|
||||
breakfastIncludedInAllRatesPublic: z.boolean().default(false),
|
||||
breakfastIncludedInAllRates: z.boolean().default(false),
|
||||
features: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -25,36 +35,36 @@ export const roomConfigurationSchema = z
|
||||
roomsLeft: z.number(),
|
||||
roomType: z.string(),
|
||||
roomTypeCode: z.string(),
|
||||
status: z.string(),
|
||||
})
|
||||
.transform((data) => {
|
||||
if (data.products.length) {
|
||||
if (data.products[0].redemptions) {
|
||||
// No need of rate check in reward night scenario
|
||||
return { ...data }
|
||||
} else {
|
||||
const isVoucher = data.products.some((product) => product.voucher)
|
||||
const isCorpChq = data.products.some((product) => product.bonusCheque)
|
||||
if (isVoucher || isCorpChq) {
|
||||
return {
|
||||
...data,
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Just guaranteeing that if all products all miss
|
||||
* both public and member rateCode that status is
|
||||
* set to `NotAvailable`
|
||||
*/
|
||||
const allProductsMissBothRateCodes = data.products.every(
|
||||
(product) => !product.public?.rateCode && !product.member?.rateCode
|
||||
)
|
||||
if (allProductsMissBothRateCodes) {
|
||||
return {
|
||||
...data,
|
||||
status: AvailabilityEnum.NotAvailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
status: z
|
||||
.nativeEnum(AvailabilityEnum)
|
||||
.nullish()
|
||||
.default(AvailabilityEnum.NotAvailable),
|
||||
|
||||
// Red
|
||||
campaign: z
|
||||
.array(priceProduct)
|
||||
.nullish()
|
||||
.transform(val => val ? val.filter(Boolean) : []),
|
||||
// Blue
|
||||
code: z
|
||||
.array(
|
||||
z
|
||||
.union([
|
||||
corporateChequeProduct,
|
||||
priceProduct,
|
||||
voucherProduct,
|
||||
])
|
||||
)
|
||||
.nullish()
|
||||
.transform(val => val ? val.filter(Boolean) : []),
|
||||
// Beige
|
||||
regular: z
|
||||
.array(priceProduct)
|
||||
.nullish()
|
||||
.transform(val => val ? val.filter(Boolean) : []),
|
||||
// Burgundy
|
||||
redemptions: z
|
||||
.array(redemptionProduct)
|
||||
.nullish()
|
||||
.transform(val => val ? val.filter(Boolean) : []),
|
||||
})
|
||||
|
||||
@@ -1,28 +1,161 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
productTypeCorporateChequeSchema,
|
||||
productTypePointsSchema,
|
||||
productTypeChequeSchema,
|
||||
productTypePriceSchema,
|
||||
productTypeVoucherSchema,
|
||||
} from "../productTypePrice"
|
||||
import { rateDefinitionSchema } from "./rateDefinition"
|
||||
|
||||
export const productSchema = z
|
||||
const baseProductSchema = z.object({
|
||||
// Is breakfast included on product
|
||||
breakfastIncluded: z.boolean().default(false),
|
||||
// Used to set the rate that we use to chose titles etc.
|
||||
rate: z.enum(["change", "flex", "save"]).default("save"),
|
||||
rateDefinition: rateDefinitionSchema.nullish().transform((val) =>
|
||||
val
|
||||
? val
|
||||
: {
|
||||
breakfastIncluded: false,
|
||||
cancellationRule: "",
|
||||
cancellationText: "",
|
||||
displayPriceRed: false,
|
||||
isCampaignRate: false,
|
||||
isMemberRate: false,
|
||||
isPackageRate: false,
|
||||
generalTerms: [],
|
||||
mustBeGuaranteed: false,
|
||||
rateCode: "",
|
||||
rateType: "",
|
||||
title: "",
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
function mapBaseProduct(baseProduct: typeof baseProductSchema._type) {
|
||||
return {
|
||||
breakfastIncluded: baseProduct.breakfastIncluded,
|
||||
rate: baseProduct.rate,
|
||||
rateDefinition: baseProduct.rateDefinition,
|
||||
}
|
||||
}
|
||||
|
||||
const rawCorporateChequeProduct = z
|
||||
.object({
|
||||
// Is product flex rate
|
||||
isFlex: z.boolean().default(false),
|
||||
productType: z.object({
|
||||
bonusCheque: productTypeChequeSchema.optional(),
|
||||
member: productTypePriceSchema.optional(),
|
||||
public: productTypePriceSchema.optional(),
|
||||
redemptions: z.array(productTypePointsSchema).optional(),
|
||||
voucher: productTypeVoucherSchema.optional(),
|
||||
}),
|
||||
// Used to set the rate that we use to chose titles etc.
|
||||
rate: z.enum(["change", "flex", "save"]).default("save"),
|
||||
productType: z
|
||||
.object({
|
||||
bonusCheque: productTypeCorporateChequeSchema,
|
||||
})
|
||||
.transform((data) => ({
|
||||
corporateCheque: data.bonusCheque,
|
||||
})),
|
||||
})
|
||||
.transform((data) => ({
|
||||
.merge(baseProductSchema)
|
||||
|
||||
function transformCorporateCheque(
|
||||
data: z.output<typeof rawCorporateChequeProduct>
|
||||
) {
|
||||
return {
|
||||
...data.productType,
|
||||
isFlex: data.isFlex,
|
||||
rate: data.rate,
|
||||
}))
|
||||
...mapBaseProduct(data),
|
||||
}
|
||||
}
|
||||
|
||||
export const corporateChequeProduct = rawCorporateChequeProduct.transform(
|
||||
transformCorporateCheque
|
||||
)
|
||||
|
||||
const rawPriceProduct = z
|
||||
.object({
|
||||
productType: z.object({
|
||||
member: productTypePriceSchema.nullish().default(null),
|
||||
public: productTypePriceSchema.nullish().default(null),
|
||||
}),
|
||||
})
|
||||
.merge(baseProductSchema)
|
||||
|
||||
function transformPriceProduct(data: z.output<typeof rawPriceProduct>) {
|
||||
return {
|
||||
...data.productType,
|
||||
...mapBaseProduct(data),
|
||||
}
|
||||
}
|
||||
|
||||
export const priceProduct = rawPriceProduct.transform(transformPriceProduct)
|
||||
|
||||
export const redemptionProduct = z
|
||||
.object({
|
||||
redemption: productTypePointsSchema,
|
||||
})
|
||||
.merge(baseProductSchema)
|
||||
|
||||
const rawRedemptionsProduct = z.object({
|
||||
type: z.literal("REDEMPTION").optional().default("REDEMPTION"),
|
||||
productType: z.object({
|
||||
redemptions: z
|
||||
.array(productTypePointsSchema.merge(baseProductSchema))
|
||||
.transform((data) =>
|
||||
data.map(
|
||||
({ breakfastIncluded, rate, rateDefinition, ...redemption }) => ({
|
||||
breakfastIncluded,
|
||||
rate,
|
||||
rateDefinition,
|
||||
redemption,
|
||||
})
|
||||
)
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export const redemptionsProduct = rawRedemptionsProduct.transform(
|
||||
(data) => data.productType.redemptions
|
||||
)
|
||||
|
||||
const rawVoucherProduct = z
|
||||
.object({
|
||||
type: z.literal("VOUCHER").optional().default("VOUCHER"),
|
||||
productType: z.object({
|
||||
voucher: productTypeVoucherSchema,
|
||||
}),
|
||||
})
|
||||
.merge(baseProductSchema)
|
||||
|
||||
function transformVoucherProduct(data: z.output<typeof rawVoucherProduct>) {
|
||||
return {
|
||||
...data.productType,
|
||||
...mapBaseProduct(data),
|
||||
}
|
||||
}
|
||||
|
||||
export const voucherProduct = rawVoucherProduct.transform(
|
||||
transformVoucherProduct
|
||||
)
|
||||
|
||||
export const productSchema = z.union([
|
||||
corporateChequeProduct,
|
||||
redemptionsProduct,
|
||||
voucherProduct,
|
||||
priceProduct,
|
||||
])
|
||||
// export const productSchema = z.discriminatedUnion(
|
||||
// "type",
|
||||
// [
|
||||
// rawCorporateChequeProduct,
|
||||
// rawPriceProduct,
|
||||
// rawRedemptionsProduct,
|
||||
// rawVoucherProduct,
|
||||
// ]
|
||||
// )
|
||||
// .transform(data => {
|
||||
// switch (data.type) {
|
||||
// case "CORPORATECHEQUE":
|
||||
// return transformCorporateCheque(data)
|
||||
// case "PRICEPRODUCT":
|
||||
// return transformPriceProduct(data)
|
||||
// case "REDEMPTION":
|
||||
// return data.productType.redemptions
|
||||
// case "VOUCHER":
|
||||
// return transformVoucherProduct(data)
|
||||
// }
|
||||
// })
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||
import { z } from "zod"
|
||||
|
||||
export const rateDefinitionSchema = z.object({
|
||||
breakfastIncluded: z.boolean(),
|
||||
cancellationRule: z.string(),
|
||||
cancellationText: z.string().optional(),
|
||||
cancellationText: nullableStringValidator,
|
||||
displayPriceRed: z.boolean().default(false),
|
||||
generalTerms: z.array(z.string()),
|
||||
isCampaignRate: z.boolean().default(false),
|
||||
isMemberRate: z.boolean().default(false),
|
||||
isPackageRate: z.boolean().default(false),
|
||||
mustBeGuaranteed: z.boolean(),
|
||||
rateCode: z.string(),
|
||||
rateType: z.string().optional(),
|
||||
rateType: nullableStringValidator,
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import deepmerge from "deepmerge"
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
import { badRequestError } from "@/server/errors/trpc"
|
||||
import { toApiLang } from "@/server/utils"
|
||||
|
||||
import { getCacheClient } from "@/services/dataCache"
|
||||
@@ -16,16 +17,26 @@ import {
|
||||
countriesSchema,
|
||||
getHotelIdsSchema,
|
||||
locationsSchema,
|
||||
roomsAvailabilitySchema,
|
||||
} from "./output"
|
||||
import { getHotel } from "./query"
|
||||
|
||||
import type { z } from "zod"
|
||||
|
||||
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
|
||||
import type { HotelDataWithUrl } from "@/types/hotel"
|
||||
import type {
|
||||
CitiesGroupedByCountry,
|
||||
CityLocation,
|
||||
} from "@/types/trpc/routers/hotel/locations"
|
||||
import type {
|
||||
Product,
|
||||
Products,
|
||||
RateDefinition,
|
||||
RedemptionsProduct,
|
||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { Endpoint } from "@/lib/api/endpoints"
|
||||
import type { selectedRoomAvailabilityInputSchema } from "./input"
|
||||
|
||||
export function getPoiGroupByCategoryName(category: string | undefined) {
|
||||
if (!category) return PointOfInterestGroupEnum.LOCATION
|
||||
@@ -532,3 +543,173 @@ export async function getHotelsByHotelIds({
|
||||
|
||||
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
|
||||
}
|
||||
|
||||
function findProduct(product: Products, rateDefinition: RateDefinition) {
|
||||
if ("corporateCheque" in product) {
|
||||
return product.corporateCheque.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
|
||||
if (("member" in product && product.member) || "public" in product) {
|
||||
let isMemberRate = false
|
||||
if (product.member) {
|
||||
isMemberRate = product.member.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
let isPublicRate = false
|
||||
if (product.public) {
|
||||
isPublicRate = product.public.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
return isMemberRate || isPublicRate
|
||||
}
|
||||
|
||||
if ("voucher" in product) {
|
||||
return product.voucher.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
|
||||
if (Array.isArray(product)) {
|
||||
return product.find(
|
||||
(r) => r.redemption.rateCode === rateDefinition.rateCode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSelectedRoomAvailability(
|
||||
input: z.input<typeof selectedRoomAvailabilityInputSchema>,
|
||||
lang: string,
|
||||
serviceToken: string
|
||||
) {
|
||||
const {
|
||||
adults,
|
||||
bookingCode,
|
||||
children,
|
||||
hotelId,
|
||||
inputLang,
|
||||
roomStayEndDate,
|
||||
roomStayStartDate,
|
||||
redemption,
|
||||
} = input
|
||||
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
...(children && { children }),
|
||||
...(bookingCode && { bookingCode }),
|
||||
...(redemption && { isRedemption: "true" }),
|
||||
language: inputLang ?? lang,
|
||||
}
|
||||
|
||||
metrics.selectedRoomAvailability.counter.add(1, input)
|
||||
console.info(
|
||||
"api.hotels.selectedRoomAvailability start",
|
||||
JSON.stringify({ query: { hotelId: input.hotelId, params } })
|
||||
)
|
||||
const apiResponseAvailability = await api.get(
|
||||
api.endpoints.v1.Availability.hotel(hotelId.toString()),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${serviceToken}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponseAvailability.ok) {
|
||||
const text = await apiResponseAvailability.text()
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponseAvailability.status,
|
||||
statusText: apiResponseAvailability.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.hotels.selectedRoomAvailability error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: {
|
||||
status: apiResponseAvailability.status,
|
||||
statusText: apiResponseAvailability.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw new Error("Failed to fetch selected room availability")
|
||||
}
|
||||
const apiJsonAvailability = await apiResponseAvailability.json()
|
||||
const validateAvailabilityData =
|
||||
roomsAvailabilitySchema.safeParse(apiJsonAvailability)
|
||||
if (!validateAvailabilityData.success) {
|
||||
metrics.selectedRoomAvailability.fail.add(1, {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validateAvailabilityData.error),
|
||||
})
|
||||
console.error(
|
||||
"api.hotels.selectedRoomAvailability validation error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: validateAvailabilityData.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
const { rateDefinitions, roomConfigurations } = validateAvailabilityData.data
|
||||
|
||||
const rateDefinition = rateDefinitions.find(
|
||||
(rd) => rd.rateCode === input.rateCode
|
||||
)
|
||||
if (!rateDefinition) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedRoom = roomConfigurations.find(
|
||||
(room) =>
|
||||
room.roomTypeCode === input.roomTypeCode &&
|
||||
room.products.find((product) => findProduct(product, rateDefinition))
|
||||
)
|
||||
|
||||
if (!selectedRoom) {
|
||||
return null
|
||||
}
|
||||
|
||||
let product: Product | RedemptionsProduct | undefined =
|
||||
selectedRoom.products.find((product) =>
|
||||
findProduct(product, rateDefinition)
|
||||
)
|
||||
|
||||
if (!product) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(product)) {
|
||||
const redemptionProduct = product.find(
|
||||
(r) => r.redemption.rateCode === rateDefinition.rateCode
|
||||
)
|
||||
if (!redemptionProduct) {
|
||||
return null
|
||||
}
|
||||
product = redemptionProduct
|
||||
}
|
||||
|
||||
return {
|
||||
rateDefinition,
|
||||
rateDefinitions,
|
||||
rooms: roomConfigurations,
|
||||
product,
|
||||
selectedRoom,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user