diff --git a/apps/scandic-web/app/api/debug/route.ts b/apps/scandic-web/app/api/debug/route.ts index 2feb9b339..160fd2652 100644 --- a/apps/scandic-web/app/api/debug/route.ts +++ b/apps/scandic-web/app/api/debug/route.ts @@ -1,8 +1,6 @@ import { notFound } from "next/navigation" import { NextResponse } from "next/server" -import { logger } from "@scandic-hotels/common/logger" - import { env } from "@/env/server" import { auth } from "@/auth" @@ -13,6 +11,12 @@ export const GET = async () => { } const user = await auth() - logger.debug("[DEBUG] access-token", user?.token) - return NextResponse.json(user) + const sortedEnv = Object.keys(env) + .sort() + .reduce>((acc, key) => { + acc[key] = env[key as keyof typeof env] + return acc + }, {}) + + return NextResponse.json({ user, env: sortedEnv }) } diff --git a/apps/scandic-web/services/warmup/warmupCountries.ts b/apps/scandic-web/services/warmup/warmupCountries.ts index e448498a7..127b0eeb8 100644 --- a/apps/scandic-web/services/warmup/warmupCountries.ts +++ b/apps/scandic-web/services/warmup/warmupCountries.ts @@ -1,5 +1,5 @@ import { getServiceToken } from "@scandic-hotels/common/tokenManager" -import { getCountries } from "@scandic-hotels/trpc/routers/hotels/utils" +import { getCountries } from "@scandic-hotels/trpc/routers/hotels/services/getCountries" import type { Lang } from "@scandic-hotels/common/constants/language" diff --git a/apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts b/apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts index 51138028a..49db548de 100644 --- a/apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts +++ b/apps/scandic-web/services/warmup/warmupHotelIdsByCountry.ts @@ -2,10 +2,8 @@ import { Lang } from "@scandic-hotels/common/constants/language" import { createLogger } from "@scandic-hotels/common/logger/createLogger" import { getServiceToken } from "@scandic-hotels/common/tokenManager" import { safeTry } from "@scandic-hotels/common/utils/safeTry" -import { - getCountries, - getHotelIdsByCountry, -} from "@scandic-hotels/trpc/routers/hotels/utils" +import { getCountries } from "@scandic-hotels/trpc/routers/hotels/services/getCountries" +import { getHotelIdsByCountry } from "@scandic-hotels/trpc/routers/hotels/services/getHotelIdsByCountry" import type { WarmupFunction, WarmupResult } from "." diff --git a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext.tsx b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext.tsx index 05f061653..1df7ddb55 100644 --- a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext.tsx +++ b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext.tsx @@ -17,7 +17,7 @@ import { logger } from "@scandic-hotels/common/logger" import { type RouterOutput, trpc } from "@scandic-hotels/trpc/client" import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel" -import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/input" +import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema" import { useIsLoggedIn } from "../../hooks/useIsLoggedIn" import useLang from "../../hooks/useLang" diff --git a/packages/booking-flow/lib/contexts/SelectRate/types.ts b/packages/booking-flow/lib/contexts/SelectRate/types.ts index 63e1c8608..57eeaff4d 100644 --- a/packages/booking-flow/lib/contexts/SelectRate/types.ts +++ b/packages/booking-flow/lib/contexts/SelectRate/types.ts @@ -1,7 +1,7 @@ import { type RouterOutput } from "@scandic-hotels/trpc/client" import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" -import type { RoomsAvailabilityOutputSchema } from "@scandic-hotels/trpc/types/availability" +import type { RoomsAvailabilityOutputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema" import type { PackageEnum } from "@scandic-hotels/trpc/types/packages" import type { RoomConfiguration } from "@scandic-hotels/trpc/types/roomAvailability" diff --git a/packages/booking-flow/lib/trpc/memoizedRequests/getSelectedRoomsAvailabilityEnterDetails.ts b/packages/booking-flow/lib/trpc/memoizedRequests/getSelectedRoomsAvailabilityEnterDetails.ts index 9152dfa50..5474098d2 100644 --- a/packages/booking-flow/lib/trpc/memoizedRequests/getSelectedRoomsAvailabilityEnterDetails.ts +++ b/packages/booking-flow/lib/trpc/memoizedRequests/getSelectedRoomsAvailabilityEnterDetails.ts @@ -3,7 +3,7 @@ import { cache } from "react" import { serverClient } from "../../trpc" -import type { RoomsAvailabilityExtendedInputSchema } from "@scandic-hotels/trpc/types/availability" +import type { RoomsAvailabilityExtendedInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/enterDetails" export const getSelectedRoomsAvailabilityEnterDetails = cache( async function getMemoizedSelectedRoomsAvailability( diff --git a/packages/trpc/lib/routers/autocomplete/destinations.ts b/packages/trpc/lib/routers/autocomplete/destinations.ts index b4e96ca95..2df6f3cc9 100644 --- a/packages/trpc/lib/routers/autocomplete/destinations.ts +++ b/packages/trpc/lib/routers/autocomplete/destinations.ts @@ -10,12 +10,10 @@ import { safeProtectedServiceProcedure } from "../../procedures" import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils" import { getCountryPageUrls } from "../../routers/contentstack/destinationCountryPage/utils" import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils" -import { - getCitiesByCountry, - getCountries, - getLocations, -} from "../../routers/hotels/utils" +import { getLocations } from "../../routers/hotels/utils" import { ApiCountry, type Country } from "../../types/country" +import { getCitiesByCountry } from "../hotels/services/getCitiesByCountry" +import { getCountries } from "../hotels/services/getCountries" import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete" import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation" diff --git a/packages/trpc/lib/routers/booking/mutation.ts b/packages/trpc/lib/routers/booking/mutation.ts index ea4a89ca7..e5b84e8ba 100644 --- a/packages/trpc/lib/routers/booking/mutation.ts +++ b/packages/trpc/lib/routers/booking/mutation.ts @@ -7,7 +7,7 @@ import { createRefIdPlugin } from "../../plugins/refIdToConfirmationNumber" import { safeProtectedServiceProcedure } from "../../procedures" import { encrypt } from "../../utils/encryption" import { isValidSession } from "../../utils/session" -import { getMembershipNumber } from "../user/utils" +import { getMembershipNumber } from "../user/utils/getMemberShipNumber" import { addPackageInput, cancelBookingsInput, diff --git a/packages/trpc/lib/routers/booking/query.ts b/packages/trpc/lib/routers/booking/query.ts index a78cb6b1f..111290111 100644 --- a/packages/trpc/lib/routers/booking/query.ts +++ b/packages/trpc/lib/routers/booking/query.ts @@ -8,10 +8,10 @@ import { safeProtectedServiceProcedure, serviceProcedure, } from "../../procedures" -import { getHotel } from "../../routers/hotels/utils" import { toApiLang } from "../../utils" import { encrypt } from "../../utils/encryption" import { isValidSession } from "../../utils/session" +import { getHotel } from "../hotels/services/getHotel" import { getHotelRoom } from "./helpers" import { createRefIdInput, diff --git a/packages/trpc/lib/routers/contentstack/destinationCityPage/query.ts b/packages/trpc/lib/routers/contentstack/destinationCityPage/query.ts index edb904d65..f7577f995 100644 --- a/packages/trpc/lib/routers/contentstack/destinationCityPage/query.ts +++ b/packages/trpc/lib/routers/contentstack/destinationCityPage/query.ts @@ -9,7 +9,7 @@ import { import { request } from "../../../graphql/request" import { contentStackUidWithServiceProcedure } from "../../../procedures" import { generateRefsResponseTag } from "../../../utils/generateTag" -import { getCityByCityIdentifier } from "../../hotels/utils" +import { getCityByCityIdentifier } from "../../hotels/services/getCityByCityIdentifier" import { destinationCityPageRefsSchema, destinationCityPageSchema, diff --git a/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts b/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts index 826b8e238..af3f15dda 100644 --- a/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts +++ b/packages/trpc/lib/routers/contentstack/destinationCountryPage/utils.ts @@ -6,7 +6,7 @@ import { request } from "../../../graphql/request" import { ApiCountry, type Country } from "../../../types/country" import { DestinationCountryPageEnum } from "../../../types/destinationCountryPage" import { generateTag, generateTagsFromSystem } from "../../../utils/generateTag" -import { getCitiesByCountry } from "../../hotels/utils" +import { getCitiesByCountry } from "../../hotels/services/getCitiesByCountry" import { destinationCityListDataSchema } from "../destinationCityPage/output" import { countryPageUrlsSchema } from "./output" diff --git a/packages/trpc/lib/routers/contentstack/destinationOverviewPage/query.ts b/packages/trpc/lib/routers/contentstack/destinationOverviewPage/query.ts index 34f29efec..3a8318fc7 100644 --- a/packages/trpc/lib/routers/contentstack/destinationOverviewPage/query.ts +++ b/packages/trpc/lib/routers/contentstack/destinationOverviewPage/query.ts @@ -17,11 +17,9 @@ import { generateRefsResponseTag, generateTag, } from "../../../utils/generateTag" -import { - getCitiesByCountry, - getCountries, - getHotelIdsByCityId, -} from "../../hotels/utils" +import { getCitiesByCountry } from "../../hotels/services/getCitiesByCountry" +import { getCountries } from "../../hotels/services/getCountries" +import { getHotelIdsByCityId } from "../../hotels/services/getHotelIdsByCityId" import { getCityPageUrls } from "../destinationCityPage/utils" import { getCountryPageUrls } from "../destinationCountryPage/utils" import { diff --git a/packages/trpc/lib/routers/contentstack/metadata/query.ts b/packages/trpc/lib/routers/contentstack/metadata/query.ts index a51fe5d0c..383ce1da8 100644 --- a/packages/trpc/lib/routers/contentstack/metadata/query.ts +++ b/packages/trpc/lib/routers/contentstack/metadata/query.ts @@ -20,7 +20,7 @@ import { GetStartPageMetadata } from "../../../graphql/Query/StartPage/Metadata. import { request } from "../../../graphql/request" import { contentStackUidWithServiceProcedure } from "../../../procedures" import { generateTag } from "../../../utils/generateTag" -import { getHotel } from "../../hotels/utils" +import { getHotel } from "../../hotels/services/getHotel" import { getUrlsOfAllLanguages } from "../languageSwitcher/utils" import { getMetadataInput } from "./input" import { getNonContentstackUrls, rawMetadataSchema } from "./output" diff --git a/packages/trpc/lib/routers/contentstack/metadata/utils.ts b/packages/trpc/lib/routers/contentstack/metadata/utils.ts index 66952e292..d428225fe 100644 --- a/packages/trpc/lib/routers/contentstack/metadata/utils.ts +++ b/packages/trpc/lib/routers/contentstack/metadata/utils.ts @@ -8,9 +8,9 @@ import { getSortedCities } from "../../../utils/getSortedCities" import { getCityByCityIdentifier, getHotelIdsByCityIdentifier, - getHotelIdsByCountry, - getHotelsByHotelIds, -} from "../../hotels/utils" +} from "../../hotels/services/getCityByCityIdentifier" +import { getHotelIdsByCountry } from "../../hotels/services/getHotelIdsByCountry" +import { getHotelsByHotelIds } from "../../hotels/services/getHotelsByHotelIds" import { getCityPages } from "../destinationCountryPage/utils" import { transformDestinationFiltersResponse } from "../schemas/destinationFilters" diff --git a/packages/trpc/lib/routers/hotels/availability/enterDetails.ts b/packages/trpc/lib/routers/hotels/availability/enterDetails.ts new file mode 100644 index 000000000..83d3b20df --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/enterDetails.ts @@ -0,0 +1,178 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" +import { RateEnum } from "@scandic-hotels/common/constants/rate" +import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType" +import { createLogger } from "@scandic-hotels/common/logger/createLogger" + +import { SEARCH_TYPE_REDEMPTION } from "../../../constants/booking" +import { AvailabilityEnum } from "../../../enums/selectHotel" +import { unauthorizedError } from "../../../errors" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { getVerifiedUser } from "../../user/utils/getVerifiedUser" +import { baseBookingSchema, baseRoomSchema, selectedRoomSchema } from "../input" +import { getHotel } from "../services/getHotel" +import { getRoomsAvailability } from "../services/getRoomsAvailability" +import { + getBedTypes, + getSelectedRoomAvailability, + selectRateRedirectURL, +} from "../utils" + +import type { Room } from "../../../types/room" + +export type RoomsAvailabilityExtendedInputSchema = z.input< + typeof enterDetailsRoomsAvailabilityInputSchema +> +export const enterDetailsRoomsAvailabilityInputSchema = z.object({ + booking: baseBookingSchema.extend({ + rooms: z.array(baseRoomSchema.merge(selectedRoomSchema)), + }), + lang: z.nativeEnum(Lang), +}) + +const logger = createLogger("trpc:availability:enterDetails") +export const enterDetails = safeProtectedServiceProcedure + .input(enterDetailsRoomsAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ session: ctx.session }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, + }, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + }) + }) + .query(async function ({ ctx, input }) { + const availability = await getRoomsAvailability( + input, + ctx.token, + ctx.serviceToken, + ctx.userPoints + ) + const hotelData = await getHotel( + { + hotelId: input.booking.hotelId, + isCardOnlyPayment: false, + language: input.lang || ctx.lang, + }, + ctx.serviceToken + ) + + const selectedRooms = [] + for (const [idx, room] of availability.entries()) { + if (!room || "error" in room) { + logger.error(`Availability failed: ${room.error}`, room.details) + selectedRooms.push(null) + continue + } + const bookingRoom = input.booking.rooms[idx] + const selected = getSelectedRoomAvailability( + bookingRoom.rateCode, + room.rateDefinitions, + room.roomConfigurations, + bookingRoom.roomTypeCode, + ctx.userPoints + ) + if (!selected) { + logger.error("Unable to find selected room") + selectedRooms.push(null) + continue + } + + const { rateDefinition, rateDefinitions, product, rooms, selectedRoom } = + selected + + const bedTypes = getBedTypes( + rooms, + selectedRoom.roomType, + hotelData?.roomCategories + ) + + const counterRateCode = input.booking.rooms[idx].counterRateCode + const rateCode = input.booking.rooms[idx].rateCode + let memberRateDefinition = undefined + if ("member" in product && product.member && counterRateCode) { + memberRateDefinition = rateDefinitions.find( + (rate) => + (rate.rateCode.toLowerCase() === counterRateCode.toLowerCase() || + rate.rateCode.toLowerCase() === rateCode.toLowerCase()) && + rate.isMemberRate + ) + } + + const selectedPackages = input.booking.rooms[idx].packages + selectedRooms.push({ + bedTypes, + breakfastIncluded: rateDefinition.breakfastIncluded, + cancellationText: rateDefinition.cancellationText, + cancellationRule: rateDefinition.cancellationRule, + isAvailable: selectedRoom.status === AvailabilityEnum.Available, + isFlexRate: product.rate === RateEnum.flex, + memberMustBeGuaranteed: memberRateDefinition?.mustBeGuaranteed, + mustBeGuaranteed: rateDefinition.mustBeGuaranteed, + packages: room.packages.filter((pkg) => + selectedPackages?.includes(pkg.code) + ), + rate: product.rate, + rateDefinitionTitle: rateDefinition.title, + rateDetails: rateDefinition.generalTerms, + memberRateDetails: memberRateDefinition?.generalTerms, + // Send rate Title when it is a booking code rate + rateTitle: + rateDefinition.rateType !== RateTypeEnum.Regular + ? rateDefinition.title + : undefined, + rateType: rateDefinition.rateType, + roomRate: product, + roomType: selectedRoom.roomType, + roomTypeCode: selectedRoom.roomTypeCode, + }) + } + + const totalBedsAvailableForRoomTypeCode: Record = {} + for (const selectedRoom of selectedRooms) { + if (selectedRoom) { + if (!totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode]) { + totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] = + selectedRoom.bedTypes.reduce( + (total, bedType) => total + bedType.roomsLeft, + 0 + ) + } + } + } + + for (const [idx, selectedRoom] of selectedRooms.entries()) { + if (selectedRoom) { + const totalBedsLeft = + totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] + if (totalBedsLeft <= 0) { + selectedRooms[idx] = null + continue + } + totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] = + totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] - 1 + } + } + + if (selectedRooms.some((sr) => !sr)) { + console.log("DEBUG: REDIRECTING TO SELECT RATE", selectedRooms) + return selectRateRedirectURL(input, selectedRooms.map(Boolean)) + } + + const rooms: Room[] = selectedRooms.filter((sr) => !!sr) + return rooms + }) diff --git a/packages/trpc/lib/routers/hotels/availability/hotelsByCity.ts b/packages/trpc/lib/routers/hotels/availability/hotelsByCity.ts new file mode 100644 index 000000000..ce3634182 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/hotelsByCity.ts @@ -0,0 +1,122 @@ +import dayjs from "dayjs" +import { z } from "zod" + +import { getCacheClient } from "@scandic-hotels/common/dataCache" + +import { env } from "../../../../env/server" +import { unauthorizedError } from "../../../errors" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { toApiLang } from "../../../utils" +import { getVerifiedUser } from "../../user/utils/getVerifiedUser" +import { getHotelsAvailabilityByCity } from "../services/getHotelsAvailabilityByCity" + +export type HotelsAvailabilityInputSchema = z.output< + typeof hotelsAvailabilityInputSchema +> +export const hotelsAvailabilityInputSchema = z + .object({ + cityId: z.string(), + roomStayStartDate: z.string().refine( + (val) => { + const fromDate = dayjs(val) + + return fromDate.isValid() + }, + { + message: "FROMDATE_INVALID", + } + ), + roomStayEndDate: z.string().refine( + (val) => { + const fromDate = dayjs(val) + return fromDate.isValid() + }, + { + message: "TODATE_INVALID", + } + ), + adults: z.number(), + children: z.string().optional(), + bookingCode: z.string().optional().default(""), + redemption: z.boolean().optional().default(false), + }) + .refine( + (data) => { + const fromDate = dayjs(data.roomStayStartDate).startOf("day") + const toDate = dayjs(data.roomStayEndDate).startOf("day") + + return fromDate.isBefore(toDate) + }, + { + message: "FROMDATE_BEFORE_TODATE", + } + ) + .refine( + (data) => { + const fromDate = dayjs(data.roomStayStartDate) + const today = dayjs().startOf("day") + + return fromDate.isSameOrAfter(today) + }, + { + message: "FROMDATE_CANNOT_BE_IN_THE_PAST", + } + ) + +export const hotelsByCity = safeProtectedServiceProcedure + .input(hotelsAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.redemption) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ session: ctx.session }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, + }, + input, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + input, + }) + }) + .query(async ({ ctx, input }) => { + const { lang } = ctx + const apiLang = toApiLang(lang) + const { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, + } = input + + // In case of redemption do not cache result + if (redemption) { + return getHotelsAvailabilityByCity( + input, + apiLang, + ctx.token, + ctx.userPoints + ) + } + + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + `${cityId}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, + async () => { + return getHotelsAvailabilityByCity(input, apiLang, ctx.token) + }, + env.CACHE_TIME_CITY_SEARCH + ) + }) diff --git a/packages/trpc/lib/routers/hotels/availability/hotelsByCityWithBookingCode.ts b/packages/trpc/lib/routers/hotels/availability/hotelsByCityWithBookingCode.ts new file mode 100644 index 000000000..8a86a319f --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/hotelsByCityWithBookingCode.ts @@ -0,0 +1,58 @@ +import { serviceProcedure } from "../../../procedures" +import { toApiLang } from "../../../utils" +import { getHotelsAvailabilityByCity } from "../services/getHotelsAvailabilityByCity" +import { getHotelsAvailabilityByHotelIds } from "../services/getHotelsAvailabilityByHotelIds" +import { hotelsAvailabilityInputSchema } from "./hotelsByCity" + +export const hotelsByCityWithBookingCode = serviceProcedure + .input(hotelsAvailabilityInputSchema) + .query(async ({ input, ctx }) => { + const { lang } = ctx + const apiLang = toApiLang(lang) + + const bookingCodeAvailabilityResponse = await getHotelsAvailabilityByCity( + input, + apiLang, + ctx.serviceToken + ) + + // Get regular availability of hotels which don't have availability with booking code. + const unavailableHotelIds = bookingCodeAvailabilityResponse.availability + .filter((hotel) => { + return hotel.status === "NotAvailable" + }) + .flatMap((hotel) => { + return hotel.hotelId + }) + + // All hotels have availability with booking code no need to fetch regular prices. + // return response as is without any filtering as below. + if (!unavailableHotelIds || !unavailableHotelIds.length) { + return bookingCodeAvailabilityResponse + } + + const unavailableHotelsInput = { + ...input, + bookingCode: "", + hotelIds: unavailableHotelIds, + } + const unavailableHotels = await getHotelsAvailabilityByHotelIds( + unavailableHotelsInput, + apiLang, + ctx.serviceToken + ) + + // No regular rates available due to network or API failure (no need to filter & merge). + if (!unavailableHotels) { + return bookingCodeAvailabilityResponse + } + + // Filtering the response hotels to merge bookingCode rates and regular rates in single response. + return { + availability: bookingCodeAvailabilityResponse.availability + .filter((hotel) => { + return hotel.status === "Available" + }) + .concat(unavailableHotels.availability), + } + }) diff --git a/packages/trpc/lib/routers/hotels/availability/hotelsByHotelIds.ts b/packages/trpc/lib/routers/hotels/availability/hotelsByHotelIds.ts new file mode 100644 index 000000000..b936f7283 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/hotelsByHotelIds.ts @@ -0,0 +1,97 @@ +import dayjs from "dayjs" +import { z } from "zod" + +import { unauthorizedError } from "../../../errors" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { toApiLang } from "../../../utils" +import { getVerifiedUser } from "../../user/utils/getVerifiedUser" +import { getHotelsAvailabilityByHotelIds } from "../services/getHotelsAvailabilityByHotelIds" + +export type HotelsByHotelIdsAvailabilityInputSchema = z.output< + typeof getHotelsByHotelIdsAvailabilityInputSchema +> +export const getHotelsByHotelIdsAvailabilityInputSchema = z + .object({ + hotelIds: z.array(z.number()), + roomStayStartDate: z.string().refine( + (val) => { + const fromDate = dayjs(val) + return fromDate.isValid() + }, + { + message: "FROMDATE_INVALID", + } + ), + roomStayEndDate: z.string().refine( + (val) => { + const toDate = dayjs(val) + + return toDate.isValid() + }, + { + message: "TODATE_INVALID", + } + ), + adults: z.number(), + children: z.string().optional(), + bookingCode: z.string().optional().default(""), + redemption: z.boolean().optional().default(false), + }) + .refine( + (data) => { + const fromDate = dayjs(data.roomStayStartDate).startOf("day") + const toDate = dayjs(data.roomStayEndDate).startOf("day") + + return fromDate.isBefore(toDate) + }, + { + message: "FROMDATE_BEFORE_TODATE", + } + ) + .refine( + (data) => { + const fromDate = dayjs(data.roomStayStartDate) + const today = dayjs().startOf("day") + + return fromDate.isSameOrAfter(today) + }, + { + message: "FROMDATE_CANNOT_BE_IN_THE_PAST", + } + ) + +export const hotelsByHotelIds = safeProtectedServiceProcedure + .input(getHotelsByHotelIdsAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.redemption) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ session: ctx.session }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, + }, + input, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + input, + }) + }) + .query(async ({ input, ctx }) => { + const { lang } = ctx + const apiLang = toApiLang(lang) + return getHotelsAvailabilityByHotelIds( + input, + apiLang, + ctx.token, + ctx.userPoints + ) + }) diff --git a/packages/trpc/lib/routers/hotels/availability/index.ts b/packages/trpc/lib/routers/hotels/availability/index.ts new file mode 100644 index 000000000..b8fe31b27 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/index.ts @@ -0,0 +1,16 @@ +import { router } from "../../.." +import { enterDetails } from "./enterDetails" +import { hotelsByCity } from "./hotelsByCity" +import { hotelsByCityWithBookingCode } from "./hotelsByCityWithBookingCode" +import { hotelsByHotelIds } from "./hotelsByHotelIds" +import { myStay } from "./myStay" +import { selectRate } from "./selectRate" + +export const availability = router({ + hotelsByCity, + hotelsByHotelIds, + enterDetails, + myStay, + selectRate, + hotelsByCityWithBookingCode, +}) diff --git a/packages/trpc/lib/routers/hotels/availability/myStay.ts b/packages/trpc/lib/routers/hotels/availability/myStay.ts new file mode 100644 index 000000000..53650580d --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/myStay.ts @@ -0,0 +1,81 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" +import { createLogger } from "@scandic-hotels/common/logger/createLogger" + +import { SEARCH_TYPE_REDEMPTION } from "../../../constants/booking" +import { unauthorizedError } from "../../../errors" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { getVerifiedUser } from "../../user/utils/getVerifiedUser" +import { baseBookingSchema, baseRoomSchema, selectedRoomSchema } from "../input" +import { getRoomsAvailability } from "../services/getRoomsAvailability" +import { getSelectedRoomAvailability } from "../utils" + +export const myStayRoomAvailabilityInputSchema = z.object({ + booking: baseBookingSchema.extend({ + room: baseRoomSchema.merge(selectedRoomSchema), + }), + lang: z.nativeEnum(Lang), +}) + +const logger = createLogger("trpc:availability:myStay") +export const myStay = safeProtectedServiceProcedure + .input(myStayRoomAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ session: ctx.session }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, + }, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + }) + }) + .query(async function ({ ctx, input }) { + const [availability] = await getRoomsAvailability( + { + booking: { + ...input.booking, + rooms: [input.booking.room], + }, + lang: input.lang, + }, + ctx.token, + ctx.serviceToken, + ctx.userPoints + ) + + if (!availability || "error" in availability) { + return null + } + + const bookingRoom = input.booking.room + const selected = getSelectedRoomAvailability( + bookingRoom.rateCode, + availability.rateDefinitions, + availability.roomConfigurations, + bookingRoom.roomTypeCode, + ctx.userPoints + ) + + if (!selected) { + logger.error("Unable to find selected room") + return null + } + + return { + product: selected.product, + selectedRoom: selected.selectedRoom, + } + }) diff --git a/packages/trpc/lib/routers/hotels/availability/selectRate/index.ts b/packages/trpc/lib/routers/hotels/availability/selectRate/index.ts new file mode 100644 index 000000000..6de415feb --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/selectRate/index.ts @@ -0,0 +1,8 @@ +import { router } from "../../../.." +import { room } from "./room" +import { rooms } from "./rooms" + +export const selectRate = router({ + room, + rooms, +}) diff --git a/packages/trpc/lib/routers/hotels/availability/selectRate/room.ts b/packages/trpc/lib/routers/hotels/availability/selectRate/room.ts new file mode 100644 index 000000000..014db59b1 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/selectRate/room.ts @@ -0,0 +1,69 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" + +import { SEARCH_TYPE_REDEMPTION } from "../../../../constants/booking" +import { unauthorizedError } from "../../../../errors" +import { safeProtectedServiceProcedure } from "../../../../procedures" +import { getVerifiedUser } from "../../../user/utils/getVerifiedUser" +import { baseBookingSchema, baseRoomSchema } from "../../input" +import { getRoomsAvailability } from "../../services/getRoomsAvailability" +import { mergeRoomTypes } from "../../utils" + +export const selectRateRoomAvailabilityInputSchema = z.object({ + booking: baseBookingSchema.extend({ + room: baseRoomSchema, + }), + lang: z.nativeEnum(Lang), +}) + +export const room = safeProtectedServiceProcedure + .input(selectRateRoomAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ + session: ctx.session, + }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, + }, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + }) + }) + .query(async function ({ ctx, input }) { + const [availability] = await getRoomsAvailability( + { + booking: { + ...input.booking, + rooms: [input.booking.room], + }, + lang: input.lang, + }, + ctx.token, + ctx.serviceToken, + ctx.userPoints + ) + + if (!availability || "error" in availability) { + return null + } + + const roomConfigurations = mergeRoomTypes(availability.roomConfigurations) + + return { + ...availability, + roomConfigurations, + } + }) diff --git a/packages/trpc/lib/routers/hotels/availability/selectRate/rooms/index.ts b/packages/trpc/lib/routers/hotels/availability/selectRate/rooms/index.ts new file mode 100644 index 000000000..a02d3485e --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/selectRate/rooms/index.ts @@ -0,0 +1,58 @@ +import "server-only" + +import { SEARCH_TYPE_REDEMPTION } from "../../../../../constants/booking" +import { unauthorizedError } from "../../../../../errors" +import { safeProtectedServiceProcedure } from "../../../../../procedures" +import { getVerifiedUser } from "../../../../user/utils/getVerifiedUser" +import { getRoomsAvailability } from "../../../services/getRoomsAvailability" +import { mergeRoomTypes } from "../../../utils" +import { selectRateRoomsAvailabilityInputSchema } from "./schema" + +export const rooms = safeProtectedServiceProcedure + .input(selectRateRoomsAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ + session: ctx.session, + }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, + }, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + }) + }) + .query(async function ({ ctx, input }) { + input.booking.rooms = input.booking.rooms.map((room) => ({ + ...room, + bookingCode: room.bookingCode || input.booking.bookingCode, + })) + + const availability = await getRoomsAvailability( + input, + ctx.token, + ctx.serviceToken, + ctx.userPoints + ) + + for (const room of availability) { + if (!room || "error" in room) { + continue + } + + room.roomConfigurations = mergeRoomTypes(room.roomConfigurations) + } + + return availability + }) diff --git a/packages/trpc/lib/routers/hotels/availability/selectRate/rooms/schema.ts b/packages/trpc/lib/routers/hotels/availability/selectRate/rooms/schema.ts new file mode 100644 index 000000000..d927a2632 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/availability/selectRate/rooms/schema.ts @@ -0,0 +1,64 @@ +import dayjs from "dayjs" +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" + +import { baseBookingSchema, baseRoomSchema } from "../../../input" + +export type RoomsAvailabilityInputRoom = + RoomsAvailabilityInputSchema["booking"]["rooms"][number] +export type RoomsAvailabilityOutputSchema = z.output< + typeof selectRateRoomsAvailabilityInputSchema +> +export type RoomsAvailabilityInputSchema = z.input< + typeof selectRateRoomsAvailabilityInputSchema +> +export const selectRateRoomsAvailabilityInputSchema = z + .object({ + booking: baseBookingSchema.extend({ + rooms: z.array(baseRoomSchema), + }), + lang: z.nativeEnum(Lang), + }) + .refine( + (data) => { + const fromDate = dayjs(data.booking.fromDate) + + return fromDate.isValid() + }, + { + message: "FROMDATE_INVALID", + } + ) + .refine( + (data) => { + const toDate = dayjs(data.booking.toDate) + + return toDate.isValid() + }, + { + message: "TODATE_INVALID", + } + ) + .refine( + (data) => { + const fromDate = dayjs(data.booking.fromDate).startOf("day") + const toDate = dayjs(data.booking.toDate).startOf("day") + + return fromDate.isBefore(toDate) + }, + { + message: "TODATE_MUST_BE_AFTER_FROMDATE", + } + ) + .refine( + (data) => { + const fromDate = dayjs(data.booking.fromDate) + const today = dayjs().startOf("day") + + return fromDate.isSameOrAfter(today) + }, + { + message: "FROMDATE_CANNOT_BE_IN_THE_PAST", + } + ) diff --git a/packages/trpc/lib/routers/hotels/input.ts b/packages/trpc/lib/routers/hotels/input.ts index 976ef627b..2eb11f781 100644 --- a/packages/trpc/lib/routers/hotels/input.ts +++ b/packages/trpc/lib/routers/hotels/input.ts @@ -1,4 +1,3 @@ -import dayjs from "dayjs" import { z } from "zod" import { Lang } from "@scandic-hotels/common/constants/language" @@ -8,106 +7,6 @@ import { ChildBedMapEnum } from "../../enums/childBedMapEnum" import { RoomPackageCodeEnum } from "../../enums/roomFilter" import { Country } from "../../types/country" -export const hotelsAvailabilityInputSchema = z - .object({ - cityId: z.string(), - roomStayStartDate: z.string().refine( - (val) => { - const fromDate = dayjs(val) - - return fromDate.isValid() - }, - { - message: "FROMDATE_INVALID", - } - ), - roomStayEndDate: z.string().refine( - (val) => { - const fromDate = dayjs(val) - return fromDate.isValid() - }, - { - message: "TODATE_INVALID", - } - ), - adults: z.number(), - children: z.string().optional(), - bookingCode: z.string().optional().default(""), - redemption: z.boolean().optional().default(false), - }) - .refine( - (data) => { - const fromDate = dayjs(data.roomStayStartDate).startOf("day") - const toDate = dayjs(data.roomStayEndDate).startOf("day") - - return fromDate.isBefore(toDate) - }, - { - message: "FROMDATE_BEFORE_TODATE", - } - ) - .refine( - (data) => { - const fromDate = dayjs(data.roomStayStartDate) - const today = dayjs().startOf("day") - - return fromDate.isSameOrAfter(today) - }, - { - message: "FROMDATE_CANNOT_BE_IN_THE_PAST", - } - ) - -export const getHotelsByHotelIdsAvailabilityInputSchema = z - .object({ - hotelIds: z.array(z.number()), - roomStayStartDate: z.string().refine( - (val) => { - const fromDate = dayjs(val) - return fromDate.isValid() - }, - { - message: "FROMDATE_INVALID", - } - ), - roomStayEndDate: z.string().refine( - (val) => { - const toDate = dayjs(val) - - return toDate.isValid() - }, - { - message: "TODATE_INVALID", - } - ), - adults: z.number(), - children: z.string().optional(), - bookingCode: z.string().optional().default(""), - redemption: z.boolean().optional().default(false), - }) - .refine( - (data) => { - const fromDate = dayjs(data.roomStayStartDate).startOf("day") - const toDate = dayjs(data.roomStayEndDate).startOf("day") - - return fromDate.isBefore(toDate) - }, - { - message: "FROMDATE_BEFORE_TODATE", - } - ) - .refine( - (data) => { - const fromDate = dayjs(data.roomStayStartDate) - const today = dayjs().startOf("day") - - return fromDate.isSameOrAfter(today) - }, - { - message: "FROMDATE_CANNOT_BE_IN_THE_PAST", - } - ) - const childrenInRoomSchema = z .array( z.object({ @@ -117,7 +16,7 @@ const childrenInRoomSchema = z ) .optional() -const baseRoomSchema = z.object({ +export const baseRoomSchema = z.object({ adults: z.number().int().min(1), bookingCode: z.string().optional(), childrenInRoom: childrenInRoomSchema, @@ -126,13 +25,13 @@ const baseRoomSchema = z.object({ .optional(), }) -const selectedRoomSchema = z.object({ +export const selectedRoomSchema = z.object({ counterRateCode: z.string().optional(), rateCode: z.string(), roomTypeCode: z.string(), }) -const baseBookingSchema = z.object({ +export const baseBookingSchema = z.object({ bookingCode: z.string().optional(), fromDate: z.string(), hotelId: z.string(), @@ -140,77 +39,6 @@ const baseBookingSchema = z.object({ toDate: z.string(), }) -export const selectRateRoomsAvailabilityInputSchema = z - .object({ - booking: baseBookingSchema.extend({ - rooms: z.array(baseRoomSchema), - }), - lang: z.nativeEnum(Lang), - }) - .refine( - (data) => { - const fromDate = dayjs(data.booking.fromDate) - - return fromDate.isValid() - }, - { - message: "FROMDATE_INVALID", - } - ) - .refine( - (data) => { - const toDate = dayjs(data.booking.toDate) - - return toDate.isValid() - }, - { - message: "TODATE_INVALID", - } - ) - .refine( - (data) => { - const fromDate = dayjs(data.booking.fromDate).startOf("day") - const toDate = dayjs(data.booking.toDate).startOf("day") - - return fromDate.isBefore(toDate) - }, - { - message: "TODATE_MUST_BE_AFTER_FROMDATE", - } - ) - .refine( - (data) => { - const fromDate = dayjs(data.booking.fromDate) - const today = dayjs().startOf("day") - - return fromDate.isSameOrAfter(today) - }, - { - message: "FROMDATE_CANNOT_BE_IN_THE_PAST", - } - ) - -export const selectRateRoomAvailabilityInputSchema = z.object({ - booking: baseBookingSchema.extend({ - room: baseRoomSchema, - }), - lang: z.nativeEnum(Lang), -}) - -export const enterDetailsRoomsAvailabilityInputSchema = z.object({ - booking: baseBookingSchema.extend({ - rooms: z.array(baseRoomSchema.merge(selectedRoomSchema)), - }), - lang: z.nativeEnum(Lang), -}) - -export const myStayRoomAvailabilityInputSchema = z.object({ - booking: baseBookingSchema.extend({ - room: baseRoomSchema.merge(selectedRoomSchema), - }), - lang: z.nativeEnum(Lang), -}) - export const roomFeaturesInputSchema = z.object({ adults: z.number(), childrenInRoom: childrenInRoomSchema, diff --git a/packages/trpc/lib/routers/hotels/query.ts b/packages/trpc/lib/routers/hotels/query.ts index 3021169d0..e33df0b8a 100644 --- a/packages/trpc/lib/routers/hotels/query.ts +++ b/packages/trpc/lib/routers/hotels/query.ts @@ -1,6 +1,4 @@ import { Lang } from "@scandic-hotels/common/constants/language" -import { RateEnum } from "@scandic-hotels/common/constants/rate" -import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType" import { getCacheClient } from "@scandic-hotels/common/dataCache" import { dt } from "@scandic-hotels/common/dt" import { createLogger } from "@scandic-hotels/common/logger/createLogger" @@ -9,10 +7,8 @@ import { createCounter } from "@scandic-hotels/common/telemetry" import { env } from "../../../env/server" import { router } from "../.." import * as api from "../../api" -import { SEARCH_TYPE_REDEMPTION } from "../../constants/booking" import { BreakfastPackageEnum } from "../../enums/breakfast" -import { AvailabilityEnum } from "../../enums/selectHotel" -import { badRequestError, unauthorizedError } from "../../errors" +import { badRequestError } from "../../errors" import { contentStackBaseWithServiceProcedure, publicProcedure, @@ -25,23 +21,17 @@ import { ancillaryPackageInputSchema, breakfastPackageInputSchema, cityCoordinatesInputSchema, - enterDetailsRoomsAvailabilityInputSchema, getAdditionalDataInputSchema, getDestinationsMapDataInput, getHotelsByCityIdentifierInput, getHotelsByCountryInput, getHotelsByCSFilterInput, - getHotelsByHotelIdsAvailabilityInputSchema, getLocationsInput, getLocationsUrlsInput, getMeetingRoomsInputSchema, hotelInputSchema, - hotelsAvailabilityInputSchema, - myStayRoomAvailabilityInputSchema, nearbyHotelIdsInput, roomPackagesInputSchema, - selectRateRoomAvailabilityInputSchema, - selectRateRoomsAvailabilityInputSchema, } from "../../routers/hotels/input" import { ancillaryPackagesSchema, @@ -49,498 +39,26 @@ import { getNearbyHotelIdsSchema, } from "../../routers/hotels/output" import { toApiLang } from "../../utils" -import { getVerifiedUser } from "../user/utils" import { additionalDataSchema } from "./schemas/hotel/include/additionalData" import { meetingRoomsSchema } from "./schemas/meetingRoom" -import { - getBedTypes, - getCitiesByCountry, - getCountries, - getHotel, - getHotelIdsByCityId, - getHotelIdsByCityIdentifier, - getHotelIdsByCountry, - getHotelsAvailabilityByCity, - getHotelsAvailabilityByHotelIds, - getHotelsByHotelIds, - getLocations, - getPackages, - getRoomsAvailability, - getSelectedRoomAvailability, - mergeRoomTypes, - selectRateRedirectURL, -} from "./utils" +import { getCitiesByCountry } from "./services/getCitiesByCountry" +import { getHotelIdsByCityIdentifier } from "./services/getCityByCityIdentifier" +import { getCountries } from "./services/getCountries" +import { getHotel } from "./services/getHotel" +import { getHotelIdsByCityId } from "./services/getHotelIdsByCityId" +import { getHotelIdsByCountry } from "./services/getHotelIdsByCountry" +import { getHotelsByHotelIds } from "./services/getHotelsByHotelIds" +import { getPackages } from "./services/getPackages" +import { availability } from "./availability" +import { getLocations } from "./utils" import type { HotelListingHotelData } from "../../types/hotel" import type { CityLocation } from "../../types/locations" -import type { Room } from "../../types/room" const hotelQueryLogger = createLogger("hotelQueryRouter") export const hotelQueryRouter = router({ - availability: router({ - hotelsByCity: safeProtectedServiceProcedure - .input(hotelsAvailabilityInputSchema) - .use(async ({ ctx, input, next }) => { - if (input.redemption) { - if (ctx.session?.token.access_token) { - const verifiedUser = await getVerifiedUser({ session: ctx.session }) - if (!verifiedUser?.error) { - return next({ - ctx: { - token: ctx.session.token.access_token, - userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, - }, - input, - }) - } - } - throw unauthorizedError() - } - return next({ - ctx: { - token: ctx.serviceToken, - }, - input, - }) - }) - .query(async ({ ctx, input }) => { - const { lang } = ctx - const apiLang = toApiLang(lang) - const { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - } = input - - // In case of redemption do not cache result - if (redemption) { - return getHotelsAvailabilityByCity( - input, - apiLang, - ctx.token, - ctx.userPoints - ) - } - - const cacheClient = await getCacheClient() - return await cacheClient.cacheOrGet( - `${cityId}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, - async () => { - return getHotelsAvailabilityByCity(input, apiLang, ctx.token) - }, - env.CACHE_TIME_CITY_SEARCH - ) - }), - hotelsByHotelIds: safeProtectedServiceProcedure - .input(getHotelsByHotelIdsAvailabilityInputSchema) - .use(async ({ ctx, input, next }) => { - if (input.redemption) { - if (ctx.session?.token.access_token) { - const verifiedUser = await getVerifiedUser({ session: ctx.session }) - if (!verifiedUser?.error) { - return next({ - ctx: { - token: ctx.session.token.access_token, - userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, - }, - input, - }) - } - } - throw unauthorizedError() - } - return next({ - ctx: { - token: ctx.serviceToken, - }, - input, - }) - }) - .query(async ({ input, ctx }) => { - const { lang } = ctx - const apiLang = toApiLang(lang) - return getHotelsAvailabilityByHotelIds( - input, - apiLang, - ctx.token, - ctx.userPoints - ) - }), - - enterDetails: safeProtectedServiceProcedure - .input(enterDetailsRoomsAvailabilityInputSchema) - .use(async ({ ctx, input, next }) => { - if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { - if (ctx.session?.token.access_token) { - const verifiedUser = await getVerifiedUser({ session: ctx.session }) - if (!verifiedUser?.error) { - return next({ - ctx: { - token: ctx.session.token.access_token, - userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, - }, - }) - } - } - throw unauthorizedError() - } - return next({ - ctx: { - token: ctx.serviceToken, - }, - }) - }) - .query(async function ({ ctx, input }) { - const availability = await getRoomsAvailability( - input, - ctx.token, - ctx.serviceToken, - ctx.userPoints - ) - const hotelData = await getHotel( - { - hotelId: input.booking.hotelId, - isCardOnlyPayment: false, - language: input.lang || ctx.lang, - }, - ctx.serviceToken - ) - - const selectedRooms = [] - for (const [idx, room] of availability.entries()) { - if (!room || "error" in room) { - hotelQueryLogger.error( - `Availability failed: ${room.error}`, - room.details - ) - selectedRooms.push(null) - continue - } - const bookingRoom = input.booking.rooms[idx] - const selected = getSelectedRoomAvailability( - bookingRoom.rateCode, - room.rateDefinitions, - room.roomConfigurations, - bookingRoom.roomTypeCode, - ctx.userPoints - ) - if (!selected) { - hotelQueryLogger.error("Unable to find selected room") - selectedRooms.push(null) - continue - } - - const { - rateDefinition, - rateDefinitions, - product, - rooms, - selectedRoom, - } = selected - - const bedTypes = getBedTypes( - rooms, - selectedRoom.roomType, - hotelData?.roomCategories - ) - - const counterRateCode = input.booking.rooms[idx].counterRateCode - const rateCode = input.booking.rooms[idx].rateCode - let memberRateDefinition = undefined - if ("member" in product && product.member && counterRateCode) { - memberRateDefinition = rateDefinitions.find( - (rate) => - (rate.rateCode.toLowerCase() === - counterRateCode.toLowerCase() || - rate.rateCode.toLowerCase() === rateCode.toLowerCase()) && - rate.isMemberRate - ) - } - - const selectedPackages = input.booking.rooms[idx].packages - selectedRooms.push({ - bedTypes, - breakfastIncluded: rateDefinition.breakfastIncluded, - cancellationText: rateDefinition.cancellationText, - cancellationRule: rateDefinition.cancellationRule, - isAvailable: selectedRoom.status === AvailabilityEnum.Available, - isFlexRate: product.rate === RateEnum.flex, - memberMustBeGuaranteed: memberRateDefinition?.mustBeGuaranteed, - mustBeGuaranteed: rateDefinition.mustBeGuaranteed, - packages: room.packages.filter((pkg) => - selectedPackages?.includes(pkg.code) - ), - rate: product.rate, - rateDefinitionTitle: rateDefinition.title, - rateDetails: rateDefinition.generalTerms, - memberRateDetails: memberRateDefinition?.generalTerms, - // Send rate Title when it is a booking code rate - rateTitle: - rateDefinition.rateType !== RateTypeEnum.Regular - ? rateDefinition.title - : undefined, - rateType: rateDefinition.rateType, - roomRate: product, - roomType: selectedRoom.roomType, - roomTypeCode: selectedRoom.roomTypeCode, - }) - } - - const totalBedsAvailableForRoomTypeCode: Record = {} - for (const selectedRoom of selectedRooms) { - if (selectedRoom) { - if (!totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode]) { - totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] = - selectedRoom.bedTypes.reduce( - (total, bedType) => total + bedType.roomsLeft, - 0 - ) - } - } - } - - for (const [idx, selectedRoom] of selectedRooms.entries()) { - if (selectedRoom) { - const totalBedsLeft = - totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] - if (totalBedsLeft <= 0) { - selectedRooms[idx] = null - continue - } - totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] = - totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] - 1 - } - } - - if (selectedRooms.some((sr) => !sr)) { - console.log("DEBUG: REDIRECTING TO SELECT RATE", selectedRooms) - return selectRateRedirectURL(input, selectedRooms.map(Boolean)) - } - - // Make TS show appropriate type - return selectedRooms.filter((sr): sr is Room => !!sr) - }), - myStay: safeProtectedServiceProcedure - .input(myStayRoomAvailabilityInputSchema) - .use(async ({ ctx, input, next }) => { - if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { - if (ctx.session?.token.access_token) { - const verifiedUser = await getVerifiedUser({ session: ctx.session }) - if (!verifiedUser?.error) { - return next({ - ctx: { - token: ctx.session.token.access_token, - userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, - }, - }) - } - } - throw unauthorizedError() - } - return next({ - ctx: { - token: ctx.serviceToken, - }, - }) - }) - .query(async function ({ ctx, input }) { - const [availability] = await getRoomsAvailability( - { - booking: { - ...input.booking, - rooms: [input.booking.room], - }, - lang: input.lang, - }, - ctx.token, - ctx.serviceToken, - ctx.userPoints - ) - - if (!availability || "error" in availability) { - return null - } - - const bookingRoom = input.booking.room - const selected = getSelectedRoomAvailability( - bookingRoom.rateCode, - availability.rateDefinitions, - availability.roomConfigurations, - bookingRoom.roomTypeCode, - ctx.userPoints - ) - - if (!selected) { - hotelQueryLogger.error("Unable to find selected room") - return null - } - - return { - product: selected.product, - selectedRoom: selected.selectedRoom, - } - }), - selectRate: router({ - room: safeProtectedServiceProcedure - .input(selectRateRoomAvailabilityInputSchema) - .use(async ({ ctx, input, next }) => { - if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { - if (ctx.session?.token.access_token) { - const verifiedUser = await getVerifiedUser({ - session: ctx.session, - }) - if (!verifiedUser?.error) { - return next({ - ctx: { - token: ctx.session.token.access_token, - userPoints: - verifiedUser?.data.membership?.currentPoints ?? 0, - }, - }) - } - } - throw unauthorizedError() - } - return next({ - ctx: { - token: ctx.serviceToken, - }, - }) - }) - .query(async function ({ ctx, input }) { - const [availability] = await getRoomsAvailability( - { - booking: { - ...input.booking, - rooms: [input.booking.room], - }, - lang: input.lang, - }, - ctx.token, - ctx.serviceToken, - ctx.userPoints - ) - - if (!availability || "error" in availability) { - return null - } - - const roomConfigurations = mergeRoomTypes( - availability.roomConfigurations - ) - - return { - ...availability, - roomConfigurations, - } - }), - - rooms: safeProtectedServiceProcedure - .input(selectRateRoomsAvailabilityInputSchema) - .use(async ({ ctx, input, next }) => { - if (input.booking.searchType === SEARCH_TYPE_REDEMPTION) { - if (ctx.session?.token.access_token) { - const verifiedUser = await getVerifiedUser({ - session: ctx.session, - }) - if (!verifiedUser?.error) { - return next({ - ctx: { - token: ctx.session.token.access_token, - userPoints: - verifiedUser?.data.membership?.currentPoints ?? 0, - }, - }) - } - } - throw unauthorizedError() - } - return next({ - ctx: { - token: ctx.serviceToken, - }, - }) - }) - .query(async function ({ ctx, input }) { - input.booking.rooms = input.booking.rooms.map((room) => ({ - ...room, - bookingCode: room.bookingCode || input.booking.bookingCode, - })) - - const availability = await getRoomsAvailability( - input, - ctx.token, - ctx.serviceToken, - ctx.userPoints - ) - - for (const room of availability) { - if (!room || "error" in room) { - continue - } - - room.roomConfigurations = mergeRoomTypes(room.roomConfigurations) - } - - return availability - }), - }), - - hotelsByCityWithBookingCode: serviceProcedure - .input(hotelsAvailabilityInputSchema) - .query(async ({ input, ctx }) => { - const { lang } = ctx - const apiLang = toApiLang(lang) - - const bookingCodeAvailabilityResponse = - await getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken) - - // Get regular availability of hotels which don't have availability with booking code. - const unavailableHotelIds = bookingCodeAvailabilityResponse.availability - .filter((hotel) => { - return hotel.status === "NotAvailable" - }) - .flatMap((hotel) => { - return hotel.hotelId - }) - - // All hotels have availability with booking code no need to fetch regular prices. - // return response as is without any filtering as below. - if (!unavailableHotelIds || !unavailableHotelIds.length) { - return bookingCodeAvailabilityResponse - } - - const unavailableHotelsInput = { - ...input, - bookingCode: "", - hotelIds: unavailableHotelIds, - } - const unavailableHotels = await getHotelsAvailabilityByHotelIds( - unavailableHotelsInput, - apiLang, - ctx.serviceToken - ) - - // No regular rates available due to network or API failure (no need to filter & merge). - if (!unavailableHotels) { - return bookingCodeAvailabilityResponse - } - - // Filtering the response hotels to merge bookingCode rates and regular rates in single response. - return { - availability: bookingCodeAvailabilityResponse.availability - .filter((hotel) => { - return hotel.status === "Available" - }) - .concat(unavailableHotels.availability), - } - }), - }), + availability, get: serviceProcedure .input(hotelInputSchema) .query(async ({ ctx, input }) => { diff --git a/packages/trpc/lib/routers/hotels/services/getCitiesByCountry.ts b/packages/trpc/lib/routers/hotels/services/getCitiesByCountry.ts new file mode 100644 index 000000000..7f4addfc6 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getCitiesByCountry.ts @@ -0,0 +1,77 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createLogger } from "@scandic-hotels/common/logger/createLogger" + +import * as api from "../../../api" +import { toApiLang } from "../../../utils" +import { citiesByCountrySchema } from "../output" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +import type { CitiesGroupedByCountry } from "../../../types/locations" + +const logger = createLogger("trpc:hotels:getCitiesByCountry") +export const locationsAffix = "locations" +export async function getCitiesByCountry({ + countries, + lang, + affix = locationsAffix, + serviceToken, +}: { + countries: string[] + lang: Lang + affix?: string + serviceToken: string +}): Promise { + const cacheClient = await getCacheClient() + const allCitiesByCountries = await Promise.all( + countries.map(async (country) => { + return cacheClient.cacheOrGet( + `${lang}:${affix}:cities-by-country:${country}`, + async () => { + const params = new URLSearchParams({ + language: toApiLang(lang), + }) + const countryResponse = await api.get( + api.endpoints.v1.Hotel.Cities.country(country), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + params + ) + + if (!countryResponse.ok) { + throw new Error(`Unable to fetch cities by country ${country}`) + } + + const countryJson = await countryResponse.json() + const citiesByCountry = citiesByCountrySchema.safeParse(countryJson) + if (!citiesByCountry.success) { + logger.error( + `Unable to parse cities by country ${country}`, + citiesByCountry.error + ) + + throw new Error(`Unable to parse cities by country ${country}`) + } + return { ...citiesByCountry.data, country } + }, + "1d" + ) + }) + ) + + const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({ + ...country, + data: country.data.filter((city) => city.isPublished), + })) + + const groupedCitiesByCountry: CitiesGroupedByCountry = + filteredCitiesByCountries.reduce((acc, { country, data }) => { + acc[country] = data + return acc + }, {} as CitiesGroupedByCountry) + + return groupedCitiesByCountry +} diff --git a/packages/trpc/lib/routers/hotels/services/getCity.ts b/packages/trpc/lib/routers/hotels/services/getCity.ts new file mode 100644 index 000000000..496509897 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getCity.ts @@ -0,0 +1,46 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createLogger } from "@scandic-hotels/common/logger/createLogger" + +import * as api from "../../../api" +import { type Cities, citiesSchema } from "../output" + +import type { Endpoint } from "../../../api/endpoints" + +const logger = createLogger("trpc:hotels:getCity") +export async function getCity({ + cityUrl, + serviceToken, +}: { + cityUrl: string + serviceToken: string +}): Promise { + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + cityUrl, + async () => { + const url = new URL(cityUrl) + const cityResponse = await api.get( + url.pathname as Endpoint, + { headers: { Authorization: `Bearer ${serviceToken}` } }, + url.searchParams + ) + + if (!cityResponse.ok) { + return null + } + + const cityJson = await cityResponse.json() + const city = citiesSchema.safeParse(cityJson) + if (!city.success) { + logger.error(`Validation of city failed`, { + error: city.error, + cityUrl, + }) + return null + } + + return city.data + }, + "1d" + ) +} diff --git a/packages/trpc/lib/routers/hotels/services/getCityByCityIdentifier.ts b/packages/trpc/lib/routers/hotels/services/getCityByCityIdentifier.ts new file mode 100644 index 000000000..a13623cb4 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getCityByCityIdentifier.ts @@ -0,0 +1,50 @@ +import { Lang } from "@scandic-hotels/common/constants/language" + +import { getLocations } from "../utils" +import { getHotelIdsByCityId } from "./getHotelIdsByCityId" + +export async function getCityByCityIdentifier({ + cityIdentifier, + lang, + serviceToken, +}: { + cityIdentifier: string + lang: Lang + serviceToken: string +}) { + const locations = await getLocations({ + lang, + citiesByCountry: null, + serviceToken, + }) + if (!locations || "error" in locations) { + return null + } + + const city = locations + .filter((loc) => loc.type === "cities") + .find((loc) => loc.cityIdentifier === cityIdentifier) + + return city ?? null +} + +export async function getHotelIdsByCityIdentifier( + cityIdentifier: string, + serviceToken: string +) { + const city = await getCityByCityIdentifier({ + cityIdentifier, + lang: Lang.en, + serviceToken, + }) + + if (!city) { + return [] + } + + const hotelIds = await getHotelIdsByCityId({ + cityId: city.id, + serviceToken, + }) + return hotelIds +} diff --git a/packages/trpc/lib/routers/hotels/services/getCountries.ts b/packages/trpc/lib/routers/hotels/services/getCountries.ts new file mode 100644 index 000000000..f60a2b3d1 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getCountries.ts @@ -0,0 +1,57 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createLogger } from "@scandic-hotels/common/logger/createLogger" + +import * as api from "../../../api" +import { toApiLang } from "../../../utils" +import { countriesSchema } from "../output" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +const logger = createLogger("getCountries") +const locationsAffix = "locations" +export async function getCountries({ + lang, + serviceToken, + warmup = false, +}: { + lang: Lang + serviceToken: string + warmup?: boolean +}) { + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + `${lang}:${locationsAffix}:countries`, + async () => { + const params = new URLSearchParams({ + language: toApiLang(lang), + }) + + const countryResponse = await api.get( + api.endpoints.v1.Hotel.countries, + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + params + ) + + if (!countryResponse.ok) { + throw new Error("Unable to fetch countries") + } + + const countriesJson = await countryResponse.json() + const countries = countriesSchema.safeParse(countriesJson) + if (!countries.success) { + logger.error(`Validation for countries failed`, countries.error) + return null + } + + return countries.data + }, + "1d", + { + cacheStrategy: warmup ? "fetch-then-cache" : "cache-first", + } + ) +} diff --git a/packages/trpc/lib/routers/hotels/services/getHotel.ts b/packages/trpc/lib/routers/hotels/services/getHotel.ts new file mode 100644 index 000000000..a1ce0068c --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getHotel.ts @@ -0,0 +1,96 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { env } from "../../../../env/server" +import * as api from "../../../api" +import { cache } from "../../../DUPLICATED/cache" +import { HotelTypeEnum } from "../../../enums/hotelType" +import { badRequestError } from "../../../errors" +import { toApiLang } from "../../../utils" +import { hotelSchema } from "../output" + +import type { HotelInput } from "../../../types/hotel" + +export const getHotel = cache( + async (input: HotelInput, serviceToken: string) => { + const { language, isCardOnlyPayment } = input + const hotelId = input.hotelId.trim() + + const getHotelCounter = createCounter("hotel", "getHotel") + const metricsGetHotel = getHotelCounter.init({ + hotelId, + language, + isCardOnlyPayment, + }) + + metricsGetHotel.start() + + const cacheClient = await getCacheClient() + + const result = await cacheClient.cacheOrGet( + `${language}:hotel:${hotelId}:${!!isCardOnlyPayment}`, + 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 + * (include=City&include=NearbyHotels&include=Restaurants etc.) + **/ + const params = new URLSearchParams([ + ["include", "AdditionalData"], + ["include", "City"], + ["include", "NearbyHotels"], + ["include", "Restaurants"], + ["include", "RoomCategories"], + ["language", toApiLang(language)], + ]) + + const apiResponse = await api.get( + api.endpoints.v1.Hotel.Hotels.hotel(hotelId), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + await metricsGetHotel.httpError(apiResponse) + return null + } + + const apiJson = await apiResponse.json() + const validateHotelData = hotelSchema.safeParse(apiJson) + + if (!validateHotelData.success) { + metricsGetHotel.validationError(validateHotelData.error) + throw badRequestError() + } + + const hotelData = validateHotelData.data + + if (isCardOnlyPayment) { + hotelData.hotel.merchantInformationData.alternatePaymentOptions = [] + } + + const gallery = hotelData.additionalData?.gallery + if (gallery) { + const smallerImages = gallery.smallerImages + const hotelGalleryImages = + hotelData.hotel.hotelType === HotelTypeEnum.Signature + ? smallerImages.slice(0, 10) + : smallerImages.slice(0, 6) + hotelData.hotel.galleryImages = hotelGalleryImages + } + + return hotelData + }, + env.CACHE_TIME_HOTELS + ) + + metricsGetHotel.success() + + return result + } +) diff --git a/packages/trpc/lib/routers/hotels/services/getHotelIdsByCityId.ts b/packages/trpc/lib/routers/hotels/services/getHotelIdsByCityId.ts new file mode 100644 index 000000000..6e3947e39 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getHotelIdsByCityId.ts @@ -0,0 +1,63 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { env } from "../../../../env/server" +import * as api from "../../../api" +import { getHotelIdsSchema } from "../output" + +export async function getHotelIdsByCityId({ + cityId, + serviceToken, +}: { + cityId: string + serviceToken: string +}) { + const getHotelIdsByCityIdCounter = createCounter( + "hotel", + "getHotelIdsByCityId" + ) + const metricsGetHotelIdsByCityId = getHotelIdsByCityIdCounter.init({ + cityId, + }) + + metricsGetHotelIdsByCityId.start() + + const cacheClient = await getCacheClient() + const result = await cacheClient.cacheOrGet( + `${cityId}:hotelsByCityId`, + async () => { + const searchParams = new URLSearchParams({ + city: cityId, + }) + + const apiResponse = await api.get( + api.endpoints.v1.Hotel.hotels, + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + searchParams + ) + + if (!apiResponse.ok) { + await metricsGetHotelIdsByCityId.httpError(apiResponse) + throw new Error("Unable to fetch hotelIds by cityId") + } + + const apiJson = await apiResponse.json() + const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) + if (!validatedHotelIds.success) { + metricsGetHotelIdsByCityId.validationError(validatedHotelIds.error) + throw new Error("Unable to parse data for hotelIds by cityId") + } + + return validatedHotelIds.data + }, + env.CACHE_TIME_HOTELS + ) + + metricsGetHotelIdsByCityId.success() + + return result +} diff --git a/packages/trpc/lib/routers/hotels/services/getHotelIdsByCountry.ts b/packages/trpc/lib/routers/hotels/services/getHotelIdsByCountry.ts new file mode 100644 index 000000000..dbf0c3861 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getHotelIdsByCountry.ts @@ -0,0 +1,65 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { env } from "../../../../env/server" +import * as api from "../../../api" +import { getHotelIdsSchema } from "../output" + +export async function getHotelIdsByCountry({ + country, + serviceToken, +}: { + country: string + serviceToken: string +}) { + const getHotelIdsByCountryCounter = createCounter( + "hotel", + "getHotelIdsByCountry" + ) + + const metricsGetHotelIdsByCountry = getHotelIdsByCountryCounter.init({ + country, + }) + + metricsGetHotelIdsByCountry.start() + + const cacheClient = await getCacheClient() + + const result = await cacheClient.cacheOrGet( + `${country}:hotelsByCountry`, + async () => { + const hotelIdsParams = new URLSearchParams({ + country, + }) + + const apiResponse = await api.get( + api.endpoints.v1.Hotel.hotels, + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + hotelIdsParams + ) + + if (!apiResponse.ok) { + await metricsGetHotelIdsByCountry.httpError(apiResponse) + throw new Error("Unable to fetch hotelIds by country") + } + + const apiJson = await apiResponse.json() + const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) + if (!validatedHotelIds.success) { + metricsGetHotelIdsByCountry.validationError(validatedHotelIds.error) + throw new Error("Unable to parse hotelIds by country") + } + + return validatedHotelIds.data + }, + env.CACHE_TIME_HOTELS + ) + + metricsGetHotelIdsByCountry.success() + + return result +} diff --git a/packages/trpc/lib/routers/hotels/services/getHotelsAvailabilityByCity.ts b/packages/trpc/lib/routers/hotels/services/getHotelsAvailabilityByCity.ts new file mode 100644 index 000000000..5e7e98fd5 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getHotelsAvailabilityByCity.ts @@ -0,0 +1,93 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { badRequestError } from "../../../errors" +import { hotelsAvailabilitySchema } from "../output" + +import type { HotelsAvailabilityInputSchema } from "../availability/hotelsByCity" + +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 = { + 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 +} diff --git a/packages/trpc/lib/routers/hotels/services/getHotelsAvailabilityByHotelIds.ts b/packages/trpc/lib/routers/hotels/services/getHotelsAvailabilityByHotelIds.ts new file mode 100644 index 000000000..4251370a2 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getHotelsAvailabilityByHotelIds.ts @@ -0,0 +1,115 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import { env } from "../../../../env/server" +import * as api from "../../../api" +import { badRequestError } from "../../../errors" +import { hotelsAvailabilitySchema } from "../output" + +import type { HotelsByHotelIdsAvailabilityInputSchema } from "../availability/hotelsByHotelIds" + +export async function getHotelsAvailabilityByHotelIds( + input: HotelsByHotelIdsAvailabilityInputSchema, + apiLang: string, + token: string, + userPoints: number = 0 +) { + const { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, + } = input + + const params = new URLSearchParams([ + ["roomStayStartDate", roomStayStartDate], + ["roomStayEndDate", roomStayEndDate], + ["adults", adults.toString()], + ["children", children ?? ""], + ["bookingCode", bookingCode], + ["isRedemption", redemption.toString()], + ["language", apiLang], + ]) + + const getHotelsAvailabilityByHotelIdsCounter = createCounter( + "hotel", + "getHotelsAvailabilityByHotelIds" + ) + const metricsGetHotelsAvailabilityByHotelIds = + getHotelsAvailabilityByHotelIdsCounter.init({ + apiLang, + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, + }) + + 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 ${token}`, + }, + }, + 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() + } + + if (redemption) { + validateAvailabilityData.data.data.forEach((data) => { + data.attributes.productType?.redemptions?.forEach((r) => { + r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay + }) + }) + } + + return { + availability: validateAvailabilityData.data.data.flatMap( + (hotels) => hotels.attributes + ), + } + }, + redemption ? "no cache" : env.CACHE_TIME_CITY_SEARCH + ) + + metricsGetHotelsAvailabilityByHotelIds.success() + + return result +} diff --git a/packages/trpc/lib/routers/hotels/services/getHotelsByHotelIds.ts b/packages/trpc/lib/routers/hotels/services/getHotelsByHotelIds.ts new file mode 100644 index 000000000..c684dd668 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getHotelsByHotelIds.ts @@ -0,0 +1,112 @@ +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { chunk } from "@scandic-hotels/common/utils/chunk" + +import { getHotelPageUrls } from "../../contentstack/hotelPage/utils" +import { getHotel } from "./getHotel" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +import type { HotelListingHotelData } from "../../../types/hotel" + +export async function getHotelsByHotelIds({ + hotelIds, + lang, + serviceToken, + contentType = "hotel", +}: { + hotelIds: string[] + lang: Lang + serviceToken: string + contentType?: "hotel" | "restaurant" | "meeting" +}) { + const cacheClient = await getCacheClient() + const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${contentType}:${[...hotelIds].sort().join(",")}` + + return await cacheClient.cacheOrGet( + cacheKey, + async () => { + const hotelPages = await getHotelPageUrls(lang) + const chunkedHotelIds = chunk(hotelIds, 10) + + const hotels: HotelListingHotelData[] = [] + for (const hotelIdChunk of chunkedHotelIds) { + const chunkedHotels = await Promise.all( + hotelIdChunk.map(async (hotelId) => { + const hotelResponse = await getHotel( + { hotelId, language: lang, isCardOnlyPayment: false }, + serviceToken + ) + + if (!hotelResponse) { + throw new Error(`Hotel not found: ${hotelId}`) + } + + const hotelPage = hotelPages.find( + (page) => page.hotelId === hotelId + ) + const { hotel, cities, additionalData } = hotelResponse + + const content = { + description: hotel.hotelContent?.texts.descriptions?.short, + galleryImages: hotel.galleryImages, + url: hotelPage?.url ?? "", + openInNewTab: false, + } + + if (contentType === "restaurant") { + const restaurantDescription = + additionalData?.restaurantsOverviewPage + .restaurantsContentDescriptionShort + const restaurantImages = + additionalData.restaurantImages?.heroImages + if (restaurantDescription) { + content.description = restaurantDescription + } + if (restaurantImages && restaurantImages.length > 0) { + content.galleryImages = restaurantImages + } + } else if (contentType === "meeting") { + const meetingDescription = + hotel.hotelContent.texts.meetingDescription?.short + const meetingImages = + additionalData?.conferencesAndMeetings?.heroImages + if (meetingDescription) { + content.description = meetingDescription + } + if (meetingImages && meetingImages.length > 0) { + content.galleryImages = meetingImages + } + } + + const data: HotelListingHotelData = { + hotel: { + id: hotel.id, + countryCode: hotel.countryCode, + galleryImages: content.galleryImages, + name: hotel.name, + tripadvisor: hotel.ratings?.tripAdvisor?.rating || null, + detailedFacilities: hotel.detailedFacilities.sort( + (a, b) => b.sortOrder - a.sortOrder + ), + location: hotel.location, + hotelType: hotel.hotelType, + type: hotel.type, + address: hotel.address, + cityIdentifier: cities[0]?.cityIdentifier || null, + description: content.description || null, + }, + url: content.url, + meetingUrl: additionalData.meetingRooms.meetingOnlineLink || null, + } + + return data + }) + ) + + hotels.push(...chunkedHotels) + } + return hotels.filter((hotel): hotel is HotelListingHotelData => !!hotel) + }, + "1d" + ) +} diff --git a/packages/trpc/lib/routers/hotels/services/getPackages.ts b/packages/trpc/lib/routers/hotels/services/getPackages.ts new file mode 100644 index 000000000..0e084a8c9 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getPackages.ts @@ -0,0 +1,72 @@ +import stringify from "json-stable-stringify-without-jsonify" + +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { toApiLang } from "../../../utils" +import { packagesSchema } from "../output" + +import type { PackagesOutput } from "../../../types/packages" + +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 +} diff --git a/packages/trpc/lib/routers/hotels/services/getRoomFeaturesInventory.ts b/packages/trpc/lib/routers/hotels/services/getRoomFeaturesInventory.ts new file mode 100644 index 000000000..3d03f2390 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getRoomFeaturesInventory.ts @@ -0,0 +1,82 @@ +import stringify from "json-stable-stringify-without-jsonify" + +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { generateChildrenString } from "../helpers" +import { roomFeaturesSchema } from "../output" + +import type { RoomFeaturesInput } from "../input" + +export 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 +} diff --git a/packages/trpc/lib/routers/hotels/services/getRoomsAvailability.ts b/packages/trpc/lib/routers/hotels/services/getRoomsAvailability.ts new file mode 100644 index 000000000..e7018adeb --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getRoomsAvailability.ts @@ -0,0 +1,204 @@ +import stringify from "json-stable-stringify-without-jsonify" + +import { getCacheClient } from "@scandic-hotels/common/dataCache" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { SEARCH_TYPE_REDEMPTION } from "../../../constants/booking" +import { RoomPackageCodeEnum } from "../../../enums/roomFilter" +import { AvailabilityEnum } from "../../../enums/selectHotel" +import { toApiLang } from "../../../utils" +import { generateChildrenString } from "../helpers" +import { roomsAvailabilitySchema } from "../output" +import { getPackages } from "./getPackages" +import { getRoomFeaturesInventory } from "./getRoomFeaturesInventory" + +import type { RoomsAvailabilityOutputSchema } from "../availability/selectRate/rooms/schema" + +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 === SEARCH_TYPE_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) => { + 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 + >((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 +} diff --git a/packages/trpc/lib/routers/hotels/utils.ts b/packages/trpc/lib/routers/hotels/utils.ts index 805e762f9..64cebeec6 100644 --- a/packages/trpc/lib/routers/hotels/utils.ts +++ b/packages/trpc/lib/routers/hotels/utils.ts @@ -1,63 +1,24 @@ 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 { createLogger } from "@scandic-hotels/common/logger/createLogger" -import { createCounter } from "@scandic-hotels/common/telemetry" import { chunk } from "@scandic-hotels/common/utils/chunk" -import { env } from "../../../env/server" import * as api from "../../api" -import { SEARCH_TYPE_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 { type RoomFeaturesInput } from "../../routers/hotels/input" -import { - hotelsAvailabilitySchema, - packagesSchema, - roomFeaturesSchema, - roomsAvailabilitySchema, -} from "../../routers/hotels/output" import { toApiLang } from "../../utils" import { sortRoomConfigs } from "../../utils/sortRoomConfigs" -import { getHotelPageUrls } from "../contentstack/hotelPage/utils" -import { generateChildrenString } from "./helpers" -import { - citiesByCountrySchema, - citiesSchema, - countriesSchema, - getHotelIdsSchema, - hotelSchema, - locationsSchema, -} from "./output" +import { getCity } from "./services/getCity" +import { locationsSchema } from "./output" +import type { Lang } from "@scandic-hotels/common/constants/language" 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 { - HotelInput, - HotelListingHotelData, - Room as RoomCategory, -} from "../../types/hotel" -import type { - CitiesGroupedByCountry, - CityLocation, -} from "../../types/locations" -import type { PackagesOutput } from "../../types/packages" +import type { Room as RoomCategory } from "../../types/hotel" +import type { CitiesGroupedByCountry } from "../../types/locations" import type { Product, Products, @@ -65,391 +26,11 @@ import type { RedemptionsProduct, RoomConfiguration, } from "../../types/roomAvailability" -import type { Cities } from "./output" +import type { RoomsAvailabilityExtendedInputSchema } from "./availability/enterDetails" export const locationsAffix = "locations" const hotelUtilsLogger = createLogger("hotelUtils") -export async function getCitiesByCountry({ - countries, - lang, - affix = locationsAffix, - serviceToken, -}: { - countries: string[] - lang: Lang - affix?: string - serviceToken: string -}): Promise { - const cacheClient = await getCacheClient() - const allCitiesByCountries = await Promise.all( - countries.map(async (country) => { - return cacheClient.cacheOrGet( - `${lang}:${affix}:cities-by-country:${country}`, - async () => { - const params = new URLSearchParams({ - language: toApiLang(lang), - }) - const countryResponse = await api.get( - api.endpoints.v1.Hotel.Cities.country(country), - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }, - params - ) - - if (!countryResponse.ok) { - throw new Error(`Unable to fetch cities by country ${country}`) - } - - const countryJson = await countryResponse.json() - const citiesByCountry = citiesByCountrySchema.safeParse(countryJson) - if (!citiesByCountry.success) { - hotelUtilsLogger.error( - `Unable to parse cities by country ${country}`, - citiesByCountry.error - ) - - throw new Error(`Unable to parse cities by country ${country}`) - } - return { ...citiesByCountry.data, country } - }, - "1d" - ) - }) - ) - - const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({ - ...country, - data: country.data.filter((city) => city.isPublished), - })) - - const groupedCitiesByCountry: CitiesGroupedByCountry = - filteredCitiesByCountries.reduce((acc, { country, data }) => { - acc[country] = data - return acc - }, {} as CitiesGroupedByCountry) - - return groupedCitiesByCountry -} - -export async function getCountries({ - lang, - serviceToken, - warmup = false, -}: { - lang: Lang - serviceToken: string - warmup?: boolean -}) { - const cacheClient = await getCacheClient() - return await cacheClient.cacheOrGet( - `${lang}:${locationsAffix}:countries`, - async () => { - const params = new URLSearchParams({ - language: toApiLang(lang), - }) - - const countryResponse = await api.get( - api.endpoints.v1.Hotel.countries, - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }, - params - ) - - if (!countryResponse.ok) { - throw new Error("Unable to fetch countries") - } - - const countriesJson = await countryResponse.json() - const countries = countriesSchema.safeParse(countriesJson) - if (!countries.success) { - hotelUtilsLogger.error( - `Validation for countries failed`, - countries.error - ) - return null - } - - return countries.data - }, - "1d", - { - cacheStrategy: warmup ? "fetch-then-cache" : "cache-first", - } - ) -} - -export async function getHotelIdsByCityId({ - cityId, - serviceToken, -}: { - cityId: string - serviceToken: string -}) { - const getHotelIdsByCityIdCounter = createCounter( - "hotel", - "getHotelIdsByCityId" - ) - const metricsGetHotelIdsByCityId = getHotelIdsByCityIdCounter.init({ - cityId, - }) - - metricsGetHotelIdsByCityId.start() - - const cacheClient = await getCacheClient() - const result = await cacheClient.cacheOrGet( - `${cityId}:hotelsByCityId`, - async () => { - const searchParams = new URLSearchParams({ - city: cityId, - }) - - const apiResponse = await api.get( - api.endpoints.v1.Hotel.hotels, - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }, - searchParams - ) - - if (!apiResponse.ok) { - await metricsGetHotelIdsByCityId.httpError(apiResponse) - throw new Error("Unable to fetch hotelIds by cityId") - } - - const apiJson = await apiResponse.json() - const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) - if (!validatedHotelIds.success) { - metricsGetHotelIdsByCityId.validationError(validatedHotelIds.error) - throw new Error("Unable to parse data for hotelIds by cityId") - } - - return validatedHotelIds.data - }, - env.CACHE_TIME_HOTELS - ) - - metricsGetHotelIdsByCityId.success() - - return result -} - -export async function getCityByCityIdentifier({ - cityIdentifier, - lang, - serviceToken, -}: { - cityIdentifier: string - lang: Lang - serviceToken: string -}) { - const locations = await getLocations({ - lang, - citiesByCountry: null, - serviceToken, - }) - if (!locations || "error" in locations) { - return null - } - - const city = locations - .filter((loc): loc is CityLocation => loc.type === "cities") - .find((loc) => loc.cityIdentifier === cityIdentifier) - - return city ?? null -} - -export async function getHotelIdsByCityIdentifier( - cityIdentifier: string, - serviceToken: string -) { - const city = await getCityByCityIdentifier({ - cityIdentifier, - lang: Lang.en, - serviceToken, - }) - - if (!city) { - return [] - } - - const hotelIds = await getHotelIdsByCityId({ - cityId: city.id, - serviceToken, - }) - return hotelIds -} - -export async function getHotelIdsByCountry({ - country, - serviceToken, -}: { - country: string - serviceToken: string -}) { - const getHotelIdsByCountryCounter = createCounter( - "hotel", - "getHotelIdsByCountry" - ) - - const metricsGetHotelIdsByCountry = getHotelIdsByCountryCounter.init({ - country, - }) - - metricsGetHotelIdsByCountry.start() - - const cacheClient = await getCacheClient() - - const result = await cacheClient.cacheOrGet( - `${country}:hotelsByCountry`, - async () => { - const hotelIdsParams = new URLSearchParams({ - country, - }) - - const apiResponse = await api.get( - api.endpoints.v1.Hotel.hotels, - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }, - hotelIdsParams - ) - - if (!apiResponse.ok) { - await metricsGetHotelIdsByCountry.httpError(apiResponse) - throw new Error("Unable to fetch hotelIds by country") - } - - const apiJson = await apiResponse.json() - const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) - if (!validatedHotelIds.success) { - metricsGetHotelIdsByCountry.validationError(validatedHotelIds.error) - throw new Error("Unable to parse hotelIds by country") - } - - return validatedHotelIds.data - }, - env.CACHE_TIME_HOTELS - ) - - metricsGetHotelIdsByCountry.success() - - return result -} - -export async function getHotelsByHotelIds({ - hotelIds, - lang, - serviceToken, - contentType = "hotel", -}: { - hotelIds: string[] - lang: Lang - serviceToken: string - contentType?: "hotel" | "restaurant" | "meeting" -}) { - const cacheClient = await getCacheClient() - const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${contentType}:${[...hotelIds].sort().join(",")}` - - return await cacheClient.cacheOrGet( - cacheKey, - async () => { - const hotelPages = await getHotelPageUrls(lang) - const chunkedHotelIds = chunk(hotelIds, 10) - - const hotels: HotelListingHotelData[] = [] - for (const hotelIdChunk of chunkedHotelIds) { - const chunkedHotels = await Promise.all( - hotelIdChunk.map(async (hotelId) => { - const hotelResponse = await getHotel( - { hotelId, language: lang, isCardOnlyPayment: false }, - serviceToken - ) - - if (!hotelResponse) { - throw new Error(`Hotel not found: ${hotelId}`) - } - - const hotelPage = hotelPages.find( - (page) => page.hotelId === hotelId - ) - const { hotel, cities, additionalData } = hotelResponse - - const content = { - description: hotel.hotelContent?.texts.descriptions?.short, - galleryImages: hotel.galleryImages, - url: hotelPage?.url ?? "", - openInNewTab: false, - } - - if (contentType === "restaurant") { - const restaurantDescription = - additionalData?.restaurantsOverviewPage - .restaurantsContentDescriptionShort - const restaurantImages = - additionalData.restaurantImages?.heroImages - if (restaurantDescription) { - content.description = restaurantDescription - } - if (restaurantImages && restaurantImages.length > 0) { - content.galleryImages = restaurantImages - } - } else if (contentType === "meeting") { - const meetingDescription = - hotel.hotelContent.texts.meetingDescription?.short - const meetingImages = - additionalData?.conferencesAndMeetings?.heroImages - if (meetingDescription) { - content.description = meetingDescription - } - if (meetingImages && meetingImages.length > 0) { - content.galleryImages = meetingImages - } - } - - const data: HotelListingHotelData = { - hotel: { - id: hotel.id, - countryCode: hotel.countryCode, - galleryImages: content.galleryImages, - name: hotel.name, - tripadvisor: hotel.ratings?.tripAdvisor?.rating || null, - detailedFacilities: hotel.detailedFacilities.sort( - (a, b) => b.sortOrder - a.sortOrder - ), - location: hotel.location, - hotelType: hotel.hotelType, - type: hotel.type, - address: hotel.address, - cityIdentifier: cities[0]?.cityIdentifier || null, - description: content.description || null, - }, - url: content.url, - meetingUrl: additionalData.meetingRooms.meetingOnlineLink || null, - } - - return data - }) - ) - - hotels.push(...chunkedHotels) - } - return hotels.filter((hotel): hotel is HotelListingHotelData => !!hotel) - }, - "1d" - ) -} - export async function getLocations({ lang, citiesByCountry, @@ -556,128 +137,6 @@ export async function getLocations({ ) } -export const getHotel = cache( - async (input: HotelInput, serviceToken: string) => { - const { language, isCardOnlyPayment } = input - const hotelId = input.hotelId.trim() - - const getHotelCounter = createCounter("hotel", "getHotel") - const metricsGetHotel = getHotelCounter.init({ - hotelId, - language, - isCardOnlyPayment, - }) - - metricsGetHotel.start() - - const cacheClient = await getCacheClient() - - const result = await cacheClient.cacheOrGet( - `${language}:hotel:${hotelId}:${!!isCardOnlyPayment}`, - 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 - * (include=City&include=NearbyHotels&include=Restaurants etc.) - **/ - const params = new URLSearchParams([ - ["include", "AdditionalData"], - ["include", "City"], - ["include", "NearbyHotels"], - ["include", "Restaurants"], - ["include", "RoomCategories"], - ["language", toApiLang(language)], - ]) - - const apiResponse = await api.get( - api.endpoints.v1.Hotel.Hotels.hotel(hotelId), - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }, - params - ) - - if (!apiResponse.ok) { - await metricsGetHotel.httpError(apiResponse) - return null - } - - const apiJson = await apiResponse.json() - const validateHotelData = hotelSchema.safeParse(apiJson) - - if (!validateHotelData.success) { - metricsGetHotel.validationError(validateHotelData.error) - throw badRequestError() - } - - const hotelData = validateHotelData.data - - if (isCardOnlyPayment) { - hotelData.hotel.merchantInformationData.alternatePaymentOptions = [] - } - - const gallery = hotelData.additionalData?.gallery - if (gallery) { - const smallerImages = gallery.smallerImages - const hotelGalleryImages = - hotelData.hotel.hotelType === HotelTypeEnum.Signature - ? smallerImages.slice(0, 10) - : smallerImages.slice(0, 6) - hotelData.hotel.galleryImages = hotelGalleryImages - } - - return hotelData - }, - env.CACHE_TIME_HOTELS - ) - - metricsGetHotel.success() - - return result - } -) - -export async function getCity({ - cityUrl, - serviceToken, -}: { - cityUrl: string - serviceToken: string -}): Promise { - const cacheClient = await getCacheClient() - return await cacheClient.cacheOrGet( - cityUrl, - async () => { - const url = new URL(cityUrl) - const cityResponse = await api.get( - url.pathname as Endpoint, - { headers: { Authorization: `Bearer ${serviceToken}` } }, - url.searchParams - ) - - if (!cityResponse.ok) { - return null - } - - const cityJson = await cityResponse.json() - const city = citiesSchema.safeParse(cityJson) - if (!city.success) { - hotelUtilsLogger.error(`Validation of city failed`, { - error: city.error, - cityUrl, - }) - return null - } - - return city.data - }, - "1d" - ) -} - export const TWENTYFOUR_HOURS = 60 * 60 * 24 function findProduct(product: Products, rateDefinition: RateDefinition) { @@ -708,520 +167,6 @@ function findProduct(product: Products, rateDefinition: RateDefinition) { } } -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 = { - 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, - token: string, - userPoints: number = 0 -) { - const { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - } = input - - const params = new URLSearchParams([ - ["roomStayStartDate", roomStayStartDate], - ["roomStayEndDate", roomStayEndDate], - ["adults", adults.toString()], - ["children", children ?? ""], - ["bookingCode", bookingCode], - ["isRedemption", redemption.toString()], - ["language", apiLang], - ]) - - const getHotelsAvailabilityByHotelIdsCounter = createCounter( - "hotel", - "getHotelsAvailabilityByHotelIds" - ) - const metricsGetHotelsAvailabilityByHotelIds = - getHotelsAvailabilityByHotelIdsCounter.init({ - apiLang, - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - }) - - 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 ${token}`, - }, - }, - 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() - } - - if (redemption) { - validateAvailabilityData.data.data.forEach((data) => { - data.attributes.productType?.redemptions?.forEach((r) => { - r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay - }) - }) - } - - return { - availability: validateAvailabilityData.data.data.flatMap( - (hotels) => hotels.attributes - ), - } - }, - redemption ? "no cache" : 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 === SEARCH_TYPE_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 - >((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[], diff --git a/packages/trpc/lib/routers/navigation/mypages/index.ts b/packages/trpc/lib/routers/navigation/mypages/index.ts index fc1957000..a1f54718c 100644 --- a/packages/trpc/lib/routers/navigation/mypages/index.ts +++ b/packages/trpc/lib/routers/navigation/mypages/index.ts @@ -4,8 +4,8 @@ import { z } from "zod" import { Lang } from "@scandic-hotels/common/constants/language" import { safeProtectedProcedure } from "../../../procedures" -import { getVerifiedUser } from "../../../routers/user/utils" import { isValidSession } from "../../../utils/session" +import { getVerifiedUser } from "../../user/utils/getVerifiedUser" import { getPrimaryLinks } from "./getPrimaryLinks" import { getSecondaryLinks } from "./getSecondaryLinks" diff --git a/packages/trpc/lib/routers/partners/sas/performLevelUpgrade.ts b/packages/trpc/lib/routers/partners/sas/performLevelUpgrade.ts index 0d4385adf..1ac59703c 100644 --- a/packages/trpc/lib/routers/partners/sas/performLevelUpgrade.ts +++ b/packages/trpc/lib/routers/partners/sas/performLevelUpgrade.ts @@ -8,7 +8,7 @@ import { createLogger } from "@scandic-hotels/common/logger/createLogger" import * as api from "../../../api" import { protectedProcedure } from "../../../procedures" import { getUserSchema } from "../../user/output" -import { getVerifiedUser } from "../../user/utils" +import { getVerifiedUser } from "../../user/utils/getVerifiedUser" import type { FriendsTier } from "../../../types/user" diff --git a/packages/trpc/lib/routers/user/query/index.ts b/packages/trpc/lib/routers/user/query/index.ts index 05cb8c8b1..94bbe1fd5 100644 --- a/packages/trpc/lib/routers/user/query/index.ts +++ b/packages/trpc/lib/routers/user/query/index.ts @@ -12,7 +12,6 @@ import { getFriendsMembership, getMembershipCards, } from "../../../routers/user/helpers" -import { getVerifiedUser } from "../../../routers/user/utils" import { toApiLang } from "../../../utils" import { isValidSession } from "../../../utils/session" import { @@ -21,13 +20,12 @@ import { staysInput, } from "../input" import { getFriendTransactionsSchema } from "../output" -import { - getCreditCards, - getPreviousStays, - getUpcomingStays, - parsedUser, - updateStaysBookingUrl, -} from "../utils" +import { getCreditCards } from "../services/getCreditCards" +import { getPreviousStays } from "../services/getPreviousStays" +import { getUpcomingStays } from "../services/getUpcomingStays" +import { getVerifiedUser } from "../utils/getVerifiedUser" +import { parsedUser } from "../utils/parsedUser" +import { updateStaysBookingUrl } from "../utils/updateStaysBookingUrl" import { userTrackingInfo } from "./userTrackingInfo" export const userQueryRouter = router({ diff --git a/packages/trpc/lib/routers/user/query/userTrackingInfo.ts b/packages/trpc/lib/routers/user/query/userTrackingInfo.ts index 9552e1853..9c9bdca5c 100644 --- a/packages/trpc/lib/routers/user/query/userTrackingInfo.ts +++ b/packages/trpc/lib/routers/user/query/userTrackingInfo.ts @@ -3,7 +3,7 @@ import { createCounter } from "@scandic-hotels/common/telemetry" import { safeProtectedProcedure } from "../../../procedures" import { isValidSession } from "../../../utils/session" import { getFriendsMembership } from "../helpers" -import { getVerifiedUser } from "../utils" +import { getVerifiedUser } from "../utils/getVerifiedUser" import type { LoginType } from "@scandic-hotels/common/constants/loginType" diff --git a/packages/trpc/lib/routers/user/services/getCreditCards.ts b/packages/trpc/lib/routers/user/services/getCreditCards.ts new file mode 100644 index 000000000..103206645 --- /dev/null +++ b/packages/trpc/lib/routers/user/services/getCreditCards.ts @@ -0,0 +1,60 @@ +import { dt } from "@scandic-hotels/common/dt" +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { cache } from "../../../DUPLICATED/cache" +import { creditCardsSchema } from "../output" + +import type { Session } from "next-auth" + +export const getCreditCards = cache( + async ({ + session, + onlyNonExpired, + }: { + session: Session + onlyNonExpired?: boolean + }) => { + const getCreditCardsCounter = createCounter("user", "getCreditCards") + const metricsGetCreditCards = getCreditCardsCounter.init({ + onlyNonExpired, + }) + + metricsGetCreditCards.start() + + const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, { + headers: { + Authorization: `Bearer ${session.token.access_token}`, + }, + }) + + if (!apiResponse.ok) { + await metricsGetCreditCards.httpError(apiResponse) + return null + } + + const apiJson = await apiResponse.json() + const verifiedData = creditCardsSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsGetCreditCards.validationError(verifiedData.error) + return null + } + + const result = verifiedData.data.data.filter((card) => { + if (onlyNonExpired) { + try { + const expirationDate = dt(card.expirationDate).startOf("day") + const currentDate = dt().startOf("day") + return expirationDate > currentDate + } catch (_) { + return false + } + } + return true + }) + + metricsGetCreditCards.success() + + return result + } +) diff --git a/packages/trpc/lib/routers/user/services/getPreviousStays.ts b/packages/trpc/lib/routers/user/services/getPreviousStays.ts new file mode 100644 index 000000000..d003e5433 --- /dev/null +++ b/packages/trpc/lib/routers/user/services/getPreviousStays.ts @@ -0,0 +1,59 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { toApiLang } from "../../../utils" +import { getStaysSchema } from "../output" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function getPreviousStays( + accessToken: string, + limit: number = 10, + language: Lang, + cursor?: string +) { + const getPreviousStaysCounter = createCounter("user", "getPreviousStays") + const metricsGetPreviousStays = getPreviousStaysCounter.init({ + limit, + cursor, + language, + }) + + metricsGetPreviousStays.start() + + const params: Record = { + limit: String(limit), + language: toApiLang(language), + } + + if (cursor) { + params.offset = cursor + } + + const apiResponse = await api.get( + api.endpoints.v1.Booking.Stays.past, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + await metricsGetPreviousStays.httpError(apiResponse) + return null + } + + const apiJson = await apiResponse.json() + + const verifiedData = getStaysSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsGetPreviousStays.validationError(verifiedData.error) + return null + } + + metricsGetPreviousStays.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/routers/user/services/getUpcomingStays.ts b/packages/trpc/lib/routers/user/services/getUpcomingStays.ts new file mode 100644 index 000000000..c2a9eb871 --- /dev/null +++ b/packages/trpc/lib/routers/user/services/getUpcomingStays.ts @@ -0,0 +1,59 @@ +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../api" +import { toApiLang } from "../../../utils" +import { getStaysSchema } from "../output" + +import type { Lang } from "@scandic-hotels/common/constants/language" + +export async function getUpcomingStays( + accessToken: string, + limit: number = 10, + language: Lang, + cursor?: string +) { + const getUpcomingStaysCounter = createCounter("user", "getUpcomingStays") + const metricsGetUpcomingStays = getUpcomingStaysCounter.init({ + limit, + cursor, + language, + }) + + metricsGetUpcomingStays.start() + + const params: Record = { + limit: String(limit), + language: toApiLang(language), + } + + if (cursor) { + params.offset = cursor + } + + const apiResponse = await api.get( + api.endpoints.v1.Booking.Stays.future, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + await metricsGetUpcomingStays.httpError(apiResponse) + return null + } + + const apiJson = await apiResponse.json() + + const verifiedData = getStaysSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsGetUpcomingStays.validationError(verifiedData.error) + return null + } + + metricsGetUpcomingStays.success() + + return verifiedData.data +} diff --git a/packages/trpc/lib/routers/user/utils/getMemberShipNumber.ts b/packages/trpc/lib/routers/user/utils/getMemberShipNumber.ts new file mode 100644 index 000000000..293526ac5 --- /dev/null +++ b/packages/trpc/lib/routers/user/utils/getMemberShipNumber.ts @@ -0,0 +1,17 @@ +import { isValidSession } from "../../../utils/session" +import { getVerifiedUser } from "./getVerifiedUser" + +import type { Session } from "next-auth" + +export async function getMembershipNumber( + session: Session | null +): Promise { + if (!isValidSession(session)) return undefined + + const verifiedUser = await getVerifiedUser({ session }) + if (!verifiedUser || "error" in verifiedUser) { + return undefined + } + + return verifiedUser.data.membershipNumber +} diff --git a/packages/trpc/lib/routers/user/utils/getVerifiedUser.ts b/packages/trpc/lib/routers/user/utils/getVerifiedUser.ts new file mode 100644 index 000000000..db28811cd --- /dev/null +++ b/packages/trpc/lib/routers/user/utils/getVerifiedUser.ts @@ -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 + } +) diff --git a/packages/trpc/lib/routers/user/utils/parsedUser.ts b/packages/trpc/lib/routers/user/utils/parsedUser.ts new file mode 100644 index 000000000..42138eeba --- /dev/null +++ b/packages/trpc/lib/routers/user/utils/parsedUser.ts @@ -0,0 +1,54 @@ +import * as maskValue from "@scandic-hotels/common/utils/maskValue" + +import { countries } from "../../../constants/countries" +import { getFriendsMembership } from "../helpers" + +import type { User } from "../../../types/user" + +export function parsedUser(data: User, isMFA: boolean) { + const country = countries.find((c) => c.code === data.address?.countryCode) + + const user = { + address: { + city: data.address?.city, + country: country?.name ?? "", + countryCode: data.address?.countryCode, + streetAddress: data.address?.streetAddress, + zipCode: data.address?.zipCode, + }, + dateOfBirth: data.dateOfBirth, + email: data.email, + employmentDetails: data.employmentDetails, + firstName: data.firstName, + language: data.language, + lastName: data.lastName, + loyalty: data.loyalty, + membershipNumber: data.membershipNumber, + membership: data.loyalty ? getFriendsMembership(data.loyalty) : null, + name: `${data.firstName} ${data.lastName}`, + phoneNumber: data.phoneNumber, + profileId: data.profileId, + promotions: data.promotions || null, + } satisfies User + + if (!isMFA) { + if (user.address.city) { + user.address.city = maskValue.text(user.address.city) + } + if (user.address.streetAddress) { + user.address.streetAddress = maskValue.text(user.address.streetAddress) + } + + user.address.zipCode = data.address?.zipCode + ? maskValue.text(data.address.zipCode) + : "" + + user.dateOfBirth = maskValue.all(user.dateOfBirth) + + user.email = maskValue.email(user.email) + + user.phoneNumber = user.phoneNumber ? maskValue.phone(user.phoneNumber) : "" + } + + return user +} diff --git a/packages/trpc/lib/routers/user/utils/updateStaysBookingUrl.ts b/packages/trpc/lib/routers/user/utils/updateStaysBookingUrl.ts new file mode 100644 index 000000000..61047c105 --- /dev/null +++ b/packages/trpc/lib/routers/user/utils/updateStaysBookingUrl.ts @@ -0,0 +1,69 @@ +import "server-only" + +import { myStay } from "@scandic-hotels/common/constants/routes/myStay" + +import { env } from "../../../../env/server" +import { encrypt } from "../../../utils/encryption" +import { getVerifiedUser } from "./getVerifiedUser" + +import type { Lang } from "@scandic-hotels/common/constants/language" +import type { Session } from "next-auth" + +import type { FriendTransaction, Stay } from "../output" + +export async function updateStaysBookingUrl( + data: Stay[], + session: Session, + lang: Lang +): Promise + +export async function updateStaysBookingUrl( + data: FriendTransaction[], + session: Session, + lang: Lang +): Promise + +export async function updateStaysBookingUrl( + data: Stay[] | FriendTransaction[], + session: Session, + lang: Lang +) { + const user = await getVerifiedUser({ + session, + }) + + if (user && !("error" in user)) { + return data.map((d) => { + const originalString = + d.attributes.confirmationNumber.toString() + "," + user.data.lastName + const encryptedBookingValue = encrypt(originalString) + + // Get base URL with fallback for ephemeral environments (like deploy previews). + const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com" + + // Construct Booking URL. + const bookingUrl = new URL(myStay[lang], baseUrl) + + // Add search parameters. + if (encryptedBookingValue) { + bookingUrl.searchParams.set("RefId", encryptedBookingValue) + } else { + bookingUrl.searchParams.set("lastName", user.data.lastName) + bookingUrl.searchParams.set( + "bookingId", + d.attributes.confirmationNumber.toString() + ) + } + + return { + ...d, + attributes: { + ...d.attributes, + bookingUrl: bookingUrl.toString(), + }, + } + }) + } + + return data +} diff --git a/packages/trpc/lib/types/availability.ts b/packages/trpc/lib/types/availability.ts index ea0eb3d0d..6323790a3 100644 --- a/packages/trpc/lib/types/availability.ts +++ b/packages/trpc/lib/types/availability.ts @@ -1,11 +1,5 @@ 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 { @@ -16,23 +10,6 @@ import type { } from "../routers/hotels/schemas/productTypePrice" export type HotelsAvailability = z.output -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 export type ProductTypePrices = z.output