Files
web/apps/scandic-web/server/routers/hotels/output.ts
Arvid Norlin 961e8aea91 Merged in fix/SW-1631-rate-terms-modal (pull request #1699)
fix(SW-1631): add rate terms modal

* fix(SW-1631): add rate terms modal


Approved-by: Simon.Emanuelsson
2025-04-02 09:36:53 +00:00

660 lines
20 KiB
TypeScript

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