Merged in feat/sw-2864-move-hotels-router-to-trpc-package (pull request #2410)
feat (SW-2864): Move booking router to trpc package * 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 * Move booking router to trpc package * Merge branch 'master' into feat/sw-2864-move-hotels-router-to-trpc-package Approved-by: Linus Flood
This commit is contained in:
9
packages/trpc/lib/constants/bedTypeMap.ts
Normal file
9
packages/trpc/lib/constants/bedTypeMap.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ChildBedMapEnum } from "../enums/childBedMapEnum"
|
||||
import { ChildBedTypeEnum } from "../enums/childBedTypeEnum"
|
||||
|
||||
export const bedTypeMap: Record<number, ChildBedTypeEnum> = {
|
||||
[ChildBedMapEnum.IN_ADULTS_BED]: ChildBedTypeEnum.ParentsBed,
|
||||
[ChildBedMapEnum.IN_CRIB]: ChildBedTypeEnum.Crib,
|
||||
[ChildBedMapEnum.IN_EXTRA_BED]: ChildBedTypeEnum.ExtraBed,
|
||||
[ChildBedMapEnum.UNKNOWN]: ChildBedTypeEnum.Unknown,
|
||||
}
|
||||
2
packages/trpc/lib/constants/booking.ts
Normal file
2
packages/trpc/lib/constants/booking.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// TODO better name?
|
||||
export const REDEMPTION = "redemption"
|
||||
257
packages/trpc/lib/constants/countries.ts
Normal file
257
packages/trpc/lib/constants/countries.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
export const countriesMap = {
|
||||
Afghanistan: "AF",
|
||||
Albania: "AL",
|
||||
Algeria: "DZ",
|
||||
"American Samoa": "AS",
|
||||
Andorra: "AD",
|
||||
Angola: "AO",
|
||||
Anguilla: "AI",
|
||||
Antarctica: "AQ",
|
||||
"Antigua and Barbuda": "AG",
|
||||
Argentina: "AR",
|
||||
Armenia: "AM",
|
||||
Aruba: "AW",
|
||||
Australia: "AU",
|
||||
Austria: "AT",
|
||||
Azerbaijan: "AZ",
|
||||
Bahamas: "BS",
|
||||
Bahrain: "BH",
|
||||
Bangladesh: "BD",
|
||||
Barbados: "BB",
|
||||
Belarus: "BY",
|
||||
Belgium: "BE",
|
||||
Belize: "BZ",
|
||||
Benin: "BJ",
|
||||
Bermuda: "BM",
|
||||
Bhutan: "BT",
|
||||
Bolivia: "BO",
|
||||
Bonaire: "BQ",
|
||||
"Bosnia and Herzegovina": "BA",
|
||||
Botswana: "BW",
|
||||
"Bouvet Island": "BV",
|
||||
Brazil: "BR",
|
||||
"British Indian Ocean Territory": "IO",
|
||||
"Brunei Darussalam": "BN",
|
||||
Bulgaria: "BG",
|
||||
"Burkina Faso": "BF",
|
||||
Burundi: "BI",
|
||||
Cambodia: "KH",
|
||||
Cameroon: "CM",
|
||||
Canada: "CA",
|
||||
"Cape Verde": "CV",
|
||||
"Cayman Islands": "KY",
|
||||
"Central African Republic": "CF",
|
||||
Chad: "TD",
|
||||
Chile: "CL",
|
||||
China: "CN",
|
||||
"Christmas Island": "CX",
|
||||
"Cocos (Keeling) Islands": "CC",
|
||||
Colombia: "CO",
|
||||
Comoros: "KM",
|
||||
Congo: "CG",
|
||||
"Congo, The Democratic Republic of the": "CD",
|
||||
"Cook Islands": "CK",
|
||||
"Costa Rica": "CR",
|
||||
"Côte d'Ivoire": "CI",
|
||||
Croatia: "HR",
|
||||
Cuba: "CU",
|
||||
Curacao: "CW",
|
||||
Cyprus: "CY",
|
||||
"Czech Republic": "CZ",
|
||||
Denmark: "DK",
|
||||
Djibouti: "DJ",
|
||||
Dominica: "DM",
|
||||
"Dominican Republic": "DO",
|
||||
Ecuador: "EC",
|
||||
Egypt: "EG",
|
||||
"El Salvador": "SV",
|
||||
"Equatorial Guinea": "GQ",
|
||||
Eritrea: "ER",
|
||||
Estonia: "EE",
|
||||
Eswatini: "SZ",
|
||||
Ethiopia: "ET",
|
||||
"Falkland Islands (Malvinas)": "FK",
|
||||
"Faroe Islands": "FO",
|
||||
Fiji: "FJ",
|
||||
Finland: "FI",
|
||||
France: "FR",
|
||||
"French Guiana": "GF",
|
||||
"French Polynesia": "PF",
|
||||
"French Southern Territories": "TF",
|
||||
Gabon: "GA",
|
||||
Gambia: "GM",
|
||||
Georgia: "GE",
|
||||
Germany: "DE",
|
||||
Ghana: "GH",
|
||||
Gibraltar: "GI",
|
||||
Greece: "GR",
|
||||
Greenland: "GL",
|
||||
Grenada: "GD",
|
||||
Guadeloupe: "GP",
|
||||
Guam: "GU",
|
||||
Guatemala: "GT",
|
||||
Guernsey: "GG",
|
||||
Guinea: "GN",
|
||||
"Guinea-Bissau": "GW",
|
||||
Guyana: "GY",
|
||||
Haiti: "HT",
|
||||
"Heard Island and Mcdonald Islands": "HM",
|
||||
"Holy See (Vatican City State)": "VA",
|
||||
Honduras: "HN",
|
||||
"Hong Kong": "HK",
|
||||
Hungary: "HU",
|
||||
Iceland: "IS",
|
||||
India: "IN",
|
||||
Indonesia: "ID",
|
||||
"Iran, Islamic Republic Of": "IR",
|
||||
Iraq: "IQ",
|
||||
Ireland: "IE",
|
||||
"Isle of Man": "IM",
|
||||
Israel: "IL",
|
||||
Italy: "IT",
|
||||
Jamaica: "JM",
|
||||
Japan: "JP",
|
||||
Jersey: "JE",
|
||||
Jordan: "JO",
|
||||
Kazakhstan: "KZ",
|
||||
Kenya: "KE",
|
||||
Kiribati: "KI",
|
||||
'Korea, Democratic People"S Republic of': "KP",
|
||||
"Korea, Republic of": "KR",
|
||||
Kuwait: "KW",
|
||||
Kyrgyzstan: "KG",
|
||||
Laos: "LA",
|
||||
Latvia: "LV",
|
||||
Lebanon: "LB",
|
||||
Lesotho: "LS",
|
||||
Liberia: "LR",
|
||||
"Libyan Arab Jamahiriya": "LY",
|
||||
Liechtenstein: "LI",
|
||||
Lithuania: "LT",
|
||||
Luxembourg: "LU",
|
||||
Macao: "MO",
|
||||
"Macedonia, The Former Yugoslav Republic of": "MK",
|
||||
Madagascar: "MG",
|
||||
Malawi: "MW",
|
||||
Malaysia: "MY",
|
||||
Maldives: "MV",
|
||||
Mali: "ML",
|
||||
Malta: "MT",
|
||||
"Marshall Islands": "MH",
|
||||
Martinique: "MQ",
|
||||
Mauritania: "MR",
|
||||
Mauritius: "MU",
|
||||
Mayotte: "YT",
|
||||
Mexico: "MX",
|
||||
"Micronesia, Federated States of": "FM",
|
||||
"Moldova, Republic of": "MD",
|
||||
Monaco: "MC",
|
||||
Mongolia: "MN",
|
||||
Montenegro: "ME",
|
||||
Montserrat: "MS",
|
||||
Morocco: "MA",
|
||||
Mozambique: "MZ",
|
||||
Myanmar: "MM",
|
||||
Namibia: "NA",
|
||||
Nauru: "NR",
|
||||
Nepal: "NP",
|
||||
Netherlands: "NL",
|
||||
"Netherlands Antilles": "AN",
|
||||
"New Caledonia": "NC",
|
||||
"New Zealand": "NZ",
|
||||
Nicaragua: "NI",
|
||||
Niger: "NE",
|
||||
Nigeria: "NG",
|
||||
Niue: "NU",
|
||||
"Norfolk Island": "NF",
|
||||
"Northern Mariana Islands": "MP",
|
||||
Norway: "NO",
|
||||
Oman: "OM",
|
||||
Pakistan: "PK",
|
||||
Palau: "PW",
|
||||
Palestine: "PS",
|
||||
Panama: "PA",
|
||||
"Papua New Guinea": "PG",
|
||||
Paraguay: "PY",
|
||||
Peru: "PE",
|
||||
Philippines: "PH",
|
||||
Pitcairn: "PN",
|
||||
Poland: "PL",
|
||||
Portugal: "PT",
|
||||
"Puerto Rico": "PR",
|
||||
Qatar: "QA",
|
||||
RWANDA: "RW",
|
||||
Reunion: "RE",
|
||||
Romania: "RO",
|
||||
"Russian Federation": "RU",
|
||||
"Saint Barthelemy": "BL",
|
||||
"Saint Helena": "SH",
|
||||
"Saint Kitts and Nevis": "KN",
|
||||
"Saint Lucia": "LC",
|
||||
"Saint Martin": "MF",
|
||||
"Saint Pierre and Miquelon": "PM",
|
||||
"Saint Vincent and the Grenadines": "VC",
|
||||
Samoa: "WS",
|
||||
"San Marino": "SM",
|
||||
"Sao Tome and Principe": "ST",
|
||||
"Saudi Arabia": "SA",
|
||||
Senegal: "SN",
|
||||
Serbia: "RS",
|
||||
Seychelles: "SC",
|
||||
"Sierra Leone": "SL",
|
||||
Singapore: "SG",
|
||||
"Sint Maarten": "SX",
|
||||
Slovakia: "SK",
|
||||
Slovenia: "SI",
|
||||
"Solomon Islands": "SB",
|
||||
Somalia: "SO",
|
||||
"South Africa": "ZA",
|
||||
"South Georgia and the South Sandwich Islands": "GS",
|
||||
"South Sudan": "SS",
|
||||
Spain: "ES",
|
||||
"Sri Lanka": "LK",
|
||||
Sudan: "SD",
|
||||
Suriname: "SR",
|
||||
"Svalbard and Jan Mayen": "SJ",
|
||||
Sweden: "SE",
|
||||
Switzerland: "CH",
|
||||
"Syrian Arab Republic": "SY",
|
||||
Taiwan: "TW",
|
||||
Tajikistan: "TJ",
|
||||
"Tanzania, United Republic of": "TZ",
|
||||
Thailand: "TH",
|
||||
"Timor-Leste": "TL",
|
||||
Togo: "TG",
|
||||
Tokelau: "TK",
|
||||
Tonga: "TO",
|
||||
"Trinidad and Tobago": "TT",
|
||||
Tunisia: "TN",
|
||||
Turkey: "TR",
|
||||
Turkmenistan: "TM",
|
||||
"Turks and Caicos Islands": "TC",
|
||||
Tuvalu: "TV",
|
||||
Uganda: "UG",
|
||||
Ukraine: "UA",
|
||||
"United Arab Emirates": "AE",
|
||||
"United Kingdom": "GB",
|
||||
"United States": "US",
|
||||
"United States Minor Outlying Islands": "UM",
|
||||
Uruguay: "UY",
|
||||
Uzbekistan: "UZ",
|
||||
Vanuatu: "VU",
|
||||
Venezuela: "VE",
|
||||
Vietnam: "VN",
|
||||
"Virgin Islands, British": "VG",
|
||||
"Virgin Islands, U.S.": "VI",
|
||||
"Wallis and Futuna": "WF",
|
||||
"Western Sahara": "EH",
|
||||
Yemen: "YE",
|
||||
Zambia: "ZM",
|
||||
Zimbabwe: "ZW",
|
||||
"Åland Islands": "AX",
|
||||
} as const
|
||||
|
||||
export const countries = Object.keys(countriesMap).map((country) => ({
|
||||
code: countriesMap[country as keyof typeof countriesMap],
|
||||
name: country as keyof typeof countriesMap,
|
||||
}))
|
||||
14
packages/trpc/lib/enums/bedType.ts
Normal file
14
packages/trpc/lib/enums/bedType.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export enum BedTypeEnum {
|
||||
King = "King",
|
||||
Queen = "Queen",
|
||||
Single = "Single",
|
||||
Twin = "Twin",
|
||||
Other = "Other",
|
||||
}
|
||||
|
||||
export enum ExtraBedTypeEnum {
|
||||
SofaBed = "SofaBed",
|
||||
WallBed = "WallBed",
|
||||
PullOutBed = "PullOutBed",
|
||||
BunkBed = "BunkBed",
|
||||
}
|
||||
12
packages/trpc/lib/enums/bookingErrorCode.ts
Normal file
12
packages/trpc/lib/enums/bookingErrorCode.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum BookingErrorCodeEnum {
|
||||
InternalError = "InternalError",
|
||||
ReservationError = "ReservationError",
|
||||
AvailabilityError = "AvailabilityError",
|
||||
BookingStatusNotFound = "BookingStatusNotFound",
|
||||
TransactionAbandoned = "TransactionAbandoned",
|
||||
TransactionCancelled = "TransactionCancelled",
|
||||
TransactionFailed = "TransactionFailed",
|
||||
BookingStateError = "BookingStateError",
|
||||
MembershipFailedError = "MembershipFailedError",
|
||||
NoAvailabilityForRateAndRoomType = "NoAvailabilityForRateAndRoomType",
|
||||
}
|
||||
@@ -91,7 +91,6 @@ export const metadataQueryRouter = router({
|
||||
}
|
||||
|
||||
let data: unknown = null
|
||||
|
||||
switch (ctx.contentType) {
|
||||
case PageContentTypeEnum.accountPage:
|
||||
const accountPageResponse = await fetchMetadata<{
|
||||
|
||||
13
packages/trpc/lib/routers/hotels/helpers.ts
Normal file
13
packages/trpc/lib/routers/hotels/helpers.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { bedTypeMap } from "../../constants/bedTypeMap"
|
||||
|
||||
import type { Child } from "../../types/child"
|
||||
|
||||
export function generateChildrenString(children: Child[]): string {
|
||||
return `[${children
|
||||
.map((child) => {
|
||||
const age = child.age
|
||||
const bedType = bedTypeMap[parseInt(child.bed.toString())]
|
||||
return `${age}:${bedType}`
|
||||
})
|
||||
.join(",")}]`
|
||||
}
|
||||
4
packages/trpc/lib/routers/hotels/index.ts
Normal file
4
packages/trpc/lib/routers/hotels/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { mergeRouters } from "../.."
|
||||
import { hotelQueryRouter } from "./query"
|
||||
|
||||
export const hotelsRouter = mergeRouters(hotelQueryRouter)
|
||||
1183
packages/trpc/lib/routers/hotels/query.ts
Normal file
1183
packages/trpc/lib/routers/hotels/query.ts
Normal file
File diff suppressed because it is too large
Load Diff
49
packages/trpc/lib/routers/hotels/schemas/meetingRoom.ts
Normal file
49
packages/trpc/lib/routers/hotels/schemas/meetingRoom.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { imageSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/image"
|
||||
|
||||
export const meetingRoomsSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
attributes: z.object({
|
||||
name: z.string(),
|
||||
email: z.string().optional(),
|
||||
phoneNumber: z.string(),
|
||||
size: z.number(),
|
||||
doorWidth: z.number(),
|
||||
doorHeight: z.number(),
|
||||
length: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
floorNumber: z.number(),
|
||||
content: z.object({
|
||||
images: z.array(imageSchema),
|
||||
texts: z.object({
|
||||
facilityInformation: z.string().optional(),
|
||||
surroundingInformation: z.string().optional(),
|
||||
descriptions: z.object({
|
||||
short: z.string().optional(),
|
||||
medium: z.string().optional(),
|
||||
}),
|
||||
meetingDescription: z
|
||||
.object({
|
||||
short: z.string().optional(),
|
||||
medium: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
seatings: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
capacity: z.number(),
|
||||
})
|
||||
),
|
||||
lighting: z.string(),
|
||||
sortOrder: z.number().optional(),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
@@ -1,17 +1,31 @@
|
||||
import deepmerge from "deepmerge"
|
||||
import stringify from "json-stable-stringify-without-jsonify"
|
||||
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { getCacheClient } from "@scandic-hotels/common/dataCache"
|
||||
import { createCounter } from "@scandic-hotels/common/telemetry"
|
||||
import { chunk } from "@scandic-hotels/common/utils/chunk"
|
||||
import {
|
||||
hotelsAvailabilitySchema,
|
||||
packagesSchema,
|
||||
roomFeaturesSchema,
|
||||
roomsAvailabilitySchema,
|
||||
} from "@scandic-hotels/trpc/routers/hotels/output"
|
||||
import { sortRoomConfigs } from "@scandic-hotels/trpc/utils/sortRoomConfigs"
|
||||
|
||||
import { env } from "../../../env/server"
|
||||
import * as api from "../../api"
|
||||
import { REDEMPTION } from "../../constants/booking"
|
||||
import { cache } from "../../DUPLICATED/cache"
|
||||
import { BookingErrorCodeEnum } from "../../enums/bookingErrorCode"
|
||||
import { HotelTypeEnum } from "../../enums/hotelType"
|
||||
import { RoomPackageCodeEnum } from "../../enums/roomFilter"
|
||||
import { AvailabilityEnum } from "../../enums/selectHotel"
|
||||
import { badRequestError } from "../../errors"
|
||||
import { toApiLang } from "../../utils"
|
||||
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
|
||||
import { generateChildrenString } from "./helpers"
|
||||
import {
|
||||
citiesByCountrySchema,
|
||||
citiesSchema,
|
||||
@@ -21,14 +35,32 @@ import {
|
||||
locationsSchema,
|
||||
} from "./output"
|
||||
|
||||
import type { Room as RoomCategory } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type {
|
||||
Product,
|
||||
Products,
|
||||
RateDefinition,
|
||||
RedemptionsProduct,
|
||||
RoomConfiguration,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { Endpoint } from "../../api/endpoints"
|
||||
import type {
|
||||
HotelsAvailabilityInputSchema,
|
||||
HotelsByHotelIdsAvailabilityInputSchema,
|
||||
RoomsAvailabilityExtendedInputSchema,
|
||||
RoomsAvailabilityInputRoom,
|
||||
RoomsAvailabilityOutputSchema,
|
||||
} from "../../types/availability"
|
||||
import type { BedTypeSelection } from "../../types/bedTypeSelection"
|
||||
import type { DestinationPagesHotelData, HotelInput } from "../../types/hotel"
|
||||
import type {
|
||||
CitiesGroupedByCountry,
|
||||
CityLocation,
|
||||
} from "../../types/locations"
|
||||
import type { PackagesOutput } from "../../types/packages"
|
||||
import type { RoomFeaturesInput } from "./input"
|
||||
import type { Cities } from "./output"
|
||||
|
||||
export const locationsAffix = "locations"
|
||||
@@ -594,3 +626,712 @@ export async function getCity({
|
||||
"1d"
|
||||
)
|
||||
}
|
||||
|
||||
export const TWENTYFOUR_HOURS = 60 * 60 * 24
|
||||
|
||||
function findProduct(product: Products, rateDefinition: RateDefinition) {
|
||||
if ("corporateCheque" in product) {
|
||||
return product.corporateCheque.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
|
||||
if (("member" in product && product.member) || "public" in product) {
|
||||
let isMemberRate = false
|
||||
if (product.member) {
|
||||
isMemberRate = product.member.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
let isPublicRate = false
|
||||
if (product.public) {
|
||||
isPublicRate = product.public.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
return isMemberRate || isPublicRate
|
||||
}
|
||||
|
||||
if ("voucher" in product) {
|
||||
return product.voucher.rateCode === rateDefinition.rateCode
|
||||
}
|
||||
|
||||
if (Array.isArray(product)) {
|
||||
return product.find(
|
||||
(r) => r.redemption.rateCode === rateDefinition.rateCode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHotelsAvailabilityByCity(
|
||||
input: HotelsAvailabilityInputSchema,
|
||||
apiLang: string,
|
||||
token: string, // Either service token or user access token in case of redemption search
|
||||
userPoints: number = 0
|
||||
) {
|
||||
const {
|
||||
cityId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
redemption,
|
||||
} = input
|
||||
|
||||
const params: Record<string, string | number> = {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
...(children && { children }),
|
||||
...(bookingCode && { bookingCode }),
|
||||
...(redemption ? { isRedemption: "true" } : {}),
|
||||
language: apiLang,
|
||||
}
|
||||
|
||||
const getHotelsAvailabilityByCityCounter = createCounter(
|
||||
"hotel",
|
||||
"getHotelsAvailabilityByCity"
|
||||
)
|
||||
const metricsGetHotelsAvailabilityByCity =
|
||||
getHotelsAvailabilityByCityCounter.init({
|
||||
apiLang,
|
||||
cityId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
redemption,
|
||||
})
|
||||
|
||||
metricsGetHotelsAvailabilityByCity.start()
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.city(cityId),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
if (!apiResponse.ok) {
|
||||
await metricsGetHotelsAvailabilityByCity.httpError(apiResponse)
|
||||
throw new Error("Failed to fetch hotels availability by city")
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson)
|
||||
if (!validateAvailabilityData.success) {
|
||||
metricsGetHotelsAvailabilityByCity.validationError(
|
||||
validateAvailabilityData.error
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
if (redemption) {
|
||||
validateAvailabilityData.data.data.forEach((data) => {
|
||||
data.attributes.productType?.redemptions?.forEach((r) => {
|
||||
r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const result = {
|
||||
availability: validateAvailabilityData.data.data.flatMap(
|
||||
(hotels) => hotels.attributes
|
||||
),
|
||||
}
|
||||
|
||||
metricsGetHotelsAvailabilityByCity.success()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getHotelsAvailabilityByHotelIds(
|
||||
input: HotelsByHotelIdsAvailabilityInputSchema,
|
||||
apiLang: string,
|
||||
serviceToken: string
|
||||
) {
|
||||
const {
|
||||
hotelIds,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
} = input
|
||||
|
||||
const params = new URLSearchParams([
|
||||
["roomStayStartDate", roomStayStartDate],
|
||||
["roomStayEndDate", roomStayEndDate],
|
||||
["adults", adults.toString()],
|
||||
["children", children ?? ""],
|
||||
["bookingCode", bookingCode],
|
||||
["language", apiLang],
|
||||
])
|
||||
|
||||
const getHotelsAvailabilityByHotelIdsCounter = createCounter(
|
||||
"hotel",
|
||||
"getHotelsAvailabilityByHotelIds"
|
||||
)
|
||||
const metricsGetHotelsAvailabilityByHotelIds =
|
||||
getHotelsAvailabilityByHotelIdsCounter.init({
|
||||
apiLang,
|
||||
hotelIds,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
})
|
||||
|
||||
metricsGetHotelsAvailabilityByHotelIds.start()
|
||||
|
||||
const cacheClient = await getCacheClient()
|
||||
|
||||
const result = cacheClient.cacheOrGet(
|
||||
`${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`,
|
||||
async () => {
|
||||
/**
|
||||
* Since API expects the params appended and not just
|
||||
* a comma separated string we need to initialize the
|
||||
* SearchParams with a sequence of pairs
|
||||
* (hotelIds=810&hotelIds=879&hotelIds=222 etc.)
|
||||
**/
|
||||
|
||||
hotelIds.forEach((hotelId) =>
|
||||
params.append("hotelIds", hotelId.toString())
|
||||
)
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.hotels(),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${serviceToken}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
if (!apiResponse.ok) {
|
||||
await metricsGetHotelsAvailabilityByHotelIds.httpError(apiResponse)
|
||||
throw new Error("Failed to fetch hotels availability by hotelIds")
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const validateAvailabilityData =
|
||||
hotelsAvailabilitySchema.safeParse(apiJson)
|
||||
if (!validateAvailabilityData.success) {
|
||||
metricsGetHotelsAvailabilityByHotelIds.validationError(
|
||||
validateAvailabilityData.error
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
return {
|
||||
availability: validateAvailabilityData.data.data.flatMap(
|
||||
(hotels) => hotels.attributes
|
||||
),
|
||||
}
|
||||
},
|
||||
env.CACHE_TIME_CITY_SEARCH
|
||||
)
|
||||
|
||||
metricsGetHotelsAvailabilityByHotelIds.success()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function getRoomFeaturesInventory(
|
||||
input: RoomFeaturesInput,
|
||||
token: string
|
||||
) {
|
||||
const {
|
||||
adults,
|
||||
childrenInRoom,
|
||||
endDate,
|
||||
hotelId,
|
||||
roomFeatureCodes,
|
||||
startDate,
|
||||
} = input
|
||||
|
||||
const params = {
|
||||
adults,
|
||||
hotelId,
|
||||
roomFeatureCode: roomFeatureCodes,
|
||||
roomStayEndDate: endDate,
|
||||
roomStayStartDate: startDate,
|
||||
...(childrenInRoom?.length && {
|
||||
children: generateChildrenString(childrenInRoom),
|
||||
}),
|
||||
}
|
||||
|
||||
const getRoomFeaturesInventoryCounter = createCounter(
|
||||
"hotel",
|
||||
"getRoomFeaturesInventory"
|
||||
)
|
||||
const metricsGetRoomFeaturesInventory =
|
||||
getRoomFeaturesInventoryCounter.init(params)
|
||||
|
||||
metricsGetRoomFeaturesInventory.start()
|
||||
|
||||
const cacheClient = await getCacheClient()
|
||||
|
||||
const result = cacheClient.cacheOrGet(
|
||||
stringify(input),
|
||||
async function () {
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.roomFeatures(hotelId),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
await metricsGetRoomFeaturesInventory.httpError(apiResponse)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data)
|
||||
if (!validatedRoomFeaturesData.success) {
|
||||
metricsGetRoomFeaturesInventory.validationError(
|
||||
validatedRoomFeaturesData.error
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return validatedRoomFeaturesData.data
|
||||
},
|
||||
"5m"
|
||||
)
|
||||
|
||||
metricsGetRoomFeaturesInventory.success()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getPackages(input: PackagesOutput, serviceToken: string) {
|
||||
const { adults, children, endDate, hotelId, lang, packageCodes, startDate } =
|
||||
input
|
||||
|
||||
const getPackagesCounter = createCounter("hotel", "getPackages")
|
||||
const metricsGetPackages = getPackagesCounter.init({
|
||||
input,
|
||||
})
|
||||
|
||||
metricsGetPackages.start()
|
||||
|
||||
const cacheClient = await getCacheClient()
|
||||
|
||||
const result = cacheClient.cacheOrGet(
|
||||
stringify(input),
|
||||
async function () {
|
||||
const apiLang = toApiLang(lang)
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
adults: adults.toString(),
|
||||
children: children.toString(),
|
||||
endDate,
|
||||
language: apiLang,
|
||||
startDate,
|
||||
})
|
||||
|
||||
packageCodes.forEach((code) => {
|
||||
searchParams.append("packageCodes", code)
|
||||
})
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Package.Packages.hotel(hotelId),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${serviceToken}`,
|
||||
},
|
||||
},
|
||||
searchParams
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
await metricsGetPackages.httpError(apiResponse)
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const validatedPackagesData = packagesSchema.safeParse(apiJson)
|
||||
if (!validatedPackagesData.success) {
|
||||
metricsGetPackages.validationError(validatedPackagesData.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return validatedPackagesData.data
|
||||
},
|
||||
"3h"
|
||||
)
|
||||
|
||||
metricsGetPackages.success()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getRoomsAvailability(
|
||||
input: RoomsAvailabilityOutputSchema,
|
||||
token: string,
|
||||
serviceToken: string,
|
||||
userPoints: number | undefined
|
||||
) {
|
||||
const {
|
||||
booking: { bookingCode, fromDate, hotelId, rooms, searchType, toDate },
|
||||
lang,
|
||||
} = input
|
||||
|
||||
const redemption = searchType === REDEMPTION
|
||||
|
||||
const getRoomsAvailabilityCounter = createCounter(
|
||||
"hotel",
|
||||
"getRoomsAvailability"
|
||||
)
|
||||
const metricsGetRoomsAvailability = getRoomsAvailabilityCounter.init({
|
||||
input,
|
||||
redemption,
|
||||
})
|
||||
|
||||
metricsGetRoomsAvailability.start()
|
||||
|
||||
const apiLang = toApiLang(lang)
|
||||
|
||||
const baseCacheKey = {
|
||||
bookingCode,
|
||||
fromDate,
|
||||
hotelId,
|
||||
lang,
|
||||
searchType,
|
||||
toDate,
|
||||
}
|
||||
|
||||
const cacheClient = await getCacheClient()
|
||||
const availabilityResponses = await Promise.allSettled(
|
||||
rooms.map((room: RoomsAvailabilityInputRoom) => {
|
||||
const cacheKey = {
|
||||
...baseCacheKey,
|
||||
room,
|
||||
}
|
||||
const result = cacheClient.cacheOrGet(
|
||||
stringify(cacheKey),
|
||||
async function () {
|
||||
{
|
||||
const params = {
|
||||
adults: room.adults,
|
||||
language: apiLang,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
...(room.childrenInRoom?.length && {
|
||||
children: generateChildrenString(room.childrenInRoom),
|
||||
}),
|
||||
...(room.bookingCode && { bookingCode: room.bookingCode }),
|
||||
...(redemption && { isRedemption: "true" }),
|
||||
}
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.hotel(hotelId),
|
||||
{
|
||||
cache: undefined, // overwrite default
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
await metricsGetRoomsAvailability.httpError(apiResponse)
|
||||
const text = await apiResponse.text()
|
||||
return { error: "http_error", details: text }
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const validateAvailabilityData =
|
||||
roomsAvailabilitySchema.safeParse(apiJson)
|
||||
if (!validateAvailabilityData.success) {
|
||||
metricsGetRoomsAvailability.validationError(
|
||||
validateAvailabilityData.error
|
||||
)
|
||||
|
||||
return {
|
||||
error: "validation_error",
|
||||
details: validateAvailabilityData.error,
|
||||
}
|
||||
}
|
||||
|
||||
if (redemption) {
|
||||
for (const roomConfig of validateAvailabilityData.data
|
||||
.roomConfigurations) {
|
||||
for (const product of roomConfig.redemptions) {
|
||||
if (userPoints) {
|
||||
product.redemption.hasEnoughPoints =
|
||||
userPoints >= product.redemption.localPrice.pointsPerStay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roomFeatures = await getPackages(
|
||||
{
|
||||
adults: room.adults,
|
||||
children: room.childrenInRoom?.length || 0,
|
||||
endDate: input.booking.toDate,
|
||||
hotelId: input.booking.hotelId,
|
||||
lang,
|
||||
packageCodes: [
|
||||
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
|
||||
RoomPackageCodeEnum.ALLERGY_ROOM,
|
||||
RoomPackageCodeEnum.PET_ROOM,
|
||||
],
|
||||
startDate: input.booking.fromDate,
|
||||
},
|
||||
serviceToken
|
||||
)
|
||||
|
||||
if (roomFeatures) {
|
||||
validateAvailabilityData.data.packages = roomFeatures
|
||||
}
|
||||
|
||||
// Fetch packages
|
||||
if (room.packages?.length) {
|
||||
const roomFeaturesInventory = await getRoomFeaturesInventory(
|
||||
{
|
||||
adults: room.adults,
|
||||
childrenInRoom: room.childrenInRoom,
|
||||
endDate: input.booking.toDate,
|
||||
hotelId: input.booking.hotelId,
|
||||
lang,
|
||||
roomFeatureCodes: room.packages,
|
||||
startDate: input.booking.fromDate,
|
||||
},
|
||||
serviceToken
|
||||
)
|
||||
|
||||
if (roomFeaturesInventory) {
|
||||
const features = roomFeaturesInventory.reduce<
|
||||
Record<string, number>
|
||||
>((fts, feat) => {
|
||||
fts[feat.roomTypeCode] = feat.features?.[0]?.inventory ?? 0
|
||||
return fts
|
||||
}, {})
|
||||
|
||||
const updatedRoomConfigurations =
|
||||
validateAvailabilityData.data.roomConfigurations
|
||||
// This filter is needed since we can get availability
|
||||
// back from roomFeatures yet the availability call
|
||||
// says there are no rooms left...
|
||||
.filter((rc) => rc.roomsLeft)
|
||||
.filter((rc) => features?.[rc.roomTypeCode])
|
||||
.map((rc) => ({
|
||||
...rc,
|
||||
roomsLeft: features[rc.roomTypeCode],
|
||||
status: AvailabilityEnum.Available,
|
||||
}))
|
||||
|
||||
validateAvailabilityData.data.roomConfigurations =
|
||||
updatedRoomConfigurations
|
||||
}
|
||||
}
|
||||
|
||||
return validateAvailabilityData.data
|
||||
}
|
||||
},
|
||||
"1m"
|
||||
)
|
||||
|
||||
return result
|
||||
})
|
||||
)
|
||||
|
||||
const data = availabilityResponses.map((availability) => {
|
||||
if (availability.status === "fulfilled") {
|
||||
return availability.value
|
||||
}
|
||||
return {
|
||||
details: availability.reason,
|
||||
error: "request_failure",
|
||||
}
|
||||
})
|
||||
|
||||
metricsGetRoomsAvailability.success()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function getSelectedRoomAvailability(
|
||||
rateCode: string,
|
||||
rateDefinitions: RateDefinition[],
|
||||
roomConfigurations: RoomConfiguration[],
|
||||
roomTypeCode: string,
|
||||
userPoints: number | undefined
|
||||
) {
|
||||
const rateDefinition = rateDefinitions.find((rd) => rd.rateCode === rateCode)
|
||||
if (!rateDefinition) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedRoom = roomConfigurations.find(
|
||||
(room) =>
|
||||
room.roomTypeCode === roomTypeCode &&
|
||||
room.products.find((product) => findProduct(product, rateDefinition))
|
||||
)
|
||||
|
||||
if (!selectedRoom) {
|
||||
return null
|
||||
}
|
||||
|
||||
let product: Product | RedemptionsProduct | undefined =
|
||||
selectedRoom.products.find((product) =>
|
||||
findProduct(product, rateDefinition)
|
||||
)
|
||||
|
||||
if (!product) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(product)) {
|
||||
const redemptionProduct = userPoints
|
||||
? product.find(
|
||||
(r) =>
|
||||
r.redemption.rateCode === rateDefinition.rateCode &&
|
||||
r.redemption.localPrice.pointsPerStay <= userPoints
|
||||
)
|
||||
: undefined
|
||||
if (!redemptionProduct) {
|
||||
return null
|
||||
}
|
||||
product = redemptionProduct
|
||||
}
|
||||
|
||||
return {
|
||||
rateDefinition,
|
||||
rateDefinitions,
|
||||
rooms: roomConfigurations,
|
||||
product,
|
||||
selectedRoom,
|
||||
}
|
||||
}
|
||||
|
||||
export function getBedTypes(
|
||||
rooms: RoomConfiguration[],
|
||||
roomType: string,
|
||||
roomCategories?: RoomCategory[]
|
||||
) {
|
||||
if (!roomCategories) {
|
||||
return []
|
||||
}
|
||||
|
||||
return rooms
|
||||
.filter(
|
||||
(room) => room.status === AvailabilityEnum.Available || room.roomsLeft > 0
|
||||
)
|
||||
.filter((room) => room.roomType === roomType)
|
||||
.map((availRoom) => {
|
||||
const matchingRoom = roomCategories
|
||||
?.find((room) =>
|
||||
room.roomTypes
|
||||
.map((roomType) => roomType.code)
|
||||
.includes(availRoom.roomTypeCode)
|
||||
)
|
||||
?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode)
|
||||
|
||||
if (matchingRoom) {
|
||||
return {
|
||||
description: matchingRoom.description,
|
||||
size: matchingRoom.mainBed.widthRange,
|
||||
value: matchingRoom.code,
|
||||
type: matchingRoom.mainBed.type,
|
||||
roomsLeft: availRoom.roomsLeft,
|
||||
extraBed: matchingRoom.fixedExtraBed
|
||||
? {
|
||||
type: matchingRoom.fixedExtraBed.type,
|
||||
description: matchingRoom.fixedExtraBed.description,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((bed): bed is BedTypeSelection => Boolean(bed))
|
||||
}
|
||||
|
||||
export function mergeRoomTypes(roomConfigurations: RoomConfiguration[]) {
|
||||
// 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
|
||||
roomConfigurations.sort(sortRoomConfigs)
|
||||
|
||||
const roomConfigs = new Map<string, RoomConfiguration>()
|
||||
for (const roomConfig of roomConfigurations) {
|
||||
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())
|
||||
}
|
||||
|
||||
export function selectRateRedirectURL(
|
||||
input: RoomsAvailabilityExtendedInputSchema,
|
||||
selectedRooms: boolean[]
|
||||
) {
|
||||
const searchParams = new URLSearchParams({
|
||||
errorCode: BookingErrorCodeEnum.AvailabilityError,
|
||||
fromdate: input.booking.fromDate,
|
||||
hotel: input.booking.hotelId,
|
||||
todate: input.booking.toDate,
|
||||
})
|
||||
if (input.booking.searchType) {
|
||||
searchParams.set("searchtype", input.booking.searchType)
|
||||
}
|
||||
for (const [idx, room] of input.booking.rooms.entries()) {
|
||||
searchParams.set(`room[${idx}].adults`, room.adults.toString())
|
||||
|
||||
if (selectedRooms[idx]) {
|
||||
if (room.counterRateCode) {
|
||||
searchParams.set(`room[${idx}].counterratecode`, room.counterRateCode)
|
||||
}
|
||||
searchParams.set(`room[${idx}].ratecode`, room.rateCode)
|
||||
searchParams.set(`room[${idx}].roomtype`, room.roomTypeCode)
|
||||
} else {
|
||||
if (!searchParams.has("modifyRateIndex")) {
|
||||
searchParams.set("modifyRateIndex", idx.toString())
|
||||
}
|
||||
}
|
||||
if (room.bookingCode) {
|
||||
searchParams.set(`room[${idx}].bookingCode`, room.bookingCode)
|
||||
}
|
||||
if (room.packages) {
|
||||
searchParams.set(`room[${idx}].packages`, room.packages.join(","))
|
||||
}
|
||||
if (room.childrenInRoom?.length) {
|
||||
for (const [i, kid] of room.childrenInRoom.entries()) {
|
||||
searchParams.set(`room[${idx}].child[${i}].age`, kid.age.toString())
|
||||
searchParams.set(`room[${idx}].child[${i}].bed`, kid.bed.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${selectRate(input.lang)}?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
47
packages/trpc/lib/routers/user/helpers.ts
Normal file
47
packages/trpc/lib/routers/user/helpers.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
Membership,
|
||||
NativeFriendsMembership,
|
||||
UserLoyalty,
|
||||
} from "../../types/user"
|
||||
|
||||
export enum scandicMembershipTypes {
|
||||
SCANDIC_NATIVE = "SCANDIC_NATIVE",
|
||||
SAS_EB = "SAS_EB",
|
||||
}
|
||||
|
||||
export function isScandicNativeMembership(
|
||||
membership: Membership
|
||||
): membership is NativeFriendsMembership {
|
||||
return membership.type === scandicMembershipTypes.SCANDIC_NATIVE
|
||||
}
|
||||
|
||||
export function getFriendsMembership(userLoyalty: UserLoyalty) {
|
||||
const { memberships, ...loyalty } = userLoyalty
|
||||
|
||||
const friendsMembership = memberships.find(isScandicNativeMembership)
|
||||
|
||||
if (!friendsMembership) return null
|
||||
|
||||
const pointExpiration = loyalty.pointExpirations
|
||||
.sort(
|
||||
(a, b) => new Date(a.expires).getTime() - new Date(b.expires).getTime()
|
||||
)
|
||||
.at(0)
|
||||
|
||||
// Map to the same format that was used with Profile V1 to avoid larger changes for now.
|
||||
const result = {
|
||||
membershipType: friendsMembership.type,
|
||||
membershipNumber: friendsMembership.membershipNumber,
|
||||
membershipLevel: loyalty.tier,
|
||||
nextLevel: friendsMembership.nextTier,
|
||||
currentPoints: loyalty.points.spendable,
|
||||
expirationDate: loyalty.tierExpires,
|
||||
nightsToTopTier: friendsMembership.nightsToTopTier,
|
||||
pointsRequiredToNextlevel: friendsMembership.pointsToNextTier,
|
||||
tierExpirationDate: loyalty.tierExpires,
|
||||
pointsExpiryDate: pointExpiration?.expires,
|
||||
pointsToExpire: pointExpiration?.points,
|
||||
memberSince: friendsMembership.memberSince,
|
||||
}
|
||||
return result
|
||||
}
|
||||
145
packages/trpc/lib/routers/user/output.ts
Normal file
145
packages/trpc/lib/routers/user/output.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { countriesMap } from "../../constants/countries"
|
||||
import { getFriendsMembership } from "./helpers"
|
||||
|
||||
const scandicFriendsTier = z.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"])
|
||||
const sasEurobonusTier = z.enum(["EBB", "EBS", "EBG", "EBD", "EBP"])
|
||||
|
||||
const commonMembershipSchema = z.object({
|
||||
membershipNumber: z.string(),
|
||||
tierExpires: z.string().nullish().default(null),
|
||||
memberSince: z.string().nullish(),
|
||||
})
|
||||
|
||||
// This prevents validation errors if the API returns an unhandled membership type
|
||||
const otherMembershipSchema = z
|
||||
.object({
|
||||
// This ensures that `type` won't widen into "string", losing the literal types, when used in a union
|
||||
type: z.string().refine((val): val is string & {} => true),
|
||||
})
|
||||
.merge(commonMembershipSchema)
|
||||
|
||||
export const sasMembershipSchema = z
|
||||
.object({
|
||||
type: z.literal("SAS_EB"),
|
||||
tier: sasEurobonusTier,
|
||||
nextTier: sasEurobonusTier.nullish(),
|
||||
spendablePoints: z.number().nullish(),
|
||||
boostedByScandic: z.boolean().nullish(),
|
||||
boostedTier: sasEurobonusTier.nullish(),
|
||||
boostedTierExpires: z.string().nullish().default(null),
|
||||
})
|
||||
.merge(commonMembershipSchema)
|
||||
.transform((response) => {
|
||||
return {
|
||||
...response,
|
||||
tierExpires:
|
||||
// SAS API returns 1900-01-01 for non-expiring tiers
|
||||
response.tierExpires === "1900-01-01" ? null : response.tierExpires,
|
||||
}
|
||||
})
|
||||
|
||||
export const friendsMembershipSchema = z
|
||||
.object({
|
||||
type: z.literal("SCANDIC_NATIVE"),
|
||||
tier: scandicFriendsTier,
|
||||
nextTier: scandicFriendsTier.nullish(),
|
||||
pointsToNextTier: z.number().nullish(),
|
||||
nightsToTopTier: z.number().nullish(),
|
||||
})
|
||||
.merge(commonMembershipSchema)
|
||||
|
||||
export const membershipSchema = z.union([
|
||||
friendsMembershipSchema,
|
||||
sasMembershipSchema,
|
||||
otherMembershipSchema,
|
||||
])
|
||||
|
||||
const pointExpirationSchema = z.object({
|
||||
points: z.number().int(),
|
||||
expires: z.string(),
|
||||
})
|
||||
|
||||
export const userLoyaltySchema = z.object({
|
||||
memberships: z.array(membershipSchema),
|
||||
points: z.object({
|
||||
spendable: z.number().int(),
|
||||
earned: z.number().int(),
|
||||
spent: z.number().int(),
|
||||
}),
|
||||
tier: scandicFriendsTier,
|
||||
tierExpires: z.string(),
|
||||
tierBoostedBy: z.string().nullish(),
|
||||
pointExpirations: z.array(pointExpirationSchema),
|
||||
})
|
||||
|
||||
export const getUserSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
dateOfBirth: z.string().optional().default("1900-01-01"),
|
||||
email: z.string().email(),
|
||||
firstName: z.string(),
|
||||
language: z
|
||||
.string()
|
||||
// Preserve Profile v1 formatting for now so it matches ApiLang enum
|
||||
.transform((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.optional(),
|
||||
lastName: z.string(),
|
||||
phoneNumber: z.string().optional(),
|
||||
profileId: z.string(),
|
||||
membershipNumber: z.string(),
|
||||
address: z
|
||||
.object({
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
countryCode: z.nativeEnum(countriesMap).optional(),
|
||||
streetAddress: z.string().optional(),
|
||||
zipCode: z.string().optional(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
loyalty: userLoyaltySchema.optional(),
|
||||
}),
|
||||
type: z.string(),
|
||||
}),
|
||||
})
|
||||
.transform((apiResponse) => {
|
||||
return {
|
||||
...apiResponse.data.attributes,
|
||||
membership: apiResponse.data.attributes.loyalty
|
||||
? getFriendsMembership(apiResponse.data.attributes.loyalty)
|
||||
: null,
|
||||
name: `${apiResponse.data.attributes.firstName} ${apiResponse.data.attributes.lastName}`,
|
||||
}
|
||||
})
|
||||
|
||||
export const creditCardSchema = z
|
||||
.object({
|
||||
attribute: z.object({
|
||||
cardName: z.string().optional(),
|
||||
alias: z.string(),
|
||||
truncatedNumber: z.string().transform((s) => s.slice(-4)),
|
||||
expirationDate: z.string(),
|
||||
cardType: z
|
||||
.string()
|
||||
.transform((s) => s.charAt(0).toLowerCase() + s.slice(1)),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
.transform((apiResponse) => {
|
||||
return {
|
||||
id: apiResponse.id,
|
||||
type: apiResponse.attribute.cardType,
|
||||
truncatedNumber: apiResponse.attribute.truncatedNumber,
|
||||
alias: apiResponse.attribute.alias,
|
||||
expirationDate: apiResponse.attribute.expirationDate,
|
||||
cardType: apiResponse.attribute.cardType,
|
||||
}
|
||||
})
|
||||
|
||||
export const creditCardsSchema = z.object({
|
||||
data: z.array(creditCardSchema),
|
||||
})
|
||||
79
packages/trpc/lib/routers/user/utils.ts
Normal file
79
packages/trpc/lib/routers/user/utils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createCounter } from "@scandic-hotels/common/telemetry"
|
||||
|
||||
import * as api from "../../api"
|
||||
import { cache } from "../../DUPLICATED/cache"
|
||||
import { getUserSchema } from "./output"
|
||||
|
||||
import type { Session } from "next-auth"
|
||||
|
||||
export const getVerifiedUser = cache(
|
||||
async ({
|
||||
session,
|
||||
includeExtendedPartnerData,
|
||||
}: {
|
||||
session: Session
|
||||
includeExtendedPartnerData?: boolean
|
||||
}) => {
|
||||
const getVerifiedUserCounter = createCounter("user", "getVerifiedUser")
|
||||
const metricsGetVerifiedUser = getVerifiedUserCounter.init()
|
||||
|
||||
metricsGetVerifiedUser.start()
|
||||
|
||||
const now = Date.now()
|
||||
if (session.token.expires_at && session.token.expires_at < now) {
|
||||
metricsGetVerifiedUser.dataError(`Token expired`)
|
||||
return { error: true, cause: "token_expired" } as const
|
||||
}
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v2.Profile.profile,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token.access_token}`,
|
||||
},
|
||||
},
|
||||
includeExtendedPartnerData
|
||||
? { includes: "extendedPartnerInformation" }
|
||||
: {}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
await metricsGetVerifiedUser.httpError(apiResponse)
|
||||
|
||||
if (apiResponse.status === 401) {
|
||||
return { error: true, cause: "unauthorized" } as const
|
||||
} else if (apiResponse.status === 403) {
|
||||
return { error: true, cause: "forbidden" } as const
|
||||
} else if (apiResponse.status === 404) {
|
||||
return { error: true, cause: "notfound" } as const
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
cause: "unknown",
|
||||
status: apiResponse.status,
|
||||
} as const
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
if (!apiJson.data?.attributes) {
|
||||
metricsGetVerifiedUser.dataError(
|
||||
`Missing data attributes in API response`,
|
||||
{
|
||||
data: apiJson,
|
||||
}
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const verifiedData = getUserSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
metricsGetVerifiedUser.validationError(verifiedData.error)
|
||||
return null
|
||||
}
|
||||
|
||||
metricsGetVerifiedUser.success()
|
||||
|
||||
return verifiedData
|
||||
}
|
||||
)
|
||||
3
packages/trpc/lib/types/authError.ts
Normal file
3
packages/trpc/lib/types/authError.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface RefreshTokenError {
|
||||
error?: "RefreshAccessTokenError"
|
||||
}
|
||||
46
packages/trpc/lib/types/availability.ts
Normal file
46
packages/trpc/lib/types/availability.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
import type {
|
||||
enterDetailsRoomsAvailabilityInputSchema,
|
||||
getHotelsByHotelIdsAvailabilityInputSchema,
|
||||
hotelsAvailabilityInputSchema,
|
||||
selectRateRoomsAvailabilityInputSchema,
|
||||
} from "../routers/hotels/input"
|
||||
import type { hotelsAvailabilitySchema } from "../routers/hotels/output"
|
||||
import type { productTypeSchema } from "../routers/hotels/schemas/availability/productType"
|
||||
import type {
|
||||
productTypeCorporateChequeSchema,
|
||||
productTypePointsSchema,
|
||||
productTypePriceSchema,
|
||||
productTypeVoucherSchema,
|
||||
} from "../routers/hotels/schemas/productTypePrice"
|
||||
|
||||
export type HotelsAvailability = z.output<typeof hotelsAvailabilitySchema>
|
||||
export type HotelsAvailabilityInputSchema = z.output<
|
||||
typeof hotelsAvailabilityInputSchema
|
||||
>
|
||||
export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
|
||||
typeof getHotelsByHotelIdsAvailabilityInputSchema
|
||||
>
|
||||
export type RoomsAvailabilityInputSchema = z.input<
|
||||
typeof selectRateRoomsAvailabilityInputSchema
|
||||
>
|
||||
export type RoomsAvailabilityOutputSchema = z.output<
|
||||
typeof selectRateRoomsAvailabilityInputSchema
|
||||
>
|
||||
export type RoomsAvailabilityInputRoom =
|
||||
RoomsAvailabilityInputSchema["booking"]["rooms"][number]
|
||||
export type RoomsAvailabilityExtendedInputSchema = z.input<
|
||||
typeof enterDetailsRoomsAvailabilityInputSchema
|
||||
>
|
||||
|
||||
export type ProductType = z.output<typeof productTypeSchema>
|
||||
export type ProductTypePrices = z.output<typeof productTypePriceSchema>
|
||||
export type ProductTypePoints = z.output<typeof productTypePointsSchema>
|
||||
export type ProductTypeVoucher = z.output<typeof productTypeVoucherSchema>
|
||||
export type ProductTypeCheque = z.output<
|
||||
typeof productTypeCorporateChequeSchema
|
||||
>
|
||||
|
||||
export type HotelsAvailabilityItem =
|
||||
HotelsAvailability["data"][number]["attributes"]
|
||||
18
packages/trpc/lib/types/bedTypeSelection.ts
Normal file
18
packages/trpc/lib/types/bedTypeSelection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { BedTypeEnum, ExtraBedTypeEnum } from "../enums/bedType"
|
||||
|
||||
export type BedTypeSelection = {
|
||||
description: string
|
||||
size: {
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
value: string
|
||||
type: BedTypeEnum
|
||||
roomsLeft: number
|
||||
extraBed:
|
||||
| {
|
||||
description: string
|
||||
type: ExtraBedTypeEnum
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
6
packages/trpc/lib/types/child.ts
Normal file
6
packages/trpc/lib/types/child.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ChildBedMapEnum } from "../enums/childBedMapEnum"
|
||||
|
||||
export interface Child {
|
||||
bed: ChildBedMapEnum
|
||||
age: number
|
||||
}
|
||||
7
packages/trpc/lib/types/loginType.ts
Normal file
7
packages/trpc/lib/types/loginType.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum LoginTypeEnum {
|
||||
email = "email",
|
||||
"membership number" = "membership number",
|
||||
"email link" = "email link",
|
||||
"dtmc" = "dtmc",
|
||||
}
|
||||
export type LoginType = keyof typeof LoginTypeEnum
|
||||
27
packages/trpc/lib/types/packages.ts
Normal file
27
packages/trpc/lib/types/packages.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { BreakfastPackageEnum } from "../enums/breakfast"
|
||||
import type { RoomPackageCodeEnum } from "../enums/roomFilter"
|
||||
import type {
|
||||
ancillaryPackageInputSchema,
|
||||
breakfastPackageInputSchema,
|
||||
roomPackagesInputSchema,
|
||||
} from "../routers/hotels/input"
|
||||
import type { packagesSchema } from "../routers/hotels/output"
|
||||
|
||||
export interface BreackfastPackagesInput
|
||||
extends z.input<typeof breakfastPackageInputSchema> {}
|
||||
|
||||
export interface AncillaryPackagesInput
|
||||
extends z.input<typeof ancillaryPackageInputSchema> {}
|
||||
|
||||
export interface PackagesInput
|
||||
extends z.input<typeof roomPackagesInputSchema> {}
|
||||
|
||||
export interface PackagesOutput
|
||||
extends z.output<typeof roomPackagesInputSchema> {}
|
||||
|
||||
export type Packages = z.output<typeof packagesSchema>
|
||||
export type Package = NonNullable<Packages>[number]
|
||||
|
||||
export type PackageEnum = BreakfastPackageEnum | RoomPackageCodeEnum
|
||||
25
packages/trpc/lib/types/room.ts
Normal file
25
packages/trpc/lib/types/room.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { RateEnum } from "../enums/rate"
|
||||
import type { BedTypeSelection } from "./bedTypeSelection"
|
||||
import type { Package } from "./packages"
|
||||
import type { Product } from "./roomAvailability"
|
||||
|
||||
export interface Room {
|
||||
bedTypes: BedTypeSelection[]
|
||||
breakfastIncluded: boolean
|
||||
cancellationRule: string
|
||||
cancellationText: string
|
||||
mustBeGuaranteed: boolean
|
||||
memberMustBeGuaranteed: boolean | undefined
|
||||
packages: Package[]
|
||||
rate: RateEnum
|
||||
rateDefinitionTitle: string
|
||||
rateDetails: string[]
|
||||
memberRateDetails: string[] | undefined
|
||||
rateTitle: string | undefined
|
||||
rateType: string
|
||||
roomRate: Product
|
||||
roomType: string
|
||||
roomTypeCode: string
|
||||
isAvailable: boolean
|
||||
isFlexRate: boolean
|
||||
}
|
||||
29
packages/trpc/lib/types/user.ts
Normal file
29
packages/trpc/lib/types/user.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { z } from "zod"
|
||||
|
||||
import type { getFriendsMembership } from "../routers/user/helpers"
|
||||
import type {
|
||||
creditCardSchema,
|
||||
friendsMembershipSchema,
|
||||
getUserSchema,
|
||||
sasMembershipSchema,
|
||||
userLoyaltySchema,
|
||||
} from "../routers/user/output"
|
||||
|
||||
/**
|
||||
* All extended field needs to be added by API team to response or
|
||||
* we have to get the values from elsewhere
|
||||
*/
|
||||
export interface User extends z.output<typeof getUserSchema> {}
|
||||
|
||||
export type CreditCard = z.output<typeof creditCardSchema>
|
||||
|
||||
export type UserLoyalty = z.output<typeof userLoyaltySchema>
|
||||
|
||||
export type Membership = UserLoyalty["memberships"][number]
|
||||
export type NativeFriendsMembership = z.output<typeof friendsMembershipSchema>
|
||||
export type EurobonusMembership = z.output<typeof sasMembershipSchema>
|
||||
|
||||
export type FriendsMembership = ReturnType<typeof getFriendsMembership>
|
||||
|
||||
export type EurobonusTier = EurobonusMembership["tier"]
|
||||
export type FriendsTier = NativeFriendsMembership["tier"]
|
||||
Reference in New Issue
Block a user