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" import { attributesSchema, includedSchema, relationshipsSchema as hotelRelationshipsSchema, } from "./schemas/hotel" import { locationCitySchema } from "./schemas/location/city" import { locationHotelSchema } from "./schemas/location/hotel" import { ancillaryPackageSchema, breakfastPackageSchema, packageSchema, } from "./schemas/packages" import { rateSchema } from "./schemas/rate" import { relationshipsSchema } from "./schemas/relationships" import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration" import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RateTypeEnum } from "@/types/enums/rateType" import type { AdditionalData, City, NearbyHotel, Restaurant, Room, } from "@/types/hotel" import type { Product, RateDefinition, RoomConfiguration, } from "@/types/trpc/routers/hotel/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 }), relationships: hotelRelationshipsSchema, 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({ 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 "CancellableBefore6PM": return "flex" case "Changeable": return "change" case "NotCancellable": return "save" default: console.info( `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 // Used to ensure `Available` rooms // are shown before all `NotAvailable` const statusLookup = { [AvailabilityEnum.Available]: 1, [AvailabilityEnum.NotAvailable]: 2, } function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) { // @ts-expect-error - array indexing return statusLookup[a.status] - statusLookup[b.status] } 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(), rateDefinitions: z.array(rateDefinitionSchema), roomConfigurations: z .array(roomConfigurationSchema) .transform((data) => { // Initial sort to guarantee if one bed is NotAvailable and whereas // the other is Available to make sure data is added to the correct // roomConfig const configs = data.sort(sortRoomConfigs) const roomConfigs = new Map() for (const roomConfig of configs) { if (roomConfigs.has(roomConfig.roomType)) { const currentRoomConf = roomConfigs.get(roomConfig.roomType) if (currentRoomConf) { currentRoomConf.features = roomConfig.features.reduce( (feats, feature) => { const currentFeatureIndex = feats.findIndex( (f) => f.code === feature.code ) if (currentFeatureIndex !== -1) { feats[currentFeatureIndex].inventory = feats[currentFeatureIndex].inventory + feature.inventory } else { feats.push(feature) } return feats }, currentRoomConf.features ) currentRoomConf.roomsLeft = currentRoomConf.roomsLeft + roomConfig.roomsLeft roomConfigs.set(currentRoomConf.roomType, currentRoomConf) } } else { roomConfigs.set(roomConfig.roomType, roomConfig) } } return Array.from(roomConfigs.values()) }), }), relationships: relationshipsSchema.optional(), 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 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.breakfastIncluded = rateDefinition.breakfastIncluded 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.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 (rateDetails && rateCode) { if (rateDetailsMember) { breakfastIncludedMember.push( rateDetailsMember.breakfastIncluded ) rateDetails.rateDefinitionMember = rateDetailsMember.rateDefinition } 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) 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 const locationsSchema = z.object({ data: z .array( z .discriminatedUnion("type", [locationCitySchema, locationHotelSchema]) .transform((location) => { if (location.type === "cities") { return { ...location.attributes, country: location?.country ?? "", id: location.id, type: location.type, } } return { ...location.attributes, id: location.id, relationships: { city: { cityIdentifier: "", ianaTimeZoneId: "", id: "", isPublished: false, keywords: [], name: "", timeZoneId: "", type: "cities", url: location?.relationships?.city?.links?.related ?? "", }, }, type: location.type, operaId: location.attributes.operaId ?? "", } }) ) .transform((data) => data .filter((node) => !!node) .filter((node) => { if (node.type === "hotels") { if (!node.operaId) { return false } } return true }) .sort((a, b) => { if (a.type === b.type) { return a.name.localeCompare(b.name) } else { return a.type === "cities" ? -1 : 1 } }) ), }) export const breakfastPackagesSchema = z .object({ data: z.object({ attributes: z.object({ hotelId: z.number(), packages: z.array(breakfastPackageSchema), }), type: z.literal("breakfastpackage"), }), }) .transform(({ data }) => data.attributes.packages.filter((pkg) => pkg.code?.match(/^(BRF\d+)$/gm)) ) 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) => ({ categoryName: ancillary.categoryName, 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.small, 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, categoryName: ancillary.categoryName, })), })) .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([]), }), relationships: z .object({ links: z.array( z.object({ type: z.string(), url: z.string(), }) ), }) .optional(), type: z.string(), }) .optional(), }) .transform(({ data }) => data?.attributes.packages) export const getHotelIdsSchema = z .object({ data: z.array( z.object({ attributes: z.object({ isPublished: 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({ // We only care about the hotel id id: z.string(), }) ), }) .transform((data) => data.data.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 })