feat: update getHotel to use real hotel api endpoint, support for service tokens, type modifications

This commit is contained in:
Chuma McPhoy
2024-07-24 14:27:17 +02:00
parent 7393347f99
commit 1ff6cd267d
14 changed files with 195 additions and 89 deletions

View File

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

View File

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

View File

@@ -15,10 +15,15 @@ export default async function SelectRate({ params }: PageArgs<LangParams>) {
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<LangParams>) {
<div className={styles.page}>
<main className={styles.content}>
<div className={styles.hotelInfo}>
<HotelCard hotel={hotel} />
<HotelCard hotel={attributes} />
</div>
<RoomSelection rooms={rooms} />
<FlexibilitySelection />

View File

@@ -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 (
<div className={styles.pageContainer}>
<TabNavigation />
@@ -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}
/>
<SidePeeks />
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />

View File

@@ -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 (
<section className={styles.introSection}>
@@ -45,17 +49,19 @@ export default async function IntroSection({
<Title level="h2">{hotelName}</Title>
</div>
<Body color="textMediumContrast">{formattedLocationText}</Body>
<Link
className={styles.introLink}
target="_blank"
variant="icon"
textDecoration="underline"
color="peach80"
href={tripAdvisor.webUrl}
>
<TripAdvisorIcon color="peach80" />
{formattedTripAdvisorText}
</Link>
{hasTripAdvisorData && (
<Link
className={styles.introLink}
target="_blank"
variant="icon"
textDecoration="underline"
color="peach80"
href={tripAdvisor.webUrl}
>
<TripAdvisorIcon color="peach80" />
{formattedTripAdvisorText}
</Link>
)}
</div>
<div className={styles.subtitleContent}>
<Preamble>{hotelDescription}</Preamble>

6
env/server.ts vendored
View File

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

View File

@@ -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<typeof getHotelEndpoint>

View File

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

View File

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

View File

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

43
server/tokenManager.ts Normal file
View File

@@ -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<ServiceTokenResponse> {
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<string> {
if (userToken) {
return userToken
}
const { access_token } = await fetchServiceToken()
return access_token
}

View File

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

View File

@@ -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<typeof getHotelDataSchema>
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<HotelRatings>["tripAdvisor"]
| undefined
export type RoomData = z.infer<typeof RoomSchema>

6
types/tokens.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface ServiceTokenResponse {
access_token: string
scope?: string
token_type: string
expires_in: number
}