Merged in chore/refactor-hotel-trpc-routes (pull request #2891)

Chore/refactor hotel trpc routes

* chore(SW-3519): refactor trpc hotel routers

* chore(SW-3519): refactor trpc hotel routers

* refactor

* merge

* Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/refactor-hotel-trpc-routes


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-10-01 12:55:45 +00:00
parent 332abdfba0
commit 8498026189
52 changed files with 2338 additions and 1794 deletions

View File

@@ -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<Record<string, unknown>>((acc, key) => {
acc[key] = env[key as keyof typeof env]
return acc
}, {})
return NextResponse.json({ user, env: sortedEnv })
}

View File

@@ -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"

View File

@@ -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 "."

View File

@@ -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"

View File

@@ -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"

View File

@@ -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(

View File

@@ -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"

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<string, number> = {}
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
})

View File

@@ -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
)
})

View File

@@ -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),
}
})

View File

@@ -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
)
})

View File

@@ -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,
})

View File

@@ -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,
}
})

View File

@@ -0,0 +1,8 @@
import { router } from "../../../.."
import { room } from "./room"
import { rooms } from "./rooms"
export const selectRate = router({
room,
rooms,
})

View File

@@ -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,
}
})

View File

@@ -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
})

View File

@@ -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",
}
)

View File

@@ -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,

View File

@@ -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<string, number> = {}
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 }) => {

View File

@@ -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<CitiesGroupedByCountry> {
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
}

View File

@@ -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<Cities> {
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"
)
}

View File

@@ -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
}

View File

@@ -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",
}
)
}

View File

@@ -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
}
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<string, string | number> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
...(redemption ? { isRedemption: "true" } : {}),
language: apiLang,
}
const getHotelsAvailabilityByCityCounter = createCounter(
"hotel",
"getHotelsAvailabilityByCity"
)
const metricsGetHotelsAvailabilityByCity =
getHotelsAvailabilityByCityCounter.init({
apiLang,
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
redemption,
})
metricsGetHotelsAvailabilityByCity.start()
const apiResponse = await api.get(
api.endpoints.v1.Availability.city(cityId),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetHotelsAvailabilityByCity.httpError(apiResponse)
throw new Error("Failed to fetch hotels availability by city")
}
const apiJson = await apiResponse.json()
const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
metricsGetHotelsAvailabilityByCity.validationError(
validateAvailabilityData.error
)
throw badRequestError()
}
if (redemption) {
validateAvailabilityData.data.data.forEach((data) => {
data.attributes.productType?.redemptions?.forEach((r) => {
r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay
})
})
}
const result = {
availability: validateAvailabilityData.data.data.flatMap(
(hotels) => hotels.attributes
),
}
metricsGetHotelsAvailabilityByCity.success()
return result
}

View File

@@ -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
}

View File

@@ -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"
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<string, number>
>((fts, feat) => {
fts[feat.roomTypeCode] = feat.features?.[0]?.inventory ?? 0
return fts
}, {})
const updatedRoomConfigurations =
validateAvailabilityData.data.roomConfigurations
// This filter is needed since we can get availability
// back from roomFeatures yet the availability call
// says there are no rooms left...
.filter((rc) => rc.roomsLeft)
.filter((rc) => features?.[rc.roomTypeCode])
.map((rc) => ({
...rc,
roomsLeft: features[rc.roomTypeCode],
status: AvailabilityEnum.Available,
}))
validateAvailabilityData.data.roomConfigurations =
updatedRoomConfigurations
}
}
return validateAvailabilityData.data
}
},
"1m"
)
return result
})
)
const data = availabilityResponses.map((availability) => {
if (availability.status === "fulfilled") {
return availability.value
}
return {
details: availability.reason,
error: "request_failure",
}
})
metricsGetRoomsAvailability.success()
return data
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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"

View File

@@ -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({

View File

@@ -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"

View File

@@ -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
}
)

View File

@@ -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<string, string> = {
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
}

View File

@@ -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<string, string> = {
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
}

View File

@@ -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<string | undefined> {
if (!isValidSession(session)) return undefined
const verifiedUser = await getVerifiedUser({ session })
if (!verifiedUser || "error" in verifiedUser) {
return undefined
}
return verifiedUser.data.membershipNumber
}

View File

@@ -0,0 +1,79 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import { cache } from "../../../DUPLICATED/cache"
import { getUserSchema } from "../output"
import type { Session } from "next-auth"
export const getVerifiedUser = cache(
async ({
session,
includeExtendedPartnerData,
}: {
session: Session
includeExtendedPartnerData?: boolean
}) => {
const getVerifiedUserCounter = createCounter("user", "getVerifiedUser")
const metricsGetVerifiedUser = getVerifiedUserCounter.init()
metricsGetVerifiedUser.start()
const now = Date.now()
if (session.token.expires_at && session.token.expires_at < now) {
metricsGetVerifiedUser.dataError(`Token expired`)
return { error: true, cause: "token_expired" } as const
}
const apiResponse = await api.get(
api.endpoints.v2.Profile.profile,
{
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
},
includeExtendedPartnerData
? { includes: "extendedPartnerInformation" }
: {}
)
if (!apiResponse.ok) {
await metricsGetVerifiedUser.httpError(apiResponse)
if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const
} else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const
} else if (apiResponse.status === 404) {
return { error: true, cause: "notfound" } as const
}
return {
error: true,
cause: "unknown",
status: apiResponse.status,
} as const
}
const apiJson = await apiResponse.json()
if (!apiJson.data?.attributes) {
metricsGetVerifiedUser.dataError(
`Missing data attributes in API response`,
{
data: apiJson,
}
)
return null
}
const verifiedData = getUserSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetVerifiedUser.validationError(verifiedData.error)
return null
}
metricsGetVerifiedUser.success()
return verifiedData
}
)

View File

@@ -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
}

View File

@@ -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<Stay[]>
export async function updateStaysBookingUrl(
data: FriendTransaction[],
session: Session,
lang: Lang
): Promise<FriendTransaction[]>
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
}

View File

@@ -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<typeof hotelsAvailabilitySchema>
export type HotelsAvailabilityInputSchema = z.output<
typeof hotelsAvailabilityInputSchema
>
export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
typeof getHotelsByHotelIdsAvailabilityInputSchema
>
export type RoomsAvailabilityInputSchema = z.input<
typeof selectRateRoomsAvailabilityInputSchema
>
export type RoomsAvailabilityOutputSchema = z.output<
typeof selectRateRoomsAvailabilityInputSchema
>
export type RoomsAvailabilityInputRoom =
RoomsAvailabilityInputSchema["booking"]["rooms"][number]
export type RoomsAvailabilityExtendedInputSchema = z.input<
typeof enterDetailsRoomsAvailabilityInputSchema
>
export type ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema>