From 1ff6cd267db8d99f6731865f4ca2965926761ba3 Mon Sep 17 00:00:00 2001 From: Chuma McPhoy Date: Wed, 24 Jul 2024 14:27:17 +0200 Subject: [PATCH] feat: update getHotel to use real hotel api endpoint, support for service tokens, type modifications --- .env.local.example | 1 + .../hotelreservation/select-hotel/page.tsx | 12 ++- .../hotelreservation/select-rate/page.tsx | 11 ++- .../ContentType/HotelPage/HotelPage.tsx | 10 ++- .../HotelPage/IntroSection/index.tsx | 36 +++++---- env/server.ts | 6 ++ lib/api/endpoints.ts | 10 ++- lib/api/index.ts | 2 +- server/routers/hotels/output.ts | 52 ++++++------- server/routers/hotels/query.ts | 73 ++++++++++--------- server/tokenManager.ts | 43 +++++++++++ server/trpc.ts | 15 ++++ types/hotel.ts | 7 +- types/tokens.ts | 6 ++ 14 files changed, 195 insertions(+), 89 deletions(-) create mode 100644 server/tokenManager.ts create mode 100644 types/tokens.ts diff --git a/.env.local.example b/.env.local.example index 508c33acd..6d6af70cc 100644 --- a/.env.local.example +++ b/.env.local.example @@ -11,6 +11,7 @@ CURITY_CLIENT_SECRET_SERVICE="" CURITY_CLIENT_ID_USER="" CURITY_CLIENT_SECRET_USER="" CURITY_ISSUER_USER="https://testlogin.scandichotels.com" +CURITY_ISSUER_SERVICE="https://testlogin.scandichotels.com" CYPRESS_BASE_URL="http://localhost:3000" # See next.config.js for info DEPLOY_PRIME_URL="http://localhost:3000" diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx index b75ba0aaf..ca65e8a21 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx @@ -18,14 +18,20 @@ export default async function SelectHotelPage({ const intl = await getIntl() setLang(params.lang) - const { attributes } = await serverClient().hotel.getHotel({ - hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55", + const hotelData = await serverClient().hotel.getHotel({ + hotelId: "879", language: getLang(), }) + + if (!hotelData) { + return null + } + + const { attributes } = hotelData const hotels = [attributes] const hotelFilters = await serverClient().hotel.getFilters({ - hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55", + hotelId: "879", }) const tempSearchTerm = "Stockholm" diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx index 162d7dc8c..f0d8c8be3 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx @@ -15,10 +15,15 @@ export default async function SelectRate({ params }: PageArgs) { setLang(params.lang) // TODO: pass the correct hotel ID - const { attributes: hotel } = await serverClient().hotel.getHotel({ - hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55", + const hotel = await serverClient().hotel.getHotel({ + hotelId: "879", language: getLang(), }) + + if (!hotel) return null + + const { attributes } = hotel + const rooms = await serverClient().hotel.getRates({ // TODO: pass the correct hotel ID and all other parameters that should be included in the search hotelId: "1", @@ -28,7 +33,7 @@ export default async function SelectRate({ params }: PageArgs) {
- +
diff --git a/components/ContentType/HotelPage/HotelPage.tsx b/components/ContentType/HotelPage/HotelPage.tsx index d0a45a3c7..fd1ea59e6 100644 --- a/components/ContentType/HotelPage/HotelPage.tsx +++ b/components/ContentType/HotelPage/HotelPage.tsx @@ -18,12 +18,18 @@ export default async function HotelPage() { return null } const lang = getLang() - const { attributes, roomCategories } = await serverClient().hotel.getHotel({ + + const hotelData = await serverClient().hotel.getHotel({ hotelId: hotelPageIdentifierData.hotel_page_id, language: lang, include: ["RoomCategories"], }) + if (!hotelData) { + return null + } + const { attributes, roomCategories } = hotelData + return (
@@ -34,7 +40,7 @@ export default async function HotelPage() { hotelDescription={attributes.hotelContent.texts.descriptions.short} location={attributes.location} address={attributes.address} - tripAdvisor={attributes.ratings.tripAdvisor} + tripAdvisor={attributes.ratings?.tripAdvisor} /> diff --git a/components/ContentType/HotelPage/IntroSection/index.tsx b/components/ContentType/HotelPage/IntroSection/index.tsx index 4c98c4d4c..47553c61a 100644 --- a/components/ContentType/HotelPage/IntroSection/index.tsx +++ b/components/ContentType/HotelPage/IntroSection/index.tsx @@ -30,10 +30,14 @@ export default async function IntroSection({ ) const lang = getLang() const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})` - const formattedTripAdvisorText = intl.formatMessage( - { id: "Tripadvisor reviews" }, - { rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews } - ) + const hasTripAdvisorData = + tripAdvisor?.rating && tripAdvisor?.numberOfReviews && tripAdvisor?.webUrl + const formattedTripAdvisorText = hasTripAdvisorData + ? intl.formatMessage( + { id: "Tripadvisor reviews" }, + { rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews } + ) + : "" return (
@@ -45,17 +49,19 @@ export default async function IntroSection({ {hotelName}
{formattedLocationText} - - - {formattedTripAdvisorText} - + {hasTripAdvisorData && ( + + + {formattedTripAdvisorText} + + )}
{hotelDescription} diff --git a/env/server.ts b/env/server.ts index d4f5f84c2..58f98067b 100644 --- a/env/server.ts +++ b/env/server.ts @@ -21,8 +21,11 @@ export const env = createEnv({ CMS_PREVIEW_URL: z.string(), CMS_URL: z.string(), CURITY_CLIENT_ID_USER: z.string(), + CURITY_CLIENT_ID_SERVICE: z.string(), + CURITY_CLIENT_SECRET_SERVICE: z.string(), CURITY_CLIENT_SECRET_USER: z.string(), CURITY_ISSUER_USER: z.string(), + CURITY_ISSUER_SERVICE: z.string(), CYPRESS_BASE_URL: z.string().default("http://127.0.0.1:3000"), DESIGN_SYSTEM_ACCESS_TOKEN: z.string(), ENVTEST: z.string().optional(), @@ -76,8 +79,11 @@ export const env = createEnv({ CMS_PREVIEW_URL: process.env.CMS_PREVIEW_URL, CMS_URL: process.env.CMS_URL, CURITY_CLIENT_ID_USER: process.env.CURITY_CLIENT_ID_USER, + CURITY_CLIENT_ID_SERVICE: process.env.CURITY_CLIENT_ID_SERVICE, + CURITY_CLIENT_SECRET_SERVICE: process.env.CURITY_CLIENT_SECRET_SERVICE, CURITY_CLIENT_SECRET_USER: process.env.CURITY_CLIENT_SECRET_USER, CURITY_ISSUER_USER: process.env.CURITY_ISSUER_USER, + CURITY_ISSUER_SERVICE: process.env.CURITY_ISSUER_SERVICE, CYPRESS_BASE_URL: process.env.CYPRESS_TEST_URL, DESIGN_SYSTEM_ACCESS_TOKEN: process.env.DESIGN_SYSTEM_ACCESS_TOKEN, ENVTEST: process.env.ENVTEST, diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 739029fe7..2ea028311 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -12,8 +12,14 @@ export namespace endpoints { friendTransactions = "profile/v1/Transaction/friendTransactions", upcomingStays = "booking/v1/Stays/future", previousStays = "booking/v1/Stays/past", - hotel = "hotel/v1/Hotels", + hotels = "hotel/v1/Hotels", } } -export type Endpoint = endpoints.v0 | endpoints.v1 +export const getHotelEndpoint = (hotelId: string | number) => + `${endpoints.v1.hotels}/${hotelId}` as const + +export type Endpoint = + | endpoints.v0 + | endpoints.v1 + | ReturnType diff --git a/lib/api/index.ts b/lib/api/index.ts index db751428d..810ebe76e 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -9,7 +9,7 @@ import type { } from "@/types/fetch" import type { Endpoint } from "./endpoints" -export { endpoints } from "./endpoints" +export { endpoints, getHotelEndpoint } from "./endpoints" const defaultOptions: RequestInit = { cache: "no-store", diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index b580c341d..e94a81fff 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -2,29 +2,31 @@ import { z } from "zod" import { fromUppercaseToLangEnum } from "@/utils/languages" -const RatingsSchema = z.object({ - tripAdvisor: z.object({ - numberOfReviews: z.number(), - rating: z.number(), - ratingImageUrl: z.string(), - webUrl: z.string(), - awards: z.array( - z.object({ - displayName: z.string(), - images: z.object({ - small: z.string(), - medium: z.string(), - large: z.string(), - }), - }) - ), - reviews: z.object({ - widgetHtmlTagId: z.string(), - widgetScriptEmbedUrlIframe: z.string(), - widgetScriptEmbedUrlJavaScript: z.string(), +const RatingsSchema = z + .object({ + tripAdvisor: z.object({ + numberOfReviews: z.number(), + rating: z.number(), + ratingImageUrl: z.string(), + webUrl: z.string(), + awards: z.array( + z.object({ + displayName: z.string(), + images: z.object({ + small: z.string(), + medium: z.string(), + large: z.string(), + }), + }) + ), + reviews: z.object({ + widgetHtmlTagId: z.string(), + widgetScriptEmbedUrlIframe: z.string(), + widgetScriptEmbedUrlJavaScript: z.string(), + }), }), - }), -}) + }) + .optional() const AddressSchema = z.object({ streetAddress: z.string(), @@ -88,7 +90,7 @@ const InteriorSchema = z.object({ numberOfFloors: z.number(), numberOfRooms: z.object({ connected: z.number(), - forEllergics: z.number(), + forEllergics: z.number().optional(), forDisabled: z.number(), nonSmoking: z.number(), pet: z.number(), @@ -151,7 +153,7 @@ const DetailedFacilitySchema = z.object({ code: z.string().optional(), applyToAllHotels: z.boolean(), public: z.boolean(), - icon: z.number(), + icon: z.string(), //Check output. iconName: z.string().optional(), sortOrder: z.number(), }) @@ -302,7 +304,7 @@ const SocialMediaSchema = z.object({ const MetaSpecialAlertSchema = z.object({ type: z.string(), - description: z.string(), + description: z.string().optional(), displayInBookingFlow: z.boolean(), startDate: z.string(), endDate: z.string(), diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 6bdc325e0..1122cc9e7 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -1,6 +1,11 @@ import * as api from "@/lib/api" +import { getHotelEndpoint } from "@/lib/api/endpoints" import { badRequestError } from "@/server/errors/trpc" -import { publicProcedure, router } from "@/server/trpc" +import { + anonymousOrAuthProcedure, + publicProcedure, + router, +} from "@/server/trpc" import { getFiltersInputSchema, @@ -14,65 +19,61 @@ import { RoomSchema, } from "./output" import tempFilterData from "./tempFilterData.json" -import tempHotelData from "./tempHotelData.json" +// import tempHotelData from "./tempHotelData.json" import tempRatesData from "./tempRatesData.json" import { toApiLang } from "./utils" export const hotelQueryRouter = router({ - getHotel: publicProcedure + getHotel: anonymousOrAuthProcedure .input(getHotelInputSchema) .query(async ({ input, ctx }) => { const { hotelId, language, include } = input const params = new URLSearchParams() const apiLang = toApiLang(language) - params.set("hotelId", hotelId.toString()) params.set("language", apiLang) if (include) { params.set("include", include.join(",")) } - // TODO: Enable once we have authorized API access. - // const apiResponse = await api.get( - // api.endpoints.v1.hotel, - // {}, // Include token. - // params - // ) - // - // if (!apiResponse.ok) { - // console.info(`API Response Failed - Getting Hotel`) - // console.error(apiResponse) - // return null - // } - // const apiJson = await apiResponse.json() - - // NOTE: We can pass an "include" param to the hotel API to retrieve - // additional data for an individual hotel. - // Example "included" data can be found in our tempHotelData file. - const { included, ...apiJsonWithoutIncluded } = tempHotelData - const validatedHotelData = getHotelDataSchema.safeParse( - apiJsonWithoutIncluded + const authToken = await ctx.getToken() + const apiResponse = await api.get( + getHotelEndpoint(hotelId), + { + cache: "no-store", + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + params ) + if (!apiResponse.ok) { + console.info(`API Response Failed - Getting Hotel`) + console.error(apiResponse) + return null + } + const apiJson = await apiResponse.json() + const validatedHotelData = getHotelDataSchema.safeParse(apiJson) if (!validatedHotelData.success) { console.error(`Get Individual Hotel Data - Verified Data Error`) console.error(validatedHotelData.error) throw badRequestError() } + const included = validatedHotelData.data.included || [] + const roomCategories = included - ? included - .filter((item) => item.type === "roomcategories") - .map((roomCategory) => { - const validatedRoom = RoomSchema.safeParse(roomCategory) - if (!validatedRoom.success) { - console.error(`Get Room Category Data - Verified Data Error`) - console.error(validatedRoom.error) - throw badRequestError() - } - return validatedRoom.data - }) - : [] + .filter((item) => item.type === "roomcategories") + .map((roomCategory) => { + const validatedRoom = RoomSchema.safeParse(roomCategory) + if (!validatedRoom.success) { + console.error(`Get Room Category Data - Verified Data Error`) + console.error(validatedRoom.error) + throw badRequestError() + } + return validatedRoom.data + }) return { attributes: validatedHotelData.data.data.attributes, diff --git a/server/tokenManager.ts b/server/tokenManager.ts new file mode 100644 index 000000000..c1027c5ed --- /dev/null +++ b/server/tokenManager.ts @@ -0,0 +1,43 @@ +import { env } from "@/env/server" + +import { ServiceTokenResponse } from "@/types/tokens" + +const SERVICE_TOKEN_REVALIDATE_SECONDS = 3599 // 59 minutes and 59 seconds. + +async function fetchServiceToken(): Promise { + try { + const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: env.CURITY_CLIENT_ID_SERVICE, + client_secret: env.CURITY_CLIENT_SECRET_SERVICE, + }), + next: { + revalidate: SERVICE_TOKEN_REVALIDATE_SECONDS, + }, + }) + + if (!response.ok) { + throw new Error("Failed to obtain service token") + } + + return response.json() + } catch (error) { + console.error("Error fetching service token:", error) + throw error + } +} + +export async function getAuthToken(userToken?: string | null): Promise { + if (userToken) { + return userToken + } + + const { access_token } = await fetchServiceToken() + return access_token +} diff --git a/server/trpc.ts b/server/trpc.ts index 1caf32b53..1ff889f74 100644 --- a/server/trpc.ts +++ b/server/trpc.ts @@ -7,6 +7,7 @@ import { sessionExpiredError, unauthorizedError, } from "./errors/trpc" +import { getAuthToken } from "./tokenManager" import { transformer } from "./transformer" import { langInput } from "./utils" @@ -99,3 +100,17 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) { }, }) }) + +export const anonymousOrAuthProcedure = t.procedure.use(async function (opts) { + const session: Session | null = await opts.ctx.auth() + const userToken = session?.token?.access_token || null + + const getToken = async () => await getAuthToken(userToken) + + return opts.next({ + ctx: { + session, + getToken, + }, + }) +}) diff --git a/types/hotel.ts b/types/hotel.ts index 9d277d145..67af48b8a 100644 --- a/types/hotel.ts +++ b/types/hotel.ts @@ -1,13 +1,16 @@ import { z } from "zod" -import { getHotelDataSchema,RoomSchema } from "@/server/routers/hotels/output" +import { getHotelDataSchema, RoomSchema } from "@/server/routers/hotels/output" export type HotelData = z.infer export type Hotel = HotelData["data"]["attributes"] export type HotelAddress = HotelData["data"]["attributes"]["address"] export type HotelLocation = HotelData["data"]["attributes"]["location"] + +type HotelRatings = HotelData["data"]["attributes"]["ratings"] export type HotelTripAdvisor = - HotelData["data"]["attributes"]["ratings"]["tripAdvisor"] + | NonNullable["tripAdvisor"] + | undefined export type RoomData = z.infer diff --git a/types/tokens.ts b/types/tokens.ts new file mode 100644 index 000000000..355ab5ad5 --- /dev/null +++ b/types/tokens.ts @@ -0,0 +1,6 @@ +export interface ServiceTokenResponse { + access_token: string + scope?: string + token_type: string + expires_in: number +}