Merged in feat/sw-2863-move-contentstack-router-to-trpc-package (pull request #2389)
feat(SW-2863): Move contentstack router to trpc package * Add exports to packages and lint rule to prevent relative imports * Add env to trpc package * Add eslint to trpc package * Apply lint rules * Use direct imports from trpc package * Add lint-staged config to trpc * Move lang enum to common * Restructure trpc package folder structure * WIP first step * update internal imports in trpc * Fix most errors in scandic-web Just 100 left... * Move Props type out of trpc * Fix CategorizedFilters types * Move more schemas in hotel router * Fix deps * fix getNonContentstackUrls * Fix import error * Fix entry error handling * Fix generateMetadata metrics * Fix alertType enum * Fix duplicated types * lint:fix * Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package * Fix broken imports * Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package Approved-by: Linus Flood
This commit is contained in:
654
packages/trpc/lib/routers/hotels/output.ts
Normal file
654
packages/trpc/lib/routers/hotels/output.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { toLang } from "@scandic-hotels/common/utils/languages"
|
||||
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
|
||||
|
||||
import { RateEnum } from "../../enums/rate"
|
||||
import { RateTypeEnum } from "../../enums/rateType"
|
||||
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,
|
||||
relationshipsSchema as hotelRelationshipsSchema,
|
||||
} from "./schemas/hotel"
|
||||
import { addressSchema } from "./schemas/hotel/address"
|
||||
import { detailedFacilitiesSchema } from "./schemas/hotel/detailedFacility"
|
||||
import { locationSchema } from "./schemas/hotel/location"
|
||||
import { imageSchema } from "./schemas/image"
|
||||
import { locationCitySchema } from "./schemas/location/city"
|
||||
import { locationHotelSchema } from "./schemas/location/hotel"
|
||||
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
|
||||
}),
|
||||
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({
|
||||
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 "CancellableBefore6PM":
|
||||
return RateEnum.flex
|
||||
case "Changeable":
|
||||
return RateEnum.change
|
||||
case "NotCancellable":
|
||||
return RateEnum.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
|
||||
|
||||
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 const locationsSchema = z.object({
|
||||
data: z
|
||||
.array(
|
||||
z
|
||||
.discriminatedUnion("type", [locationCitySchema, locationHotelSchema])
|
||||
.transform((location) => {
|
||||
if (location.type === "cities") {
|
||||
return {
|
||||
...location.attributes,
|
||||
country: location.attributes.countryName || "",
|
||||
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 && node.isPublished)
|
||||
.filter((node) => {
|
||||
if (node.type === "hotels") {
|
||||
if (!node.operaId) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (!node.cityIdentifier) {
|
||||
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),
|
||||
})
|
||||
.or(z.object({ packages: z.tuple([]) })),
|
||||
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 || 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,
|
||||
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([]),
|
||||
}),
|
||||
})
|
||||
.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 destinationPagesHotelDataSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
location: locationSchema,
|
||||
cityIdentifier: z.string().optional(),
|
||||
tripadvisor: z.number().optional(),
|
||||
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".
|
||||
url: z.string().optional(),
|
||||
hotelContent: z
|
||||
.object({
|
||||
texts: z.object({
|
||||
descriptions: z.object({
|
||||
short: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
.transform(({ data: { hotelContent, ...data } }) => {
|
||||
return {
|
||||
hotel: {
|
||||
...data,
|
||||
hotelDescription: hotelContent?.texts.descriptions?.short,
|
||||
},
|
||||
url: data.url ?? "",
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user