Merge branch 'develop' into feature/tracking

This commit is contained in:
Linus Flood
2024-10-15 07:46:54 +02:00
209 changed files with 4413 additions and 1046 deletions

View File

@@ -5,9 +5,10 @@ export const createBookingSchema = z
data: z.object({
attributes: z.object({
confirmationNumber: z.string(),
cancellationNumber: z.string().optional(),
cancellationNumber: z.string().nullable(),
reservationStatus: z.string(),
paymentUrl: z.string().optional(),
paymentUrl: z.string().nullable(),
metadata: z.any(), // TODO: define metadata schema (not sure what it does)
}),
type: z.string(),
id: z.string(),

View File

@@ -321,7 +321,7 @@ const validateInternalLink = z
const lang = node.system.locale
return {
url: originalUrl ?? removeMultipleSlashes(`/${lang}/${url}`),
url: originalUrl || removeMultipleSlashes(`/${lang}/${url}`),
title: node.title,
}
})
@@ -339,14 +339,18 @@ export const validateLinkItem = z
url: data.pageConnection?.url ?? data.link?.href ?? "",
title: data?.title ?? data.link?.title,
openInNewTab: data.open_in_new_tab,
isExternal: !!data.link?.href,
isExternal: !data.pageConnection?.url,
}
})
const validateLinks = z
.array(validateLinkItem)
.transform((data) => data.filter((item) => item.url))
export const validateSecondaryLinks = z.array(
z.object({
title: z.string(),
links: z.array(validateLinkItem),
links: validateLinks,
})
)
@@ -362,7 +366,7 @@ export const validateFooterConfigSchema = z
all_footer: z.object({
items: z.array(
z.object({
main_links: z.array(validateLinkItem),
main_links: validateLinks,
app_downloads: z.object({
title: z.string(),
links: validateLinksWithType,
@@ -371,7 +375,7 @@ export const validateFooterConfigSchema = z
social_media: z.object({
links: validateLinksWithType,
}),
tertiary_links: z.array(validateLinkItem),
tertiary_links: validateLinks,
})
),
}),

View File

@@ -1,5 +1,8 @@
import { Lang } from "@/constants/languages"
import { GetContentPage } from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
import {
GetContentPage,
GetContentPageBlocks,
} from "@/lib/graphql/Query/ContentPage/ContentPage.graphql"
import { request } from "@/lib/graphql/request"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
@@ -40,18 +43,38 @@ export const contentPageQueryRouter = router({
})
)
const response = await request<GetContentPageSchema>(
GetContentPage,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags,
},
}
)
const [mainResponse, blocksResponse] = await Promise.all([
request<GetContentPageSchema>(
GetContentPage,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags,
},
}
),
request<GetContentPageSchema>(
GetContentPageBlocks,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags,
},
}
),
])
const contentPage = contentPageSchema.safeParse(response.data)
const responseData = {
...mainResponse.data,
content_page: {
...mainResponse.data.content_page,
blocks: blocksResponse.data.content_page.blocks,
},
}
const contentPage = contentPageSchema.safeParse(responseData)
if (!contentPage.success) {
console.error(

View File

@@ -1,12 +1,16 @@
import { z } from "zod"
import * as pageLinks from "@/server/routers/contentstack/schemas/pageLinks"
import { tempImageVaultAssetSchema } from "../imageVault"
import { systemSchema } from "../system"
import { buttonSchema } from "./utils/buttonLinkSchema"
import { linkConnectionRefsSchema } from "./utils/linkConnection"
import { imageSchema } from "./image"
import { imageContainerSchema } from "./imageContainer"
import { BlocksEnums } from "@/types/enums/blocks"
import { CardsGridEnum } from "@/types/enums/cardsGrid"
import { CardsGridEnum, CardsGridLayoutEnum } from "@/types/enums/cardsGrid"
export const cardBlockSchema = z.object({
__typename: z.literal(CardsGridEnum.cards.Card),
@@ -49,9 +53,43 @@ export const teaserCardBlockSchema = z.object({
has_primary_button: z.boolean().default(false),
has_secondary_button: z.boolean().default(false),
has_sidepeek_button: z.boolean().default(false),
side_peek_button: z
sidepeek_button: z
.object({
title: z.string().optional().default(""),
call_to_action_text: z.string().optional().default(""),
})
.optional(),
sidepeek_content: z
.object({
heading: z.string(),
content: z.object({
json: z.any(),
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: z
.discriminatedUnion("__typename", [
imageContainerSchema,
imageSchema,
pageLinks.accountPageSchema,
pageLinks.contentPageSchema,
pageLinks.hotelPageSchema,
pageLinks.loyaltyPageSchema,
])
.transform((data) => {
const link = pageLinks.transform(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
has_primary_button: z.boolean().default(false),
primary_button: buttonSchema,
has_secondary_button: z.boolean().default(false),
secondary_button: buttonSchema,
})
.optional(),
system: systemSchema,
@@ -68,8 +106,9 @@ export function transformTeaserCardBlock(
secondaryButton: card.has_secondary_button
? card.secondary_button
: undefined,
sidePeekButton: card.has_sidepeek_button
? card.side_peek_button
sidePeekButton: card.has_sidepeek_button ? card.sidepeek_button : undefined,
sidePeekContent: card.has_sidepeek_button
? card.sidepeek_content
: undefined,
image: card.image,
system: card.system,
@@ -105,7 +144,7 @@ export const cardsGridSchema = z.object({
})
),
}),
layout: z.enum(["twoColumnGrid", "threeColumnGrid", "twoPlusOne"]),
layout: z.nativeEnum(CardsGridLayoutEnum),
preamble: z.string().optional().default(""),
theme: z.enum(["one", "two", "three"]).nullable(),
title: z.string().optional().default(""),

View File

@@ -9,49 +9,57 @@ export const shortcutsSchema = z.object({
.literal(BlocksEnums.block.Shortcuts)
.optional()
.default(BlocksEnums.block.Shortcuts),
shortcuts: z.object({
subtitle: z.string().nullable(),
title: z.string().nullable(),
shortcuts: z
.array(
z.object({
open_in_new_tab: z.boolean(),
text: z.string().optional().default(""),
linkConnection: z.object({
edges: z.array(
z.object({
node: z
.discriminatedUnion("__typename", [
pageLinks.accountPageSchema,
pageLinks.contentPageSchema,
pageLinks.loyaltyPageSchema,
])
.transform((data) => {
const link = pageLinks.transform(data)
if (link) {
return link
}
return data
}),
})
),
}),
})
)
.transform((data) => {
return data
.filter((node) => node.linkConnection.edges.length)
.map((node) => {
const link = node.linkConnection.edges[0].node
return {
openInNewTab: node.open_in_new_tab,
text: node.text,
title: link.title,
url: link.url,
}
shortcuts: z
.object({
subtitle: z.string().nullable(),
title: z.string().nullable(),
two_column_list: z.boolean().nullable().default(false),
shortcuts: z
.array(
z.object({
open_in_new_tab: z.boolean(),
text: z.string().optional().default(""),
linkConnection: z.object({
edges: z.array(
z.object({
node: z
.discriminatedUnion("__typename", [
pageLinks.accountPageSchema,
pageLinks.contentPageSchema,
pageLinks.loyaltyPageSchema,
])
.transform((data) => {
const link = pageLinks.transform(data)
if (link) {
return link
}
return data
}),
})
),
}),
})
}),
}),
)
.transform((data) => {
return data
.filter((node) => node.linkConnection.edges.length)
.map((node) => {
const link = node.linkConnection.edges[0].node
return {
openInNewTab: node.open_in_new_tab,
text: node.text,
title: link.title,
url: link.url,
}
})
}),
})
.transform(({ two_column_list, ...rest }) => {
return {
...rest,
hasTwoColumns: !!two_column_list,
}
}),
})
export const shortcutsRefsSchema = z.object({

View File

@@ -20,6 +20,7 @@ export const buttonSchema = z
pageLinks.accountPageSchema,
pageLinks.contentPageSchema,
pageLinks.loyaltyPageSchema,
pageLinks.hotelPageSchema,
])
.transform((data) => {
const link = pageLinks.transform(data)

View File

@@ -2,12 +2,11 @@ import { z } from "zod"
import { toLang } from "@/server/utils"
import { imageMetaDataSchema, imageSizesSchema } from "./schemas/image"
import { roomSchema } from "./schemas/room"
import { getPoiGroupByCategoryName } from "./utils"
import {
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
import { PointOfInterestCategoryNameEnum } from "@/types/hotel"
const ratingsSchema = z
.object({
@@ -121,20 +120,6 @@ const locationSchema = z.object({
longitude: z.number(),
})
const imageMetaDataSchema = z.object({
title: z.string(),
altText: z.string(),
altText_En: z.string(),
copyRight: z.string(),
})
const imageSizesSchema = z.object({
tiny: z.string(),
small: z.string(),
medium: z.string(),
large: z.string(),
})
const hotelContentSchema = z.object({
images: z.object({
metaData: imageMetaDataSchema,
@@ -230,7 +215,6 @@ const rewardNightSchema = z.object({
}),
})
const poiGroups = z.nativeEnum(PointOfInterestGroupEnum)
const poiCategoryNames = z.nativeEnum(PointOfInterestCategoryNameEnum)
export const pointOfInterestSchema = z
@@ -369,83 +353,6 @@ const relationshipsSchema = z.object({
}),
})
const roomContentSchema = z.object({
images: z.array(
z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
texts: z.object({
descriptions: z.object({
short: z.string(),
medium: z.string(),
}),
}),
})
const roomTypesSchema = z.object({
name: z.string(),
description: z.string(),
code: z.string(),
roomCount: z.number(),
mainBed: z.object({
type: z.string(),
description: z.string(),
widthRange: z.object({
min: z.number(),
max: z.number(),
}),
}),
fixedExtraBed: z.object({
type: z.string(),
description: z.string().optional(),
widthRange: z.object({
min: z.number(),
max: z.number(),
}),
}),
roomSize: z.object({
min: z.number(),
max: z.number(),
}),
occupancy: z.object({
total: z.number(),
adults: z.number(),
children: z.number(),
}),
isLackingCribs: z.boolean(),
isLackingExtraBeds: z.boolean(),
})
const roomFacilitiesSchema = z.object({
availableInAllRooms: z.boolean(),
name: z.string(),
isUniqueSellingPoint: z.boolean(),
sortOrder: z.number(),
})
export const roomSchema = z.object({
attributes: z.object({
name: z.string(),
sortOrder: z.number(),
content: roomContentSchema,
roomTypes: z.array(roomTypesSchema),
roomFacilities: z.array(roomFacilitiesSchema),
occupancy: z.object({
total: z.number(),
adults: z.number(),
children: z.number(),
}),
roomSize: z.object({
min: z.number(),
max: z.number(),
}),
}),
id: z.string(),
type: z.enum(["roomcategories"]),
})
const merchantInformationSchema = z.object({
webMerchantId: z.string(),
cards: z.record(z.string(), z.boolean()).transform((val) => {
@@ -572,46 +479,30 @@ export type HotelsAvailability = z.infer<typeof hotelsAvailabilitySchema>
export type HotelsAvailabilityPrices =
HotelsAvailability["data"][number]["attributes"]["bestPricePerNight"]
const priceSchema = z.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
})
export const productTypePriceSchema = z.object({
rateCode: z.string(),
rateType: z.string().optional(),
localPrice: priceSchema,
requestedPrice: priceSchema.optional(),
})
const productSchema = z.object({
productType: z.object({
public: z.object({
rateCode: z.string(),
rateType: z.string().optional(),
localPrice: z.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
}),
requestedPrice: z
.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
})
.optional(),
}),
member: z.object({
rateCode: z.string(),
rateType: z.string().optional(),
localPrice: z.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
}),
requestedPrice: z
.object({
pricePerNight: z.string(),
pricePerStay: z.string(),
currency: z.string(),
})
.optional(),
}),
public: productTypePriceSchema.optional(),
member: productTypePriceSchema.optional(),
}),
})
const roomConfigurationSchema = z.object({
status: z.string(),
bedType: z.string(),
// TODO: Remove the optional when the API change has been deployed
roomTypeCode: z.string().optional(),
roomType: z.string(),
roomsLeft: z.number(),
features: z.array(z.object({ inventory: z.number(), code: z.string() })),

View File

@@ -30,7 +30,6 @@ import {
getHotelsAvailabilitySchema,
getRatesSchema,
getRoomsAvailabilitySchema,
roomSchema,
} from "./output"
import tempRatesData from "./tempRatesData.json"
import {
@@ -190,35 +189,7 @@ export const hotelQueryRouter = router({
const images = extractHotelImages(hotelAttributes)
const roomCategories = included
? included
.filter((item) => item.type === "roomcategories")
.map((roomCategory) => {
const validatedRoom = roomSchema.safeParse(roomCategory)
if (!validatedRoom.success) {
getHotelFailCounter.add(1, {
hotelId,
lang,
include,
error_type: "validation_error",
error: JSON.stringify(
validatedRoom.error.issues.map(({ code, message }) => ({
code,
message,
}))
),
})
console.error(
"api.hotels.hotel validation error",
JSON.stringify({
query: { hotelId, params },
error: validatedRoom.error,
})
)
throw badRequestError()
}
return validatedRoom.data
})
? included.filter((item) => item.type === "roomcategories")
: []
const activities = contentstackData?.content
@@ -635,6 +606,7 @@ export const hotelQueryRouter = router({
query: { hotelId, params: params },
})
)
return validateHotelData.data
}),
}),

View File

@@ -0,0 +1,15 @@
import { z } from "zod"
export const imageSizesSchema = z.object({
tiny: z.string(),
small: z.string(),
medium: z.string(),
large: z.string(),
})
export const imageMetaDataSchema = z.object({
title: z.string(),
altText: z.string(),
altText_En: z.string(),
copyRight: z.string(),
})

View File

@@ -0,0 +1,93 @@
import { z } from "zod"
import { imageMetaDataSchema, imageSizesSchema } from "./image"
const roomContentSchema = z.object({
images: z.array(
z.object({
metaData: imageMetaDataSchema,
imageSizes: imageSizesSchema,
})
),
texts: z.object({
descriptions: z.object({
short: z.string(),
medium: z.string(),
}),
}),
})
const roomTypesSchema = z.object({
name: z.string(),
description: z.string(),
code: z.string(),
roomCount: z.number(),
mainBed: z.object({
type: z.string(),
description: z.string(),
widthRange: z.object({
min: z.number(),
max: z.number(),
}),
}),
fixedExtraBed: z.object({
type: z.string(),
description: z.string().optional(),
widthRange: z.object({
min: z.number(),
max: z.number(),
}),
}),
roomSize: z.object({
min: z.number(),
max: z.number(),
}),
occupancy: z.object({
total: z.number(),
adults: z.number(),
children: z.number(),
}),
isLackingCribs: z.boolean(),
isLackingExtraBeds: z.boolean(),
})
const roomFacilitiesSchema = z.object({
availableInAllRooms: z.boolean(),
name: z.string(),
isUniqueSellingPoint: z.boolean(),
sortOrder: z.number(),
})
export const roomSchema = z
.object({
attributes: z.object({
name: z.string(),
sortOrder: z.number(),
content: roomContentSchema,
roomTypes: z.array(roomTypesSchema),
roomFacilities: z.array(roomFacilitiesSchema),
occupancy: z.object({
total: z.number(),
adults: z.number(),
children: z.number(),
}),
roomSize: z.object({
min: z.number(),
max: z.number(),
}),
}),
id: z.string(),
type: z.enum(["roomcategories"]),
})
.transform((data) => {
return {
descriptions: data.attributes.content.texts.descriptions,
id: data.id,
images: data.attributes.content.images,
name: data.attributes.name,
occupancy: data.attributes.occupancy,
roomSize: data.attributes.roomSize,
sortOrder: data.attributes.sortOrder,
type: data.type,
}
})

View File

@@ -238,14 +238,10 @@ export const userQueryRouter = router({
const data = await getVerifiedUser({ session: ctx.session })
if (!data) {
if (!data || "error" in data) {
return null
}
if ("error" in data) {
return data
}
return parsedUser(data.data, true)
}),
name: safeProtectedProcedure.query(async function ({ ctx }) {

View File

@@ -21,6 +21,7 @@ const fetchServiceTokenFailCounter = meter.createCounter(
async function fetchServiceToken(scopes: string[]) {
fetchServiceTokenCounter.add(1)
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
method: "POST",
headers: {
@@ -36,52 +37,67 @@ async function fetchServiceToken(scopes: string[]) {
})
if (!response.ok) {
fetchServiceTokenFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: response.status,
statusText: response.statusText,
}),
})
throw new Error("Failed to obtain service token")
}
return response.json()
}
export async function getServiceToken(): Promise<ServiceTokenResponse> {
try {
const scopes = ["profile", "hotel", "booking"]
const tag = generateServiceTokenTag(scopes)
const getCachedJwt = unstable_cache(
async (scopes) => {
const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000
return { expiresAt, jwt }
},
[tag],
{ tags: [tag] }
)
const cachedJwt = await getCachedJwt(scopes)
if (cachedJwt.expiresAt < Date.now()) {
console.log(
"trpc.context.serviceToken: Service token expired, revalidating tag"
)
revalidateTag(tag)
console.log(
"trpc.context.serviceToken: Fetching new temporary service token."
)
fetchTempServiceTokenCounter.add(1)
const newToken = await fetchServiceToken(scopes)
return newToken
const text = await response.text()
const error = {
status: response.status,
statusText: response.statusText,
text,
}
return cachedJwt.jwt
} catch (error) {
console.error("Error fetching service token:", error)
throw error
fetchServiceTokenFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify(error),
})
console.error(
"fetchServiceToken error",
JSON.stringify({
query: {
grant_type: "client_credentials",
client_id: env.CURITY_CLIENT_ID_SERVICE,
scope: scopes.join(" "),
},
error,
})
)
throw new Error(
`[fetchServiceToken] Failed to obtain service token: ${JSON.stringify(error)}`
)
}
return response.json() as Promise<ServiceTokenResponse>
}
export async function getServiceToken() {
const scopes = ["profile", "hotel", "booking"]
const tag = generateServiceTokenTag(scopes)
const getCachedJwt = unstable_cache(
async (scopes) => {
const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000
return { expiresAt, jwt }
},
[tag],
{ tags: [tag] }
)
const cachedJwt = await getCachedJwt(scopes)
if (cachedJwt.expiresAt < Date.now()) {
console.log(
"trpc.context.serviceToken: Service token expired, revalidating tag"
)
revalidateTag(tag)
console.log(
"trpc.context.serviceToken: Fetching new temporary service token."
)
fetchTempServiceTokenCounter.add(1)
const newToken = await fetchServiceToken(scopes)
return newToken
}
return cachedJwt.jwt
}

View File

@@ -124,7 +124,7 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
export const serviceProcedure = t.procedure.use(async (opts) => {
const { access_token } = await getServiceToken()
if (!access_token) {
throw internalServerError(`Failed to obtain service token`)
throw internalServerError(`[serviceProcedure] No service token`)
}
return opts.next({
ctx: {
@@ -140,6 +140,22 @@ export const serverActionProcedure = t.procedure.experimental_caller(
})
)
export const serviceServerActionProcedure = serverActionProcedure.use(
async (opts) => {
const { access_token } = await getServiceToken()
if (!access_token) {
throw internalServerError(
"[serviceServerActionProcedure]: No service token"
)
}
return opts.next({
ctx: {
serviceToken: access_token,
},
})
}
)
export const protectedServerActionProcedure = serverActionProcedure.use(
async (opts) => {
const session = await opts.ctx.auth()