import { metrics } from "@opentelemetry/api" import { Lang } from "@/constants/languages" import * as api from "@/lib/api" import { dt } from "@/lib/dt" import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql" import { request } from "@/lib/graphql/request" import { badRequestError, notFound, serverErrorByStatus, } from "@/server/errors/trpc" import { contentStackUidWithServiceProcedure, publicProcedure, router, safeProtectedServiceProcedure, serviceProcedure, } from "@/server/trpc" import { toApiLang } from "@/server/utils" import { hotelPageSchema } from "../contentstack/hotelPage/output" import { fetchHotelPageRefs, generatePageTags, getHotelPageCounter, validateHotelPageRefs, } from "../contentstack/hotelPage/utils" import { getVerifiedUser, parsedUser } from "../user/query" import { getRoomPackagesInputSchema, getRoomPackagesSchema, } from "./schemas/packages" import { getBreakfastPackageInputSchema, getHotelDataInputSchema, getHotelInputSchema, getHotelsAvailabilityInputSchema, getRatesInputSchema, getRoomsAvailabilityInputSchema, getSelectedRoomAvailabilityInputSchema, type HotelDataInput, } from "./input" import { breakfastPackagesSchema, getHotelDataSchema, getHotelsAvailabilitySchema, getRatesSchema, getRoomsAvailabilitySchema, } from "./output" import tempRatesData from "./tempRatesData.json" import { getCitiesByCountry, getCountries, getLocations, TWENTYFOUR_HOURS, } from "./utils" import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { BreakfastPackageEnum } from "@/types/enums/breakfast" import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { Facility } from "@/types/hotel" import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage" const meter = metrics.getMeter("trpc.hotels") const getHotelCounter = meter.createCounter("trpc.hotel.get") const getHotelSuccessCounter = meter.createCounter("trpc.hotel.get-success") const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail") const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get") const getPackagesSuccessCounter = meter.createCounter( "trpc.hotel.packages.get-success" ) const getPackagesFailCounter = meter.createCounter( "trpc.hotel.packages.get-fail" ) const hotelsAvailabilityCounter = meter.createCounter( "trpc.hotel.availability.hotels" ) const hotelsAvailabilitySuccessCounter = meter.createCounter( "trpc.hotel.availability.hotels-success" ) const hotelsAvailabilityFailCounter = meter.createCounter( "trpc.hotel.availability.hotels-fail" ) const roomsAvailabilityCounter = meter.createCounter( "trpc.hotel.availability.rooms" ) const roomsAvailabilitySuccessCounter = meter.createCounter( "trpc.hotel.availability.rooms-success" ) const roomsAvailabilityFailCounter = meter.createCounter( "trpc.hotel.availability.rooms-fail" ) const selectedRoomAvailabilityCounter = meter.createCounter( "trpc.hotel.availability.room" ) const selectedRoomAvailabilitySuccessCounter = meter.createCounter( "trpc.hotel.availability.room-success" ) const selectedRoomAvailabilityFailCounter = meter.createCounter( "trpc.hotel.availability.room-fail" ) const breakfastPackagesCounter = meter.createCounter("trpc.package.breakfast") const breakfastPackagesSuccessCounter = meter.createCounter( "trpc.package.breakfast-success" ) const breakfastPackagesFailCounter = meter.createCounter( "trpc.package.breakfast-fail" ) async function getContentstackData(lang: Lang, uid?: string | null) { if (!uid) { return null } const contentPageRefsData = await fetchHotelPageRefs(lang, uid) const contentPageRefs = validateHotelPageRefs(contentPageRefsData, lang, uid) if (!contentPageRefs) { return null } const tags = generatePageTags(contentPageRefs, lang) getHotelPageCounter.add(1, { lang, uid }) console.info( "contentstack.hotelPage start", JSON.stringify({ query: { lang, uid }, }) ) const response = await request( GetHotelPage, { locale: lang, uid, }, { cache: "force-cache", next: { tags, }, } ) if (!response.data) { throw notFound(response) } const hotelPageData = hotelPageSchema.safeParse(response.data) if (!hotelPageData.success) { console.error( `Failed to validate Hotel Page - (uid: ${uid}, lang: ${lang})` ) console.error(hotelPageData.error) return null } return hotelPageData.data.hotel_page } export async function getHotelData( input: HotelDataInput, serviceToken: string ) { const { hotelId, language, include, isCardOnlyPayment } = input const params: Record = { hotelId, language, } if (include) { params.include = include.join(",") } getHotelCounter.add(1, { hotelId, language, include, }) console.info( "api.hotels.hotelData start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.hotel(hotelId), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() getHotelFailCounter.add(1, { hotelId, language, include, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotelData error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const validateHotelData = getHotelDataSchema.safeParse(apiJson) if (!validateHotelData.success) { getHotelFailCounter.add(1, { hotelId, language, include, error_type: "validation_error", error: JSON.stringify(validateHotelData.error), }) console.error( "api.hotels.hotelData validation error", JSON.stringify({ query: { hotelId, params }, error: validateHotelData.error, }) ) throw badRequestError() } getHotelSuccessCounter.add(1, { hotelId, language, include, }) console.info( "api.hotels.hotelData success", JSON.stringify({ query: { hotelId, params: params }, }) ) if (isCardOnlyPayment) { validateHotelData.data.data.attributes.merchantInformationData.alternatePaymentOptions = [] } return validateHotelData.data } export const hotelQueryRouter = router({ get: contentStackUidWithServiceProcedure .input(getHotelInputSchema) .query(async ({ ctx, input }) => { const { lang, uid } = ctx const { include } = input const contentstackData = await getContentstackData(lang, uid) const hotelId = contentstackData?.hotel_page_id if (!hotelId) { throw notFound(`Hotel not found for uid: ${uid}`) } const apiLang = toApiLang(lang) const params: Record = { hotelId, language: apiLang, } if (include) { params.include = include.join(",") } getHotelCounter.add(1, { hotelId, lang, include }) console.info( "api.hotels.hotel start", JSON.stringify({ query: { hotelId, params }, }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.hotel(hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() getHotelFailCounter.add(1, { hotelId, lang, include, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotel error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) throw serverErrorByStatus(apiResponse.status, apiResponse) } const apiJson = await apiResponse.json() const validatedHotelData = getHotelDataSchema.safeParse(apiJson) if (!validatedHotelData.success) { getHotelFailCounter.add(1, { hotelId, lang, include, error_type: "validation_error", error: JSON.stringify(validatedHotelData.error), }) console.error( "api.hotels.hotel validation error", JSON.stringify({ query: { hotelId, params }, error: validatedHotelData.error, }) ) throw badRequestError() } const included = validatedHotelData.data.included || [] const hotelAttributes = validatedHotelData.data.data.attributes const images = hotelAttributes.gallery?.smallerImages const hotelAlerts = hotelAttributes.meta?.specialAlerts || [] const roomCategories = included ? included.filter((item) => item.type === "roomcategories") : [] const activities = contentstackData?.content ? contentstackData?.content[0] : null const facilities: Facility[] = [ { ...apiJson.data.attributes.restaurantImages, id: FacilityCardTypeEnum.restaurant, }, { ...apiJson.data.attributes.conferencesAndMeetings, id: FacilityCardTypeEnum.conference, }, { ...apiJson.data.attributes.healthAndWellness, id: FacilityCardTypeEnum.wellness, }, ] getHotelSuccessCounter.add(1, { hotelId, lang, include }) console.info( "api.hotels.hotel success", JSON.stringify({ query: { hotelId, params: params }, }) ) return { hotelName: hotelAttributes.name, hotelDescription: hotelAttributes.hotelContent.texts.descriptions.short, hotelLocation: hotelAttributes.location, hotelAddress: hotelAttributes.address, hotelRatings: hotelAttributes.ratings, hotelDetailedFacilities: hotelAttributes.detailedFacilities, hotelImages: images, pointsOfInterest: hotelAttributes.pointsOfInterest, roomCategories, activitiesCard: activities?.upcoming_activities_card, facilities, alerts: hotelAlerts, faq: contentstackData?.faq, } }), availability: router({ hotels: serviceProcedure .input(getHotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = ctx const apiLang = toApiLang(lang) const { cityId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, attachedProfileId, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), promotionCode, reservationProfileType, attachedProfileId, language: apiLang, } hotelsAvailabilityCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, }) console.info( "api.hotels.hotelsAvailability start", JSON.stringify({ query: { cityId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Availability.city(cityId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() hotelsAvailabilityFailCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotelsAvailability error", JSON.stringify({ query: { cityId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const validateAvailabilityData = getHotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { hotelsAvailabilityFailCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( "api.hotels.hotelsAvailability validation error", JSON.stringify({ query: { cityId, params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } hotelsAvailabilitySuccessCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, }) console.info( "api.hotels.hotelsAvailability success", JSON.stringify({ query: { cityId, params: params }, }) ) return { availability: validateAvailabilityData.data.data .filter( (hotels) => hotels.attributes.status === AvailabilityEnum.Available ) .flatMap((hotels) => hotels.attributes), } }), rooms: serviceProcedure .input(getRoomsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, attachedProfileId, rateCode, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), promotionCode, reservationProfileType, attachedProfileId, } roomsAvailabilityCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, }) console.info( "api.hotels.roomsAvailability start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Availability.hotel(hotelId.toString()), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() roomsAvailabilityFailCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.roomsAvailability error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const validateAvailabilityData = getRoomsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { roomsAvailabilityFailCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( "api.hotels.roomsAvailability validation error", JSON.stringify({ query: { hotelId, params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } roomsAvailabilitySuccessCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, }) console.info( "api.hotels.roomsAvailability success", JSON.stringify({ query: { hotelId, params: params }, }) ) if (rateCode) { validateAvailabilityData.data.mustBeGuaranteed = validateAvailabilityData.data.rateDefinitions.filter( (rate) => rate.rateCode === rateCode )[0].mustBeGuaranteed } return validateAvailabilityData.data }), room: serviceProcedure .input(getSelectedRoomAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, attachedProfileId, rateCode, roomTypeCode, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), promotionCode, reservationProfileType, attachedProfileId, } selectedRoomAvailabilityCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, }) console.info( "api.hotels.selectedRoomAvailability start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponseAvailability = await api.get( api.endpoints.v1.Availability.hotel(hotelId.toString()), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponseAvailability.ok) { const text = await apiResponseAvailability.text() selectedRoomAvailabilityFailCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, error_type: "http_error", error: JSON.stringify({ status: apiResponseAvailability.status, statusText: apiResponseAvailability.statusText, text, }), }) console.error( "api.hotels.selectedRoomAvailability error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponseAvailability.status, statusText: apiResponseAvailability.statusText, text, }, }) ) return null } const apiJsonAvailability = await apiResponseAvailability.json() const validateAvailabilityData = getRoomsAvailabilitySchema.safeParse(apiJsonAvailability) if (!validateAvailabilityData.success) { selectedRoomAvailabilityFailCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( "api.hotels.selectedRoomAvailability validation error", JSON.stringify({ query: { hotelId, params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } const selectedRoom = validateAvailabilityData.data.roomConfigurations .filter((room) => room.status === "Available") .find((room) => room.roomTypeCode === roomTypeCode) if (!selectedRoom) { console.error("No matching room found") return null } const memberRate = selectedRoom.products.find( (rate) => rate.productType.member?.rateCode === rateCode )?.productType.member const publicRate = selectedRoom.products.find( (rate) => rate.productType.public?.rateCode === rateCode )?.productType.public const mustBeGuaranteed = validateAvailabilityData.data.rateDefinitions.filter( (rate) => rate.rateCode === rateCode )[0].mustBeGuaranteed const cancellationText = validateAvailabilityData.data.rateDefinitions.find( (rate) => rate.rateCode === rateCode )?.cancellationText ?? "" selectedRoomAvailabilitySuccessCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, promotionCode, reservationProfileType, }) console.info( "api.hotels.selectedRoomAvailability success", JSON.stringify({ query: { hotelId, params: params }, }) ) return { selectedRoom, mustBeGuaranteed, cancellationText, memberRate, publicRate, } }), }), rates: router({ get: publicProcedure .input(getRatesInputSchema) .query(async ({ input, ctx }) => { // TODO: Do a real API call when the endpoint is ready // const { hotelId } = input // const params = new URLSearchParams() // const apiLang = toApiLang(language) // params.set("hotelId", hotelId.toString()) // params.set("language", apiLang) console.info("api.hotels.rates start", JSON.stringify({})) const validatedHotelData = getRatesSchema.safeParse(tempRatesData) if (!tempRatesData) { console.error( "api.hotels.rates error", JSON.stringify({ error: null }) ) //Can't return null here since consuming component does not handle null yet // return null } if (!validatedHotelData.success) { console.error( "api.hotels.rates validation error", JSON.stringify({ error: validatedHotelData.error, }) ) throw badRequestError() } console.info("api.hotels.rates success", JSON.stringify({})) return validatedHotelData.data }), }), hotelData: router({ get: serviceProcedure .input(getHotelDataInputSchema) .query(async ({ ctx, input }) => { return getHotelData(input, ctx.serviceToken) }), }), locations: router({ get: serviceProcedure.query(async function ({ ctx }) { const searchParams = new URLSearchParams() searchParams.set("language", toApiLang(ctx.lang)) const options: RequestOptionsWithOutBody = { // needs to clear default option as only // cache or next.revalidate is permitted cache: undefined, headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, next: { revalidate: TWENTYFOUR_HOURS, }, } const countries = await getCountries(options, searchParams, ctx.lang) let citiesByCountry = null if (countries) { citiesByCountry = await getCitiesByCountry( countries, options, searchParams, ctx.lang ) } const locations = await getLocations( ctx.lang, options, searchParams, citiesByCountry ) if (Array.isArray(locations)) { return { data: locations, } } return locations }), }), packages: router({ get: serviceProcedure .input(getRoomPackagesInputSchema) .query(async ({ input, ctx }) => { const { hotelId, startDate, endDate, adults, children, packageCodes } = input const { lang } = ctx const apiLang = toApiLang(lang) const searchParams = new URLSearchParams({ startDate, endDate, adults: adults.toString(), children: children.toString(), language: apiLang, }) packageCodes.forEach((code) => { searchParams.append("packageCodes", code) }) const params = searchParams.toString() getPackagesCounter.add(1, { hotelId, }) console.info( "api.hotels.packages start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Package.Packages.hotel(hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { getPackagesFailCounter.add(1, { hotelId, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, }), }) console.error( "api.hotels.packages error", JSON.stringify({ query: { hotelId, params } }) ) throw serverErrorByStatus(apiResponse.status, apiResponse) } const apiJson = await apiResponse.json() const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson) if (!validatedPackagesData.success) { getHotelFailCounter.add(1, { hotelId, error_type: "validation_error", error: JSON.stringify(validatedPackagesData.error), }) console.error( "api.hotels.packages validation error", JSON.stringify({ query: { hotelId, params }, error: validatedPackagesData.error, }) ) throw badRequestError() } getPackagesSuccessCounter.add(1, { hotelId, }) console.info( "api.hotels.packages success", JSON.stringify({ query: { hotelId, params: params } }) ) return validatedPackagesData.data }), breakfast: safeProtectedServiceProcedure .input(getBreakfastPackageInputSchema) .query(async function ({ ctx, input }) { const { lang } = ctx const apiLang = toApiLang(lang) const params = { Adults: input.adults, EndDate: dt(input.toDate).format("YYYY-MM-DD"), StartDate: dt(input.fromDate).format("YYYY-MM-DD"), language: apiLang, } const metricsData = { ...params, hotelId: input.hotelId } breakfastPackagesCounter.add(1, metricsData) console.info( "api.package.breakfast start", JSON.stringify({ query: metricsData }) ) const apiResponse = await api.get( api.endpoints.v1.Package.Breakfast.hotel(input.hotelId), { cache: undefined, headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, next: { revalidate: 60, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() breakfastPackagesFailCounter.add(1, { ...metricsData, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotelsAvailability error", JSON.stringify({ query: metricsData, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const breakfastPackages = breakfastPackagesSchema.safeParse(apiJson) if (!breakfastPackages.success) { hotelsAvailabilityFailCounter.add(1, { ...metricsData, error_type: "validation_error", error: JSON.stringify(breakfastPackages.error), }) console.error( "api.package.breakfast validation error", JSON.stringify({ query: metricsData, error: breakfastPackages.error, }) ) return null } breakfastPackagesSuccessCounter.add(1, metricsData) console.info( "api.package.breakfast success", JSON.stringify({ query: metricsData, }) ) if (ctx.session?.token) { const apiUser = await getVerifiedUser({ session: ctx.session }) if (apiUser && !("error" in apiUser)) { const user = parsedUser(apiUser.data, false) if ( user.membership && ["L6", "L7"].includes(user.membership.membershipLevel) ) { const originalBreakfastPackage = breakfastPackages.data.find( (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST ) const freeBreakfastPackage = breakfastPackages.data.find( (pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) if (freeBreakfastPackage && freeBreakfastPackage.localPrice) { if ( originalBreakfastPackage && originalBreakfastPackage.localPrice ) { freeBreakfastPackage.localPrice.price = originalBreakfastPackage.localPrice.price } return [freeBreakfastPackage] } } } } return breakfastPackages.data.filter( (pkg) => pkg.code !== BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) }), }), })