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 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 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(), })