Files
web/packages/trpc/lib/routers/hotels/output.ts
Joakim Jäderberg 494bf2ba78 Merged in fix/BOOK-672-hotels-without-related-links (pull request #3348)
fix(BOOK-672): remove unused relationsships that threw when missing

* fix(BOOK-672): remove unused relationsships that threw when missing


Approved-by: Linus Flood
2025-12-15 13:19:05 +00:00

604 lines
18 KiB
TypeScript

import { z } from "zod"
import { CancellationRuleEnum } from "@scandic-hotels/common/constants/booking"
import { RateEnum } from "@scandic-hotels/common/constants/rate"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { logger } from "@scandic-hotels/common/logger"
import { toLang } from "@scandic-hotels/common/utils/languages"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { RoomPackageCodeEnum } from "../../enums/roomFilter"
import { AvailabilityEnum } from "../../enums/selectHotel"
import {
ancillaryPackageSchema,
breakfastPackageSchema,
packageSchema,
} from "../../routers/hotels/schemas/packages"
import { sortRoomConfigs } from "../../utils/sortRoomConfigs"
import { occupancySchema } from "./schemas/availability/occupancy"
import { productTypeSchema } from "./schemas/availability/productType"
import { citySchema } from "./schemas/city"
import { attributesSchema, includedSchema } from "./schemas/hotel"
import { addressSchema } from "./schemas/hotel/address"
import { detailedFacilitiesSchema } from "./schemas/hotel/detailedFacility"
import { locationSchema } from "./schemas/hotel/location"
import { rewardNightSchema } from "./schemas/hotel/rewardNight"
import { imageSchema } from "./schemas/image"
import { relationshipsSchema } from "./schemas/relationships"
import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration"
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
import type {
AdditionalData,
City,
NearbyHotel,
Restaurant,
Room,
} from "../../types/hotel"
import type { Product, RateDefinition } from "../../types/roomAvailability"
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
export const hotelSchema = z
.object({
data: z.object({
attributes: attributesSchema,
id: z.string(),
language: z.string().transform((val) => {
const lang = toLang(val)
if (!lang) {
throw new Error("Invalid language")
}
return lang
}),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
}),
// NOTE: We can pass an "include" param to the hotel API to retrieve
// additional data for an individual hotel.
included: includedSchema,
})
.transform(({ data: { attributes, ...data }, included }) => {
const additionalData =
included.find(
(inc): inc is AdditionalData => inc!.type === "additionalData"
) ?? ({} as AdditionalData)
const cities = included.filter((inc): inc is City => inc!.type === "cities")
const nearbyHotels = included.filter(
(inc): inc is NearbyHotel => inc!.type === "hotels"
)
const restaurants = included.filter(
(inc): inc is Restaurant => inc!.type === "restaurants"
)
const roomCategories = included.filter(
(inc): inc is Room => inc!.type === "roomcategories"
)
return {
additionalData,
cities,
hotel: {
...data,
...attributes,
},
nearbyHotels,
restaurants,
roomCategories,
}
})
export const hotelsAvailabilitySchema = z.object({
data: z.array(
z.object({
attributes: z.object({
bookingCode: z.string().nullish(),
checkInDate: z.string(),
checkOutDate: z.string(),
hotelId: z.number(),
occupancy: occupancySchema,
productType: productTypeSchema,
status: z.string(),
}),
relationships: relationshipsSchema.optional(),
type: z.string().optional(),
})
),
})
function getRate(rate: RateDefinition) {
switch (rate.cancellationRule) {
case CancellationRuleEnum.CancellableBefore6PM:
return RateEnum.flex
case CancellationRuleEnum.Changeable:
return RateEnum.change
case CancellationRuleEnum.NotCancellable:
return RateEnum.save
default:
logger.warn(
`Unknown cancellationRule [${rate.cancellationRule}]. This should never happen!`
)
return null
}
}
/**
* This is used for custom sorting further down
* to guarantee correct order of rates
*/
const cancellationRules = {
CancellableBefore6PM: 2,
Changeable: 1,
NotCancellable: 0,
} as const
export const roomsAvailabilitySchema = z
.object({
data: z.object({
attributes: z.object({
bookingCode: nullableStringValidator,
checkInDate: z.string(),
checkOutDate: z.string(),
hotelId: z.number(),
mustBeGuaranteed: z.boolean().optional(),
occupancy: occupancySchema.optional(),
packages: z.array(packageSchema).optional().default([]),
rateDefinitions: z.array(rateDefinitionSchema),
roomConfigurations: z.array(roomConfigurationSchema),
}),
}),
})
.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 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 ""
}
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
}
function findRateDefintion(rateCode: string) {
return rateDefinitions.find(
(rateDefinition) => rateDefinition.rateCode === 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
}
if (!rateCode) {
return null
}
const rateDefinition = findRateDefintion(rateCode)
if (!rateDefinition) {
return null
}
const rate = getRate(rateDefinition)
if (!rate) {
return null
}
if (attributes.bookingCode) {
product.bookingCode = attributes.bookingCode
} else {
product.bookingCode = undefined
}
product.rate = rate
product.rateDefinition = rateDefinition
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.rateDefinition.breakfastIncluded
)
room.code.push({
...rateDetails,
corporateCheque: product.corporateCheque,
})
}
continue
}
if ("voucher" in product) {
const rateDetails = getRateDetails(product)
if (rateDetails) {
breakfastIncluded.push(
rateDetails.rateDefinition.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.rateDefinition.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 rateDetails = getRateDetails(product)
const rateDetailsMember = getRateDetails({
...product,
public: null,
})
if (rateDetails) {
if (publicRate) {
breakfastIncluded.push(
rateDetails.rateDefinition.breakfastIncluded
)
}
if (rateDetailsMember) {
breakfastIncludedMember.push(
rateDetailsMember.rateDefinition.breakfastIncluded
)
rateDetails.rateDefinitionMember =
rateDetailsMember.rateDefinition
}
switch (rateDetails.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 citiesByCountrySchema = z.object({
data: z.array(
citySchema.transform((data) => {
return {
...data.attributes,
id: data.id,
type: data.type,
}
})
),
})
export const countriesSchema = z.object({
data: z
.array(
z.object({
attributes: z.object({
currency: z.string().default("N/A"),
name: z.string(),
}),
hotelInformationSystemId: z.number().optional(),
id: z.string().optional().default(""),
language: z.string().optional(),
type: z.literal("countries"),
})
)
.transform((data) => {
return data.map((country) => {
return {
...country.attributes,
hotelInformationSystemId: country.hotelInformationSystemId,
id: country.id,
language: country.language,
type: country.type,
}
})
}),
})
export type Cities = z.infer<typeof citiesSchema>
export const citiesSchema = z
.object({
data: z.array(citySchema),
})
.transform(({ data }) => {
if (data.length) {
const city = data[0]
return {
...city.attributes,
id: city.id,
type: city.type,
}
}
return null
})
export type BreakfastPackages = z.output<typeof breakfastPackagesSchema>
export const breakfastPackagesSchema = z
.object({
data: z.object({
attributes: z
.object({
hotelId: z.number(),
packages: z.array(breakfastPackageSchema),
})
.or(z.object({ packages: z.tuple([]) })),
type: z.literal("breakfastpackage"),
}),
})
.transform(({ data }) =>
data.attributes.packages.filter((pkg) => pkg.code?.match(/^(BRF\d+)$/gm))
)
enum SingleUseAncillaryIds {
EarlyCheckIn = "0060",
LateCheckOut = "0061",
EarlyCheckinPilot = "0060999",
LateCheckoutPilot = "0061999",
}
// Determine if ancillary requires quantity based on ID. These ancillaries are special since they
// are 1 per booking. The agreement is to use the same last digits in the ID for both early check-in
// and late check-out ancillaries in order to identify them here regardless of language or market.
// During the Pilot phase, the IDs are different but the same logic applies.
function getRequiresQuantity(id: string) {
const code = id.split("_").pop()
if (code) {
return Object.values(SingleUseAncillaryIds).includes(
code as SingleUseAncillaryIds
)
? false
: true
}
return true
}
export const ancillaryPackagesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
ancillaries: z.array(ancillaryPackageSchema),
}),
}),
})
.transform(({ data }) =>
data.attributes.ancillaries
.map((ancillary) => ({
translatedCategoryName: ancillary.categoryName,
internalCategoryName: ancillary.internalCategoryName,
ancillaryContent: ancillary.ancillaryContent
.filter((item) => item.status === "Available")
.map((item) => ({
hotelId: data.attributes.hotelId,
id: item.id,
title: item.title,
description: item.descriptions.html,
imageUrl: item.images[0]?.imageSizes.original || undefined,
price: {
total: item.variants.ancillary.price.totalPrice,
currency: item.variants.ancillary.price.currency,
},
points: item.variants.ancillaryLoyalty?.points,
loyaltyCode: item.variants.ancillaryLoyalty?.code,
requiresDeliveryTime: item.requiresDeliveryTime,
translatedCategoryName: ancillary.categoryName,
internalCategoryName: ancillary.internalCategoryName,
requiresQuantity: getRequiresQuantity(item.id),
})),
}))
.filter((ancillary) => ancillary.ancillaryContent.length > 0)
)
export const packagesSchema = z
.object({
data: z
.object({
attributes: z.object({
hotelId: z.number(),
packages: z.array(packageSchema).default([]),
}),
})
.optional(),
})
.transform(({ data }) => data?.attributes.packages)
export const getHotelIdsSchema = z
.object({
data: z.array(
z.object({
attributes: z.object({
isPublished: z.boolean(),
isActive: z.boolean(),
}),
id: z.string(),
})
),
})
.transform(({ data }) => {
const filteredHotels = data.filter((hotel) => hotel.attributes.isPublished)
return filteredHotels.map((hotel) => hotel.id)
})
export const getNearbyHotelIdsSchema = z
.object({
data: z.array(
z.object({
attributes: z.object({
isPublished: z.boolean(),
isActive: z.boolean(),
}),
id: z.string(),
})
),
})
.transform(({ data }) => {
const filteredHotels = data.filter(
(hotel) => hotel.attributes.isPublished && hotel.attributes.isActive
)
return filteredHotels.map((hotel) => hotel.id)
})
export const roomFeaturesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
roomFeatures: z
.array(
z.object({
roomTypeCode: z.string(),
features: z.array(
z.object({
inventory: z.number(),
code: z.enum([
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
]),
})
),
})
)
.default([]),
}),
}),
})
.transform((data) => {
return data.data.attributes.roomFeatures
})
export const hotelListingHotelDataSchema = z.object({
hotel: z.object({
id: z.string(),
name: z.string(),
countryCode: z.string(),
location: locationSchema,
cityIdentifier: z.string().nullable(),
tripadvisor: z.number().nullable(),
detailedFacilities: detailedFacilitiesSchema,
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
address: addressSchema,
hotelType: z.string(),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
description: z.string().nullable(),
rewardNight: rewardNightSchema,
}),
url: z.string().nullable(),
meetingUrl: z.string().nullable(),
})