feat(SW-1717): rewrite select-rate to show all variants of rate-cards

This commit is contained in:
Simon Emanuelsson
2025-03-25 11:25:44 +01:00
committed by Michael Zetterberg
parent adde77eaa9
commit ebaea78fb3
118 changed files with 4601 additions and 4374 deletions

View File

@@ -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.