Files
web/server/routers/hotels/output.ts
Matilda Landström 22f3656b92 refactor(SW-296)
2024-10-08 08:53:41 +02:00

794 lines
19 KiB
TypeScript

import { z } from "zod"
import { toLang } from "@/server/utils"
import { getPoiGroupByCategoryName } from "./utils"
import {
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
const ratingsSchema = z
.object({
tripAdvisor: z.object({
numberOfReviews: z.number(),
rating: z.number(),
ratingImageUrl: z.string(),
webUrl: z.string(),
awards: z.array(
z.object({
displayName: z.string(),
images: z.object({
small: z.string(),
medium: z.string(),
large: z.string(),
}),
})
),
reviews: z
.object({
widgetHtmlTagId: z.string(),
widgetScriptEmbedUrlIframe: z.string(),
widgetScriptEmbedUrlJavaScript: z.string(),
})
.optional(),
}),
})
.optional()
const addressSchema = z.object({
streetAddress: z.string(),
city: z.string(),
zipCode: z.string(),
country: z.string(),
})
const contactInformationSchema = z.object({
phoneNumber: z.string(),
faxNumber: z.string().optional(),
email: z.string(),
websiteUrl: z.string(),
})
const checkinSchema = z.object({
checkInTime: z.string(),
checkOutTime: z.string(),
onlineCheckOutAvailableFrom: z.string().nullable().optional(),
onlineCheckout: z.boolean(),
})
const ecoLabelsSchema = z.object({
euEcoLabel: z.boolean(),
greenGlobeLabel: z.boolean(),
nordicEcoLabel: z.boolean(),
svanenEcoLabelCertificateNumber: z.string().optional(),
})
const hotelFacilityDetailSchema = z.object({
heading: z.string(),
description: z.string(),
})
const hotelFacilitySchema = z.object({
breakfast: hotelFacilityDetailSchema,
checkout: hotelFacilityDetailSchema,
gym: hotelFacilityDetailSchema,
internet: hotelFacilityDetailSchema,
laundry: hotelFacilityDetailSchema,
luggage: hotelFacilityDetailSchema,
shop: hotelFacilityDetailSchema,
telephone: hotelFacilityDetailSchema,
})
const hotelInformationDetailSchema = z.object({
heading: z.string(),
description: z.string(),
link: z.string().optional(),
})
const hotelInformationSchema = z.object({
accessibility: hotelInformationDetailSchema,
safety: hotelInformationDetailSchema,
sustainability: hotelInformationDetailSchema,
})
const interiorSchema = z.object({
numberOfBeds: z.number(),
numberOfCribs: z.number(),
numberOfFloors: z.number(),
numberOfRooms: z.object({
connected: z.number(),
forAllergics: z.number().optional(),
forDisabled: z.number(),
nonSmoking: z.number(),
pet: z.number(),
withExtraBeds: z.number(),
total: z.number(),
}),
})
const receptionHoursSchema = z.object({
alwaysOpen: z.boolean(),
isClosed: z.boolean(),
openingTime: z.string().optional(),
closingTime: z.string().optional(),
})
const locationSchema = z.object({
distanceToCentre: z.number(),
latitude: z.number(),
longitude: z.number(),
})
const imageMetaDataSchema = z.object({
title: z.string(),
altText: z.string(),
altText_En: z.string(),
copyRight: z.string(),
})
const imageSizesSchema = z.object({
tiny: z.string(),
small: z.string(),
medium: z.string(),
large: z.string(),
})
const hotelContentSchema = z.object({
images: z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
}),
texts: z.object({
facilityInformation: z.string(),
surroundingInformation: z.string(),
descriptions: z.object({
short: z.string(),
medium: z.string(),
}),
}),
restaurantsOverviewPage: z.object({
restaurantsOverviewPageLinkText: z.string(),
restaurantsOverviewPageLink: z.string(),
restaurantsContentDescriptionShort: z.string(),
restaurantsContentDescriptionMedium: z.string(),
}),
})
const detailedFacilitySchema = z.object({
id: z.number(),
name: z.string(),
public: z.boolean(),
sortOrder: z.number(),
filter: z.string().optional(),
})
export const facilitySchema = z.object({
headingText: z.string(),
heroImages: z.array(
z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
})
const healthFacilitySchema = z.object({
type: z.string(),
content: z.object({
images: z.array(
z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
texts: z.object({
facilityInformation: z.string().optional(),
surroundingInformation: z.string().optional(),
descriptions: z.object({
short: z.string(),
medium: z.string(),
}),
}),
}),
openingDetails: z.object({
useManualOpeningHours: z.boolean(),
manualOpeningHours: z.string().optional(),
openingHours: z.object({
ordinary: z.object({
alwaysOpen: z.boolean(),
isClosed: z.boolean(),
openingTime: z.string().optional(),
closingTime: z.string().optional(),
sortOrder: z.number().optional(),
}),
weekends: z.object({
alwaysOpen: z.boolean(),
isClosed: z.boolean(),
openingTime: z.string().optional(),
closingTime: z.string().optional(),
sortOrder: z.number().optional(),
}),
}),
}),
details: z.array(
z.object({
name: z.string(),
type: z.string(),
value: z.string().optional(),
})
),
})
const rewardNightSchema = z.object({
points: z.number(),
campaign: z.object({
start: z.string(),
end: z.string(),
points: z.number(),
}),
})
const poiGroups = z.nativeEnum(PointOfInterestGroupEnum)
const poiCategoryNames = z.nativeEnum(PointOfInterestCategoryNameEnum)
export const pointOfInterestSchema = z
.object({
name: z.string(),
distance: z.number(),
category: z.object({
name: poiCategoryNames,
group: z.string(),
}),
location: locationSchema,
isHighlighted: z.boolean(),
})
.transform((poi) => ({
name: poi.name,
distance: poi.distance,
categoryName: poi.category.name,
group: getPoiGroupByCategoryName(poi.category.name),
coordinates: {
lat: poi.location.latitude,
lng: poi.location.longitude,
},
}))
const parkingPricingSchema = z.object({
freeParking: z.boolean(),
paymentType: z.string(),
localCurrency: z.object({
currency: z.string(),
range: z.object({
min: z.number().optional(),
max: z.number().optional(),
}),
ordinary: z.array(
z.object({
period: z.string(),
amount: z.number().optional(),
startTime: z.string(),
endTime: z.string(),
})
),
weekend: z.array(
z.object({
period: z.string(),
amount: z.number().optional(),
startTime: z.string(),
endTime: z.string(),
})
),
}),
requestedCurrency: z
.object({
currency: z.string(),
range: z.object({
min: z.number(),
max: z.number(),
}),
ordinary: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
weekend: z.array(
z.object({
period: z.string(),
amount: z.number(),
startTime: z.string(),
endTime: z.string(),
})
),
})
.optional(),
})
export const parkingSchema = z.object({
type: z.string(),
name: z.string(),
address: z.string().optional(),
numberOfParkingSpots: z.number().optional(),
numberOfChargingSpaces: z.number().optional(),
distanceToHotel: z.number(),
canMakeReservation: z.boolean(),
pricing: parkingPricingSchema,
})
const specialNeedSchema = z.object({
name: z.string(),
details: z.string(),
})
const specialNeedGroupSchema = z.object({
name: z.string(),
specialNeeds: z.array(specialNeedSchema),
})
const socialMediaSchema = z.object({
instagram: z.string().optional(),
facebook: z.string().optional(),
})
const metaSpecialAlertSchema = z.object({
type: z.string(),
description: z.string().optional(),
displayInBookingFlow: z.boolean(),
startDate: z.string(),
endDate: z.string(),
})
const metaSchema = z.object({
specialAlerts: z.array(metaSpecialAlertSchema),
})
const relationshipsSchema = z.object({
restaurants: z.object({
links: z.object({
related: z.string(),
}),
}),
nearbyHotels: z.object({
links: z.object({
related: z.string(),
}),
}),
roomCategories: z.object({
links: z.object({
related: z.string(),
}),
}),
meetingRooms: z.object({
links: z.object({
related: z.string(),
}),
}),
})
const roomContentSchema = z.object({
images: z.array(
z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
texts: z.object({
descriptions: z.object({
short: z.string(),
medium: z.string(),
}),
}),
})
const roomTypesSchema = z.object({
name: z.string(),
description: z.string(),
code: z.string(),
roomCount: z.number(),
mainBed: z.object({
type: z.string(),
description: z.string(),
widthRange: z.object({
min: z.number(),
max: z.number(),
}),
}),
fixedExtraBed: z.object({
type: z.string(),
description: z.string().optional(),
widthRange: z.object({
min: z.number(),
max: z.number(),
}),
}),
roomSize: z.object({
min: z.number(),
max: z.number(),
}),
occupancy: z.object({
total: z.number(),
adults: z.number(),
children: z.number(),
}),
isLackingCribs: z.boolean(),
isLackingExtraBeds: z.boolean(),
})
const roomFacilitiesSchema = z.object({
availableInAllRooms: z.boolean(),
name: z.string(),
isUniqueSellingPoint: z.boolean(),
sortOrder: z.number(),
})
export const roomSchema = z.object({
attributes: z.object({
name: z.string(),
sortOrder: z.number(),
content: roomContentSchema,
roomTypes: z.array(roomTypesSchema),
roomFacilities: z.array(roomFacilitiesSchema),
occupancy: z.object({
total: z.number(),
adults: z.number(),
children: z.number(),
}),
roomSize: z.object({
min: z.number(),
max: z.number(),
}),
}),
id: z.string(),
type: z.enum(["roomcategories"]),
})
const merchantInformationSchema = z.object({
webMerchantId: z.string(),
cards: z.record(z.string(), z.boolean()).transform((val) => {
return Object.entries(val)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
}),
alternatePaymentOptions: z
.record(z.string(), z.boolean())
.transform((val) => {
return Object.entries(val)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
}),
})
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
export const getHotelDataSchema = z.object({
data: z.object({
id: z.string(),
type: z.string(), // No enum here but the standard return appears to be "hotels".
language: z.string().transform((val) => {
const lang = toLang(val)
if (!lang) {
throw new Error("Invalid language")
}
return lang
}),
attributes: z.object({
name: z.string(),
operaId: z.string(),
keywords: z.array(z.string()),
isPublished: z.boolean(),
cityId: z.string(),
cityName: z.string(),
ratings: ratingsSchema,
address: addressSchema,
contactInformation: contactInformationSchema,
hotelFacts: z.object({
checkin: checkinSchema,
ecoLabels: ecoLabelsSchema,
hotelFacilityDetail: hotelFacilitySchema,
hotelInformation: hotelInformationSchema,
interior: interiorSchema,
receptionHours: receptionHoursSchema,
yearBuilt: z.string(),
}),
location: locationSchema,
hotelContent: hotelContentSchema,
detailedFacilities: z.array(detailedFacilitySchema),
healthFacilities: z.array(healthFacilitySchema),
merchantInformationData: merchantInformationSchema,
rewardNight: rewardNightSchema,
pointsOfInterest: z
.array(pointOfInterestSchema)
.transform((pois) => pois.sort((a, b) => a.distance - b.distance)),
parking: z.array(parkingSchema),
specialNeedGroups: z.array(specialNeedGroupSchema),
socialMedia: socialMediaSchema,
meta: metaSchema.optional(),
isActive: z.boolean(),
conferencesAndMeetings: facilitySchema.optional(),
healthAndWellness: facilitySchema.optional(),
restaurantImages: facilitySchema.optional(),
}),
relationships: relationshipsSchema,
}),
// NOTE: We can pass an "include" param to the hotel API to retrieve
// additional data for an individual hotel.
included: z.array(roomSchema).optional(),
})
const occupancySchema = z.object({
adults: z.number(),
children: z.number(),
})
const bestPricePerStaySchema = z.object({
currency: z.string(),
amount: z.number(),
regularAmount: z.number(),
memberAmount: z.number(),
discountRate: z.number(),
discountAmount: z.number(),
points: z.number(),
numberOfVouchers: z.number(),
numberOfBonusCheques: z.number(),
})
const bestPricePerNightSchema = z.object({
currency: z.string(),
amount: z.number(),
regularAmount: z.number(),
memberAmount: z.number(),
discountRate: z.number(),
discountAmount: z.number(),
points: z.number(),
numberOfVouchers: z.number(),
numberOfBonusCheques: z.number(),
})
const linksSchema = z.object({
links: z.array(
z.object({
url: z.string().url(),
type: z.string(),
})
),
})
const availabilitySchema = z.object({
data: z.array(
z.object({
attributes: z.object({
checkInDate: z.string(),
checkOutDate: z.string(),
occupancy: occupancySchema.optional(),
status: z.string(),
hotelId: z.number(),
ratePlanSet: z.string().optional(),
bestPricePerStay: bestPricePerStaySchema.optional(),
bestPricePerNight: bestPricePerNightSchema.optional(),
}),
relationships: linksSchema.optional(),
type: z.string().optional(),
})
),
})
export const getAvailabilitySchema = availabilitySchema
export type Availability = z.infer<typeof availabilitySchema>
export type AvailabilityPrices =
Availability["data"][number]["attributes"]["bestPricePerNight"]
const flexibilityPrice = z.object({
standard: z.number(),
member: z.number(),
})
const rate = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
size: z.string(),
imageSrc: z.string(),
breakfastIncluded: z.boolean(),
prices: z.object({
currency: z.string(),
nonRefundable: flexibilityPrice,
freeRebooking: flexibilityPrice,
freeCancellation: flexibilityPrice,
}),
})
export const getRatesSchema = z.array(rate)
export type Rate = z.infer<typeof rate>
const hotelFilter = z.object({
roomFacilities: z.array(z.string()),
hotelFacilities: z.array(z.string()),
hotelSurroundings: z.array(z.string()),
})
export const getFiltersSchema = hotelFilter
export type HotelFilter = z.infer<typeof hotelFilter>
export const apiCitiesByCountrySchema = z.object({
data: z.array(
z
.object({
attributes: z.object({
cityIdentifier: z.string().optional(),
name: z.string(),
keywords: z.array(z.string()).optional(),
timeZoneId: z.string().optional(),
ianaTimeZoneId: z.string().optional(),
isPublished: z.boolean().optional().default(false),
}),
id: z.string(),
type: z.literal("cities"),
})
.transform((data) => {
return {
...data.attributes,
id: data.id,
type: data.type,
}
})
),
})
export interface CitiesByCountry
extends z.output<typeof apiCitiesByCountrySchema> {}
export type CitiesGroupedByCountry = Record<string, CitiesByCountry["data"]>
export const apiCountriesSchema = z.object({
data: z
.array(
z.object({
attributes: z.object({
currency: z.string().optional(),
name: z.string(),
}),
hotelInformationSystemId: z.number().optional(),
id: z.string().optional(),
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 interface Countries extends z.output<typeof apiCountriesSchema> {}
export const apiLocationCitySchema = z.object({
attributes: z.object({
cityIdentifier: z.string().optional(),
keyWords: z.array(z.string()).optional(),
name: z.string().optional().default(""),
}),
country: z.string().optional().default(""),
id: z.string().optional().default(""),
type: z.literal("cities"),
})
export const apiCitySchema = z
.object({
data: z.array(
z.object({
attributes: z.object({
cityIdentifier: z.string().optional(),
name: z.string(),
keywords: z.array(z.string()),
timeZoneId: z.string().optional(),
ianaTimeZoneId: z.string().optional(),
isPublished: z.boolean().optional().default(false),
}),
id: z.string().optional(),
type: z.literal("cities"),
})
),
})
.transform(({ data }) => {
if (data.length) {
const city = data[0]
return {
...city.attributes,
id: city.id,
type: city.type,
}
}
return null
})
export const apiLocationHotelSchema = z.object({
attributes: z.object({
distanceToCentre: z.number().optional(),
images: z
.object({
large: z.string().optional(),
medium: z.string().optional(),
small: z.string().optional(),
tiny: z.string().optional(),
})
.optional(),
keyWords: z.array(z.string()).optional(),
name: z.string().optional().default(""),
operaId: z.string().optional(),
}),
id: z.string().optional().default(""),
relationships: z
.object({
city: z
.object({
links: z
.object({
related: z.string().optional(),
})
.optional(),
})
.optional(),
})
.optional(),
type: z.literal("hotels"),
})
export const apiLocationsSchema = z.object({
data: z
.array(
z
.discriminatedUnion("type", [
apiLocationCitySchema,
apiLocationHotelSchema,
])
.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,
}
})
)
.transform((data) =>
data
.filter((node) => !!node)
.sort((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name)
} else {
return a.type === "cities" ? -1 : 1
}
})
),
})