Merged in feature/redis (pull request #1478)

Distributed cache

* cache deleteKey now uses an options object instead of a lonely argument variable fuzzy

* merge

* remove debug logs and cleanup

* cleanup

* add fault handling

* add fault handling

* add pid when logging redis client creation

* add identifier when logging redis client creation

* cleanup

* feat: add redis-api as it's own app

* feature: use http wrapper for redis

* feat: add the possibility to fallback to unstable_cache

* Add error handling if redis cache is unresponsive

* add logging for unstable_cache

* merge

* don't cache errors

* fix: metadatabase on branchdeploys

* Handle when /en/destinations throws
add ErrorBoundary

* Add sentry-logging when ErrorBoundary catches exception

* Fix error handling for distributed cache

* cleanup code

* Added Application Insights back

* Update generateApiKeys script and remove duplicate

* Merge branch 'feature/redis' of bitbucket.org:scandic-swap/web into feature/redis

* merge


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-03-14 07:54:21 +00:00
committed by Linus Flood
parent a8304e543e
commit fa63b20ed0
141 changed files with 4404 additions and 1941 deletions

View File

@@ -25,7 +25,6 @@ import type {
GetAccountPageRefsSchema,
GetAccountPageSchema,
} from "@/types/trpc/routers/contentstack/accountPage"
import type { Lang } from "@/constants/languages"
const meter = metrics.getMeter("trpc.accountPage")
@@ -64,10 +63,8 @@ export const accountPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(lang, uid)],
},
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
@@ -128,10 +125,8 @@ export const accountPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags,
},
key: tags,
ttl: "max",
}
)

View File

@@ -20,6 +20,7 @@ import { notFound } from "@/server/errors/trpc"
import { contentstackBaseProcedure, router } from "@/server/trpc"
import { langInput } from "@/server/utils"
import { getCacheClient } from "@/services/dataCache"
import {
generateRefsResponseTag,
generateTag,
@@ -107,10 +108,8 @@ const getContactConfig = cache(async (lang: Lang) => {
locale: lang,
},
{
cache: "force-cache",
next: {
tags: [`${lang}:contact`],
},
key: `${lang}:contact`,
ttl: "max",
}
)
@@ -176,10 +175,8 @@ export const baseQueryRouter = router({
locale: lang,
},
{
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(lang, "header")],
},
key: generateRefsResponseTag(lang, "header"),
ttl: "max",
}
)
@@ -244,7 +241,7 @@ export const baseQueryRouter = router({
const response = await request<GetHeaderData>(
GetHeader,
{ locale: lang },
{ cache: "force-cache", next: { tags } }
{ key: tags, ttl: "max" }
)
if (!response.data) {
@@ -305,10 +302,8 @@ export const baseQueryRouter = router({
locale: input.lang,
},
{
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(input.lang, "current_header")],
},
key: generateRefsResponseTag(input.lang, "current_header"),
ttl: "max",
}
)
getCurrentHeaderCounter.add(1, { lang: input.lang })
@@ -326,10 +321,8 @@ export const baseQueryRouter = router({
GetCurrentHeader,
{ locale: input.lang },
{
cache: "force-cache",
next: {
tags: [generateTag(input.lang, currentHeaderUID)],
},
key: generateTag(input.lang, currentHeaderUID),
ttl: "max",
}
)
@@ -397,10 +390,8 @@ export const baseQueryRouter = router({
locale: input.lang,
},
{
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(input.lang, "current_footer")],
},
key: generateRefsResponseTag(input.lang, "current_footer"),
ttl: "max",
}
)
// There's currently no error handling/validation for the responseRef, should it be added?
@@ -422,10 +413,8 @@ export const baseQueryRouter = router({
locale: input.lang,
},
{
cache: "force-cache",
next: {
tags: [generateTag(input.lang, currentFooterUID)],
},
key: generateTag(input.lang, currentFooterUID),
ttl: "max",
}
)
@@ -486,10 +475,8 @@ export const baseQueryRouter = router({
locale: lang,
},
{
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(lang, "footer")],
},
key: generateRefsResponseTag(lang, "footer"),
ttl: "max",
}
)
@@ -563,10 +550,8 @@ export const baseQueryRouter = router({
locale: lang,
},
{
cache: "force-cache",
next: {
tags,
},
key: tags,
ttl: "max",
}
)
@@ -620,157 +605,164 @@ export const baseQueryRouter = router({
.input(langInput)
.query(async ({ input, ctx }) => {
const lang = input.lang ?? ctx.lang
getSiteConfigRefCounter.add(1, { lang })
console.info(
"contentstack.siteConfig.ref start",
JSON.stringify({ query: { lang } })
)
const responseRef = await request<GetSiteConfigRefData>(
GetSiteConfigRef,
{
locale: lang,
},
{
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(lang, "site_config")],
},
}
)
if (!responseRef.data) {
const notFoundError = notFound(responseRef)
getSiteConfigRefFailCounter.add(1, {
lang,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.siteConfig.refs not found error",
JSON.stringify({
query: {
lang,
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
generateRefsResponseTag(lang, "site_config", "root"),
async () => {
getSiteConfigRefCounter.add(1, { lang })
console.info(
"contentstack.siteConfig.ref start",
JSON.stringify({ query: { lang } })
)
const responseRef = await request<GetSiteConfigRefData>(
GetSiteConfigRef,
{
locale: lang,
},
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedSiteConfigRef = siteConfigRefSchema.safeParse(
responseRef.data
)
if (!validatedSiteConfigRef.success) {
getSiteConfigRefFailCounter.add(1, {
lang,
error_type: "validation_error",
error: JSON.stringify(validatedSiteConfigRef.error),
})
console.error(
"contentstack.siteConfig.refs validation error",
JSON.stringify({
query: {
lang,
},
error: validatedSiteConfigRef.error,
})
)
return null
}
const connections = getSiteConfigConnections(validatedSiteConfigRef.data)
const siteConfigUid = responseRef.data.all_site_config.items[0].system.uid
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, siteConfigUid),
].flat()
getSiteConfigRefSuccessCounter.add(1, { lang })
console.info(
"contentstack.siteConfig.refs success",
JSON.stringify({ query: { lang } })
)
getSiteConfigCounter.add(1, { lang })
console.info(
"contentstack.siteConfig start",
JSON.stringify({ query: { lang } })
)
const [siteConfigResponse, contactConfig] = await Promise.all([
request<GetSiteConfigData>(
GetSiteConfig,
{
locale: lang,
},
{
cache: "force-cache",
next: { tags },
}
),
getContactConfig(lang),
])
if (!siteConfigResponse.data) {
const notFoundError = notFound(siteConfigResponse)
getSiteConfigFailCounter.add(1, {
lang,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.siteConfig not found error",
JSON.stringify({
query: { lang },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedSiteConfig = siteConfigSchema.safeParse(
siteConfigResponse.data
)
if (!validatedSiteConfig.success) {
getSiteConfigFailCounter.add(1, {
lang,
error_type: "validation_error",
error: JSON.stringify(validatedSiteConfig.error),
})
console.error(
"contentstack.siteConfig validation error",
JSON.stringify({
query: { lang },
error: validatedSiteConfig.error,
})
)
return null
}
getSiteConfigSuccessCounter.add(1, { lang })
console.info(
"contentstack.siteConfig success",
JSON.stringify({ query: { lang } })
)
const { sitewideAlert } = validatedSiteConfig.data
return {
...validatedSiteConfig.data,
sitewideAlert: sitewideAlert
? {
...sitewideAlert,
phoneContact: contactConfig
? getAlertPhoneContactData(sitewideAlert, contactConfig)
: null,
{
key: generateRefsResponseTag(lang, "site_config"),
ttl: "max",
}
: null,
}
)
if (!responseRef.data) {
const notFoundError = notFound(responseRef)
getSiteConfigRefFailCounter.add(1, {
lang,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.siteConfig.refs not found error",
JSON.stringify({
query: {
lang,
},
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedSiteConfigRef = siteConfigRefSchema.safeParse(
responseRef.data
)
if (!validatedSiteConfigRef.success) {
getSiteConfigRefFailCounter.add(1, {
lang,
error_type: "validation_error",
error: JSON.stringify(validatedSiteConfigRef.error),
})
console.error(
"contentstack.siteConfig.refs validation error",
JSON.stringify({
query: {
lang,
},
error: validatedSiteConfigRef.error,
})
)
return null
}
const connections = getSiteConfigConnections(
validatedSiteConfigRef.data
)
const siteConfigUid =
responseRef.data.all_site_config.items[0].system.uid
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, siteConfigUid),
].flat()
getSiteConfigRefSuccessCounter.add(1, { lang })
console.info(
"contentstack.siteConfig.refs success",
JSON.stringify({ query: { lang } })
)
getSiteConfigCounter.add(1, { lang })
console.info(
"contentstack.siteConfig start",
JSON.stringify({ query: { lang } })
)
const [siteConfigResponse, contactConfig] = await Promise.all([
request<GetSiteConfigData>(
GetSiteConfig,
{
locale: lang,
},
{
key: tags,
ttl: "max",
}
),
getContactConfig(lang),
])
if (!siteConfigResponse.data) {
const notFoundError = notFound(siteConfigResponse)
getSiteConfigFailCounter.add(1, {
lang,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.siteConfig not found error",
JSON.stringify({
query: { lang },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedSiteConfig = siteConfigSchema.safeParse(
siteConfigResponse.data
)
if (!validatedSiteConfig.success) {
getSiteConfigFailCounter.add(1, {
lang,
error_type: "validation_error",
error: JSON.stringify(validatedSiteConfig.error),
})
console.error(
"contentstack.siteConfig validation error",
JSON.stringify({
query: { lang },
error: validatedSiteConfig.error,
})
)
return null
}
getSiteConfigSuccessCounter.add(1, { lang })
console.info(
"contentstack.siteConfig success",
JSON.stringify({ query: { lang } })
)
const { sitewideAlert } = validatedSiteConfig.data
return {
...validatedSiteConfig.data,
sitewideAlert: sitewideAlert
? {
...sitewideAlert,
phoneContact: contactConfig
? getAlertPhoneContactData(sitewideAlert, contactConfig)
: null,
}
: null,
}
},
"max"
)
}),
})

View File

@@ -37,6 +37,8 @@ import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { generateRefsResponseTag } from "@/utils/generateTag"
import { breadcrumbsRefsSchema, breadcrumbsSchema } from "./output"
import { getTags } from "./utils"
@@ -46,7 +48,6 @@ import type {
RawBreadcrumbsSchema,
} from "@/types/trpc/routers/contentstack/breadcrumbs"
import type { Lang } from "@/constants/languages"
import { generateRefsResponseTag } from "@/utils/generateTag"
const meter = metrics.getMeter("trpc.breadcrumbs")
@@ -89,8 +90,8 @@ const getBreadcrumbs = cache(async function fetchMemoizedBreadcrumbs<T>(
refQuery,
{ locale: lang, uid },
{
cache: `force-cache`,
next: { tags: [generateRefsResponseTag(lang, uid)] },
key: generateRefsResponseTag(lang, uid, "breadcrumbs"),
ttl: "max",
}
)
@@ -129,8 +130,8 @@ const getBreadcrumbs = cache(async function fetchMemoizedBreadcrumbs<T>(
query,
{ locale: lang, uid },
{
cache: "force-cache",
next: { tags },
key: tags,
ttl: "max",
}
)

View File

@@ -15,7 +15,6 @@ import {
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { GetCollectionPageSchema } from "@/types/trpc/routers/contentstack/collectionPage"
import type { Lang } from "@/constants/languages"
export const collectionPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
@@ -45,10 +44,8 @@ export const collectionPageQueryRouter = router({
GetCollectionPage,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags,
},
key: tags,
ttl: "max",
}
)

View File

@@ -1,20 +1,21 @@
import { metrics } from "@opentelemetry/api"
import { Lang } from "@/constants/languages"
import { GetCollectionPageRefs } from "@/lib/graphql/Query/CollectionPage/CollectionPage.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { getCacheClient } from "@/services/dataCache"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import { collectionPageRefsSchema } from "./output"
import { CollectionPageEnum } from "@/types/enums/collectionPage"
import { System } from "@/types/requests/system"
import {
import type { System } from "@/types/requests/system"
import type {
CollectionPageRefs,
GetCollectionPageRefsSchema,
} from "@/types/trpc/routers/contentstack/collectionPage"
import type { Lang } from "@/constants/languages"
const meter = metrics.getMeter("trpc.collectionPage")
// OpenTelemetry metrics: CollectionPage
@@ -41,15 +42,17 @@ export async function fetchCollectionPageRefs(lang: Lang, uid: string) {
query: { lang, uid },
})
)
const refsResponse = await request<GetCollectionPageRefsSchema>(
GetCollectionPageRefs,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
}
const cacheClient = await getCacheClient()
const cacheKey = generateTag(lang, uid)
const refsResponse = await cacheClient.cacheOrGet(
cacheKey,
async () =>
await request<GetCollectionPageRefsSchema>(GetCollectionPageRefs, {
locale: lang,
uid,
}),
"max"
)
if (!refsResponse.data) {

View File

@@ -17,7 +17,6 @@ import {
import type { TrackingSDKPageData } from "@/types/components/tracking"
import type { GetContentPageSchema } from "@/types/trpc/routers/contentstack/contentPage"
import type { Lang } from "@/constants/languages"
export const contentPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
@@ -43,33 +42,27 @@ export const contentPageQueryRouter = router({
{
document: GetContentPage,
variables: { locale: lang, uid },
options: {
cache: "force-cache",
next: {
tags,
},
cacheOptions: {
key: `${tags.join(",")}:contentPage`,
ttl: "max",
},
},
{
document: GetContentPageBlocksBatch1,
variables: { locale: lang, uid },
options: {
cache: "force-cache",
next: {
tags,
},
cacheOptions: {
key: `${tags.join(",")}:contentPageBlocksBatch1`,
ttl: "max",
},
},
{
document: GetContentPageBlocksBatch2,
variables: { locale: lang, uid },
options: {
cache: "force-cache",
next: {
tags,
},
cacheOptions: {
key: `${tags.join(",")}:contentPageBlocksBatch2`,
ttl: "max",
},
},
])

View File

@@ -49,21 +49,17 @@ export async function fetchContentPageRefs(lang: Lang, uid: string) {
{
document: GetContentPageRefs,
variables: { locale: lang, uid },
options: {
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
cacheOptions: {
key: generateTag(lang, uid),
ttl: "max",
},
},
{
document: GetContentPageBlocksRefs,
variables: { locale: lang, uid },
options: {
cache: "force-cache",
next: {
tags: [generateTag(lang, uid + 1)],
},
cacheOptions: {
key: generateTag(lang, uid + 1),
ttl: "max",
},
},
])

View File

@@ -46,10 +46,8 @@ export const destinationCityPageQueryRouter = router({
GetDestinationCityPageRefs,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
key: generateTag(lang, uid),
ttl: "max",
}
)
@@ -109,10 +107,8 @@ export const destinationCityPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags,
},
key: tags,
ttl: "max",
}
)
if (!response.data) {
@@ -153,7 +149,11 @@ export const destinationCityPageQueryRouter = router({
}
const destinationCityPage = validatedResponse.data.destination_city_page
const cityIdentifier = destinationCityPage.destination_settings.city
const city = await getCityByCityIdentifier(cityIdentifier, serviceToken)
const city = await getCityByCityIdentifier({
cityIdentifier,
lang,
serviceToken,
})
if (!city) {
getDestinationCityPageFailCounter.add(1, {

View File

@@ -14,8 +14,6 @@ import {
getCityPageUrlsSuccessCounter,
} from "./telemetry"
import type { BatchRequestDocument } from "graphql-request"
import { DestinationCityPageEnum } from "@/types/enums/destinationCityPage"
import type { System } from "@/types/requests/system"
import type {
@@ -78,17 +76,15 @@ export async function getCityPageCount(lang: Lang) {
"contentstack.cityPageCount start",
JSON.stringify({ query: { lang } })
)
const tags = [`${lang}:city_page_count`]
const response = await request<GetCityPageCountData>(
GetCityPageCount,
{
locale: lang,
},
{
cache: "force-cache",
next: {
tags,
},
key: `${lang}:city_page_count`,
ttl: "max",
}
)
if (!response.data) {
@@ -148,21 +144,14 @@ export async function getCityPageUrls(lang: Lang) {
// The `batchRequest` function is not working here, because the arrayMerge is
// used for other purposes.
const amountOfRequests = Math.ceil(count / 100)
const requests: (BatchRequestDocument & { options?: RequestInit })[] =
Array.from({ length: amountOfRequests }).map((_, i) => ({
document: GetCityPageUrls,
variables: { locale: lang, skip: i * 100 },
options: {
cache: "force-cache",
next: {
tags: [`${lang}:city_page_urls_batch_${i}`],
},
},
}))
const batchedResponse = await Promise.all(
requests.map((req) =>
request<GetCityPageUrlsData>(req.document, req.variables, req.options)
Array.from({ length: amountOfRequests }).map((_, i) =>
request<GetCityPageUrlsData>(
GetCityPageUrls,
{ locale: lang, skip: i * 100 },
{ key: `${lang}:city_page_urls_batch_${i}`, ttl: "max" }
)
)
)
const validatedResponse = batchedCityPageUrlsSchema.safeParse(batchedResponse)

View File

@@ -51,10 +51,8 @@ export const destinationCountryPageQueryRouter = router({
GetDestinationCountryPageRefs,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
key: generateTag(lang, uid),
ttl: "max",
}
)
@@ -114,10 +112,8 @@ export const destinationCountryPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags,
},
key: tags,
ttl: "max",
}
)
if (!response.data) {

View File

@@ -1,8 +1,6 @@
import { env } from "@/env/server"
import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql"
import { GetCountryPageUrls } from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPageUrl.graphql"
import { request } from "@/lib/graphql/request"
import { toApiLang } from "@/server/utils"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
@@ -20,7 +18,6 @@ import {
import { ApiCountry, type Country } from "@/types/enums/country"
import { DestinationCountryPageEnum } from "@/types/enums/destinationCountryPage"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { System } from "@/types/requests/system"
import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type {
@@ -85,7 +82,7 @@ export async function getCityListDataByCityIdentifier(
"contentstack.cityListData start",
JSON.stringify({ query: { lang, cityIdentifier } })
)
const tag = `${lang}:city_list_data:${cityIdentifier}`
const response = await request<GetDestinationCityListDataResponse>(
GetDestinationCityListData,
{
@@ -93,10 +90,8 @@ export async function getCityListDataByCityIdentifier(
cityIdentifier,
},
{
cache: "force-cache",
next: {
tags: [tag],
},
key: `${lang}:city_list_data:${cityIdentifier}`,
ttl: "max",
}
)
@@ -148,23 +143,12 @@ export async function getCityPages(
serviceToken: string,
country: Country
) {
const apiLang = toApiLang(lang)
const params = new URLSearchParams({
language: apiLang,
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const apiCountry = ApiCountry[lang][country]
const cities = await getCitiesByCountry([apiCountry], options, params, lang)
const cities = await getCitiesByCountry({
countries: [apiCountry],
lang,
serviceToken,
})
const publishedCities = cities[apiCountry].filter((city) => city.isPublished)
@@ -201,10 +185,8 @@ export async function getCountryPageUrls(lang: Lang) {
locale: lang,
},
{
cache: "force-cache",
next: {
tags: [tag],
},
key: tag,
ttl: "max",
}
)

View File

@@ -1,4 +1,3 @@
import { env } from "@/env/server"
import {
GetDestinationOverviewPage,
GetDestinationOverviewPageRefs,
@@ -10,9 +9,9 @@ import {
router,
serviceProcedure,
} from "@/server/trpc"
import { toApiLang } from "@/server/utils"
import { generateTag } from "@/utils/generateTag"
import { safeTry } from "@/utils/safeTry"
import {
getCitiesByCountry,
@@ -42,7 +41,6 @@ import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type {
GetDestinationOverviewPageData,
GetDestinationOverviewPageRefsSchema,
@@ -66,10 +64,8 @@ export const destinationOverviewPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
key: generateTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
@@ -133,10 +129,8 @@ export const destinationOverviewPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
key: generateTag(lang, uid),
ttl: "max",
}
)
if (!response.data) {
@@ -207,23 +201,11 @@ export const destinationOverviewPageQueryRouter = router({
}),
destinations: router({
get: serviceProcedure.query(async function ({ ctx }) {
const apiLang = toApiLang(ctx.lang)
const params = new URLSearchParams({
language: apiLang,
const countries = await getCountries({
lang: ctx.lang,
serviceToken: ctx.serviceToken,
})
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: env.CACHE_TIME_HOTELS,
},
}
const countries = await getCountries(options, params, ctx.lang)
const countryPages = await getCountryPageUrls(ctx.lang)
if (!countries) {
@@ -232,13 +214,12 @@ export const destinationOverviewPageQueryRouter = router({
const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry(
countryNames,
options,
params,
ctx.lang,
true
)
const citiesByCountry = await getCitiesByCountry({
lang: ctx.lang,
countries: countryNames,
serviceToken: ctx.serviceToken,
onlyPublished: true,
})
const cityPages = await getCityPageUrls(ctx.lang)
@@ -246,15 +227,11 @@ export const destinationOverviewPageQueryRouter = router({
Object.entries(citiesByCountry).map(async ([country, cities]) => {
const citiesWithHotelCount = await Promise.all(
cities.map(async (city) => {
const hotelIdsParams = new URLSearchParams({
language: apiLang,
city: city.id,
})
const hotels = await getHotelIdsByCityId(
city.id,
options,
hotelIdsParams
const [hotels] = await safeTry(
getHotelIdsByCityId({
cityId: city.id,
serviceToken: ctx.serviceToken,
})
)
const cityPage = cityPages.find(
@@ -268,7 +245,7 @@ export const destinationOverviewPageQueryRouter = router({
return {
id: city.id,
name: city.name,
hotelIds: hotels,
hotelIds: hotels || [],
hotelCount: hotels?.length ?? 0,
url: cityPage.url,
}

View File

@@ -31,10 +31,8 @@ export const hotelPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
key: generateTag(lang, uid),
ttl: "max",
}
)
if (!response.data) {

View File

@@ -23,8 +23,6 @@ import {
getHotelPageUrlsSuccessCounter,
} from "./telemetry"
import type { BatchRequestDocument } from "graphql-request"
import { HotelPageEnum } from "@/types/enums/hotelPage"
import type { System } from "@/types/requests/system"
import type {
@@ -48,10 +46,8 @@ export async function fetchHotelPageRefs(lang: Lang, uid: string) {
GetHotelPageRefs,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
key: generateTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
@@ -149,17 +145,14 @@ export async function getHotelPageCount(lang: Lang) {
"contentstack.hotelPageCount start",
JSON.stringify({ query: { lang } })
)
const tags = [`${lang}:hotel_page_count`]
const response = await request<GetHotelPageCountData>(
GetHotelPageCount,
{
locale: lang,
},
{
cache: "force-cache",
next: {
tags,
},
key: `${lang}:hotel_page_count`,
ttl: "max",
}
)
@@ -220,21 +213,18 @@ export async function getHotelPageUrls(lang: Lang) {
// The `batchRequest` function is not working here, because the arrayMerge is
// used for other purposes.
const amountOfRequests = Math.ceil(count / 100)
const requests: (BatchRequestDocument & { options?: RequestInit })[] =
Array.from({ length: amountOfRequests }).map((_, i) => ({
document: GetHotelPageUrls,
variables: { locale: lang, skip: i * 100 },
options: {
cache: "force-cache",
next: {
tags: [`${lang}:hotel_page_urls_batch_${i}`],
},
},
}))
const requests = Array.from({ length: amountOfRequests }).map((_, i) => ({
document: GetHotelPageUrls,
variables: { locale: lang, skip: i * 100 },
cacheKey: `${lang}:hotel_page_urls_batch_${i}`,
}))
const batchedResponse = await Promise.all(
requests.map((req) =>
request<GetHotelPageUrlsData>(req.document, req.variables, req.options)
request<GetHotelPageUrlsData>(req.document, req.variables, {
key: req.cacheKey,
ttl: "max",
})
)
)

View File

@@ -149,21 +149,17 @@ export async function getUrlsOfAllLanguages(
{
document: daDeEnDocument,
variables,
options: {
cache: "force-cache",
next: {
tags: tagsDaDeEn,
},
cacheOptions: {
ttl: "max",
key: tagsDaDeEn,
},
},
{
document: fiNoSvDocument,
variables,
options: {
cache: "force-cache",
next: {
tags: tagsFiNoSv,
},
cacheOptions: {
ttl: "max",
key: tagsFiNoSv,
},
},
])

View File

@@ -63,7 +63,7 @@ export const getAllLoyaltyLevels = cache(async (ctx: Context) => {
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
GetAllLoyaltyLevels,
{ lang: ctx.lang, level_ids: allLevelIds },
{ next: { tags }, cache: "force-cache" }
{ key: tags, ttl: "max" }
)
if (!loyaltyLevelsConfigResponse.data) {
@@ -113,10 +113,8 @@ export const getLoyaltyLevel = cache(
GetLoyaltyLevel,
{ lang: ctx.lang, level_id },
{
next: {
tags: [generateLoyaltyConfigTag(ctx.lang, "loyalty_level", level_id)],
},
cache: "force-cache",
key: generateLoyaltyConfigTag(ctx.lang, "loyalty_level", level_id),
ttl: "max",
}
)
if (

View File

@@ -25,7 +25,6 @@ import type {
GetLoyaltyPageRefsSchema,
GetLoyaltyPageSchema,
} from "@/types/trpc/routers/contentstack/loyaltyPage"
import type { Lang } from "@/constants/languages"
const meter = metrics.getMeter("trpc.loyaltyPage")
// OpenTelemetry metrics: LoyaltyPage
@@ -64,10 +63,8 @@ export const loyaltyPageQueryRouter = router({
GetLoyaltyPageRefs,
variables,
{
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(lang, uid)],
},
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
@@ -133,8 +130,8 @@ export const loyaltyPageQueryRouter = router({
GetLoyaltyPage,
variables,
{
cache: "force-cache",
next: { tags },
key: tags,
ttl: "max",
}
)

View File

@@ -64,10 +64,8 @@ const fetchMetadata = cache(async function fetchMemoizedMetadata<T>(
query,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid, affix)],
},
key: generateTag(lang, uid, affix),
ttl: "max",
}
)
if (!response.data) {

View File

@@ -1,5 +1,4 @@
import { ApiLang, type Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { type Lang } from "@/constants/languages"
import { getFiltersFromHotels } from "@/stores/destination-data/helper"
import { getIntl } from "@/i18n"
@@ -12,7 +11,6 @@ import {
} from "../../hotels/utils"
import { ApiCountry } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { RTETypeEnum } from "@/types/rte/enums"
import type {
MetadataInputSchema,
@@ -218,17 +216,19 @@ export async function getCityData(
const cityIdentifier = cities[0]
if (cityIdentifier) {
const cityData = await getCityByCityIdentifier(
const cityData = await getCityByCityIdentifier({
cityIdentifier,
serviceToken
)
serviceToken,
lang,
})
const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier,
serviceToken
)
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
let filterType
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
let filterType
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find(
@@ -264,28 +264,12 @@ export async function getCountryData(
const translatedCountry = ApiCountry[lang][country]
let filterType
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const hotelIdsParams = new URLSearchParams({
language: ApiLang.En,
const hotelIds = await getHotelIdsByCountry({
country,
serviceToken,
})
const hotelIds = await getHotelIdsByCountry(
country,
options,
hotelIdsParams
)
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
if (filter) {
const allFilters = getFiltersFromHotels(hotels)

View File

@@ -84,10 +84,8 @@ export const pageSettingsQueryRouter = router({
locale: lang,
},
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid, affix)],
},
key: generateTag(lang, uid, affix),
ttl: "max",
}
)

View File

@@ -32,10 +32,8 @@ export const getSasTierComparison = cache(async (ctx: Context) => {
GetAllSasTierComparison,
{ lang: ctx.lang },
{
next: {
tags: [tag],
},
cache: "force-cache",
key: tag,
ttl: "max",
}
)

View File

@@ -10,6 +10,8 @@ import {
} from "@/server/trpc"
import { langInput } from "@/server/utils"
import { getCacheClient } from "@/services/dataCache"
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import {
rewardsAllInput,
@@ -46,8 +48,6 @@ import {
getUnwrapSurpriseSuccessCounter,
} from "./utils"
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({
all: contentStackBaseWithServiceProcedure
.input(rewardsAllInput)
@@ -174,131 +174,139 @@ export const rewardQueryRouter = router({
? api.endpoints.v1.Profile.Reward.reward
: api.endpoints.v1.Profile.reward
const apiResponse = await api.get(endpoint, {
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: ONE_HOUR },
})
const cacheClient = await getCacheClient()
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCurrentRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
return cacheClient.cacheOrGet(
endpoint,
async () => {
const apiResponse = await api.get(endpoint, {
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
)
return null
}
const data = await apiResponse.json()
const validatedApiRewards = isNewEndpoint
? validateCategorizedRewardsSchema.safeParse(data)
: validateApiRewardSchema.safeParse(data)
if (!validatedApiRewards.success) {
getCurrentRewardFailCounter.add(1, {
locale: ctx.lang,
error_type: "validation_error",
error: JSON.stringify(validatedApiRewards.error),
})
console.error(validatedApiRewards.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { locale: ctx.lang },
error: validatedApiRewards.error,
})
)
return null
}
const rewardIds = getNonRedeemedRewardIds(validatedApiRewards.data)
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
return null
}
const wrappedSurprisesIds = validatedApiRewards.data
.filter(
(reward) =>
reward.type === "coupon" &&
reward.rewardType === "Surprise" &&
"coupon" in reward &&
reward.coupon.some(({ unwrapped }) => !unwrapped)
)
.map(({ rewardId }) => rewardId)
const rewards = cmsRewards
.filter(
(cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id)
)
.map((cmsReward) => {
const apiReward = validatedApiRewards.data.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)
const redeemableCoupons =
(apiReward &&
"coupon" in apiReward &&
apiReward.coupon.filter(
(coupon) => coupon.state !== "redeemed" && coupon.unwrapped
)) ||
[]
const firstRedeemableCouponToExpire = redeemableCoupons.reduce(
(earliest, coupon) => {
if (dt(coupon.expiresAt).isBefore(dt(earliest.expiresAt))) {
return coupon
}
return earliest
},
redeemableCoupons[0]
)?.couponCode
return {
...cmsReward,
id: apiReward?.id,
rewardType: apiReward?.rewardType,
redeemLocation: apiReward?.redeemLocation,
rewardTierLevel:
apiReward && "rewardTierLevel" in apiReward
? apiReward.rewardTierLevel
: undefined,
operaRewardId:
apiReward && "operaRewardId" in apiReward
? apiReward.operaRewardId
: "",
categories:
apiReward && "categories" in apiReward
? apiReward.categories || []
: [],
couponCode: firstRedeemableCouponToExpire,
coupons:
apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [],
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCurrentRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
})
getCurrentRewardSuccessCounter.add(1)
const data = await apiResponse.json()
return { rewards }
const validatedApiRewards = isNewEndpoint
? validateCategorizedRewardsSchema.safeParse(data)
: validateApiRewardSchema.safeParse(data)
if (!validatedApiRewards.success) {
getCurrentRewardFailCounter.add(1, {
locale: ctx.lang,
error_type: "validation_error",
error: JSON.stringify(validatedApiRewards.error),
})
console.error(validatedApiRewards.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { locale: ctx.lang },
error: validatedApiRewards.error,
})
)
return null
}
const rewardIds = getNonRedeemedRewardIds(validatedApiRewards.data)
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
return null
}
const wrappedSurprisesIds = validatedApiRewards.data
.filter(
(reward) =>
reward.type === "coupon" &&
reward.rewardType === "Surprise" &&
"coupon" in reward &&
reward.coupon.some(({ unwrapped }) => !unwrapped)
)
.map(({ rewardId }) => rewardId)
const rewards = cmsRewards
.filter(
(cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id)
)
.map((cmsReward) => {
const apiReward = validatedApiRewards.data.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)
const redeemableCoupons =
(apiReward &&
"coupon" in apiReward &&
apiReward.coupon.filter(
(coupon) => coupon.state !== "redeemed" && coupon.unwrapped
)) ||
[]
const firstRedeemableCouponToExpire = redeemableCoupons.reduce(
(earliest, coupon) => {
if (dt(coupon.expiresAt).isBefore(dt(earliest.expiresAt))) {
return coupon
}
return earliest
},
redeemableCoupons[0]
)?.couponCode
return {
...cmsReward,
id: apiReward?.id,
rewardType: apiReward?.rewardType,
redeemLocation: apiReward?.redeemLocation,
rewardTierLevel:
apiReward && "rewardTierLevel" in apiReward
? apiReward.rewardTierLevel
: undefined,
operaRewardId:
apiReward && "operaRewardId" in apiReward
? apiReward.operaRewardId
: "",
categories:
apiReward && "categories" in apiReward
? apiReward.categories || []
: [],
couponCode: firstRedeemableCouponToExpire,
coupons:
apiReward && "coupon" in apiReward
? apiReward.coupon || []
: [],
}
})
getCurrentRewardSuccessCounter.add(1)
return { rewards }
},
"1h"
)
}),
surprises: contentStackBaseWithProtectedProcedure
.input(langInput.optional()) // lang is required for client, but not for server
@@ -310,114 +318,120 @@ export const rewardQueryRouter = router({
? api.endpoints.v1.Profile.Reward.reward
: api.endpoints.v1.Profile.reward
const apiResponse = await api.get(endpoint, {
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: ONE_HOUR },
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCurrentRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
endpoint,
async () => {
const apiResponse = await api.get(endpoint, {
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
)
return null
}
const data = await apiResponse.json()
const validatedApiRewards = isNewEndpoint
? validateCategorizedRewardsSchema.safeParse(data)
: validateApiRewardSchema.safeParse(data)
if (!validatedApiRewards.success) {
getCurrentRewardFailCounter.add(1, {
locale: ctx.lang,
error_type: "validation_error",
error: JSON.stringify(validatedApiRewards.error),
})
console.error(validatedApiRewards.error)
console.error(
"contentstack.surprises validation error",
JSON.stringify({
query: { locale: ctx.lang },
error: validatedApiRewards.error,
})
)
return null
}
const rewardIds = validatedApiRewards.data
.map((reward) => reward?.rewardId)
.filter((rewardId): rewardId is string => !!rewardId)
.sort()
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
return null
}
getCurrentRewardSuccessCounter.add(1)
const surprises: Surprise[] = validatedApiRewards.data
// TODO: Add predicates once legacy endpoints are removed
.filter((reward) => {
if (reward?.rewardType !== "Surprise") {
return false
}
if (!("coupon" in reward)) {
return false
}
const unwrappedCoupons =
reward.coupon.filter((coupon) => !coupon.unwrapped) || []
if (unwrappedCoupons.length === 0) {
return false
}
return true
})
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
if (!reward) {
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCurrentRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
return {
...reward,
id: surprise.id,
rewardType: surprise.rewardType,
rewardTierLevel: undefined,
redeemLocation: surprise.redeemLocation,
categories:
"categories" in surprise ? surprise.categories || [] : [],
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
}
})
.flatMap((surprises) => (surprises ? [surprises] : []))
const data = await apiResponse.json()
const validatedApiRewards = isNewEndpoint
? validateCategorizedRewardsSchema.safeParse(data)
: validateApiRewardSchema.safeParse(data)
return surprises
if (!validatedApiRewards.success) {
getCurrentRewardFailCounter.add(1, {
locale: ctx.lang,
error_type: "validation_error",
error: JSON.stringify(validatedApiRewards.error),
})
console.error(validatedApiRewards.error)
console.error(
"contentstack.surprises validation error",
JSON.stringify({
query: { locale: ctx.lang },
error: validatedApiRewards.error,
})
)
return null
}
const rewardIds = validatedApiRewards.data
.map((reward) => reward?.rewardId)
.filter((rewardId): rewardId is string => !!rewardId)
.sort()
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
return null
}
getCurrentRewardSuccessCounter.add(1)
const surprises: Surprise[] = validatedApiRewards.data
// TODO: Add predicates once legacy endpoints are removed
.filter((reward) => {
if (reward?.rewardType !== "Surprise") {
return false
}
if (!("coupon" in reward)) {
return false
}
const unwrappedCoupons =
reward.coupon.filter((coupon) => !coupon.unwrapped) || []
if (unwrappedCoupons.length === 0) {
return false
}
return true
})
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
if (!reward) {
return null
}
return {
...reward,
id: surprise.id,
rewardType: surprise.rewardType,
rewardTierLevel: undefined,
redeemLocation: surprise.redeemLocation,
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
categories:
"categories" in surprise ? surprise.categories || [] : [],
}
})
.flatMap((surprises) => (surprises ? [surprises] : []))
return surprises
},
"1h"
)
}),
unwrap: protectedProcedure
.input(rewardsUpdateInput)

View File

@@ -1,5 +1,4 @@
import { metrics } from "@opentelemetry/api"
import { unstable_cache } from "next/cache"
import { env } from "@/env/server"
import * as api from "@/lib/api"
@@ -11,6 +10,7 @@ import {
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { getCacheClient } from "@/services/dataCache"
import { generateLoyaltyConfigTag, generateTag } from "@/utils/generateTag"
import {
@@ -85,8 +85,6 @@ export const getAllCMSRewardRefsSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.all-success"
)
const ONE_HOUR = 60 * 60
export function getUniqueRewardIds(rewardIds: string[]) {
const uniqueRewardIds = new Set(rewardIds)
return Array.from(uniqueRewardIds)
@@ -96,123 +94,133 @@ export function getUniqueRewardIds(rewardIds: string[]) {
* Uses the legacy profile/v1/Profile/tierRewards endpoint.
* TODO: Delete when the new endpoint is out in production.
*/
export const getAllCachedApiRewards = unstable_cache(
async function (token) {
const apiResponse = await api.get(api.endpoints.v1.Profile.tierRewards, {
headers: {
Authorization: `Bearer ${token}`,
},
})
export async function getAllCachedApiRewards(token: string) {
const cacheClient = await getCacheClient()
if (!apiResponse.ok) {
const text = await apiResponse.text()
getAllRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
return await cacheClient.cacheOrGet(
"getAllApiRewards",
async () => {
const apiResponse = await api.get(api.endpoints.v1.Profile.tierRewards, {
headers: {
Authorization: `Bearer ${token}`,
},
})
console.error(
"api.rewards.tierRewards error ",
JSON.stringify({
error: {
if (!apiResponse.ok) {
const text = await apiResponse.text()
getAllRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
}),
})
)
console.error(
"api.rewards.tierRewards error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
throw apiResponse
}
throw apiResponse
}
const data = await apiResponse.json()
const validatedApiTierRewards = validateApiTierRewardsSchema.safeParse(data)
const data = await apiResponse.json()
const validatedApiTierRewards =
validateApiTierRewardsSchema.safeParse(data)
if (!validatedApiTierRewards.success) {
getAllRewardFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedApiTierRewards.error),
})
console.error(validatedApiTierRewards.error)
console.error(
"api.rewards validation error",
JSON.stringify({
error: validatedApiTierRewards.error,
if (!validatedApiTierRewards.success) {
getAllRewardFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedApiTierRewards.error),
})
)
throw validatedApiTierRewards.error
}
console.error(validatedApiTierRewards.error)
console.error(
"api.rewards validation error",
JSON.stringify({
error: validatedApiTierRewards.error,
})
)
throw validatedApiTierRewards.error
}
return validatedApiTierRewards.data
},
["getAllApiRewards"],
{ revalidate: ONE_HOUR }
)
return validatedApiTierRewards.data
},
"1h"
)
}
/**
* Cached for 1 hour.
*/
export const getCachedAllTierRewards = unstable_cache(
async function (token) {
const apiResponse = await api.get(
api.endpoints.v1.Profile.Reward.allTiers,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
export async function getCachedAllTierRewards(token: string) {
const cacheClient = await getCacheClient()
if (!apiResponse.ok) {
const text = await apiResponse.text()
getAllRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.rewards.allTiers error ",
JSON.stringify({
error: {
return await cacheClient.cacheOrGet(
"getAllTierRewards",
async () => {
const apiResponse = await api.get(
api.endpoints.v1.Profile.Reward.allTiers,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
getAllRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
}),
})
)
console.error(
"api.rewards.allTiers error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
throw apiResponse
}
throw apiResponse
}
const data = await apiResponse.json()
const validatedApiAllTierRewards = validateApiAllTiersSchema.safeParse(data)
const data = await apiResponse.json()
const validatedApiAllTierRewards =
validateApiAllTiersSchema.safeParse(data)
if (!validatedApiAllTierRewards.success) {
getAllRewardFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedApiAllTierRewards.error),
})
console.error(validatedApiAllTierRewards.error)
console.error(
"api.rewards validation error",
JSON.stringify({
error: validatedApiAllTierRewards.error,
if (!validatedApiAllTierRewards.success) {
getAllRewardFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedApiAllTierRewards.error),
})
)
throw validatedApiAllTierRewards.error
}
console.error(validatedApiAllTierRewards.error)
console.error(
"api.rewards validation error",
JSON.stringify({
error: validatedApiAllTierRewards.error,
})
)
throw validatedApiAllTierRewards.error
}
return validatedApiAllTierRewards.data
},
["getApiAllTierRewards"],
{ revalidate: ONE_HOUR }
)
return validatedApiAllTierRewards.data
},
"1h"
)
}
export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
const tags = rewardIds.map((id) =>
@@ -235,10 +243,8 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
rewardIds,
},
{
cache: "force-cache",
next: {
tags: rewardIds.map((rewardId) => generateTag(lang, rewardId)),
},
key: rewardIds.map((rewardId) => generateTag(lang, rewardId)),
ttl: "max",
}
)
if (!refsResponse.data) {
@@ -292,7 +298,10 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
locale: lang,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
{
key: tags,
ttl: "max",
}
)
} else {
cmsRewardsResponse = await request<CmsRewardsResponse>(
@@ -301,7 +310,7 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
locale: lang,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
{ key: tags, ttl: "max" }
)
}

View File

@@ -46,10 +46,8 @@ export const startPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid)],
},
key: generateTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
@@ -118,10 +116,8 @@ export const startPageQueryRouter = router({
uid,
},
{
cache: "force-cache",
next: {
tags,
},
key: tags,
ttl: "max",
}
)

View File

@@ -269,6 +269,7 @@ export const countriesSchema = z.object({
}),
})
export type Cities = z.infer<typeof citiesSchema>
export const citiesSchema = z
.object({
data: z.array(citySchema),

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { z } from "zod"
import {
productTypePriceSchema,
productTypePointsSchema,
productTypePriceSchema,
} from "../productTypePrice"
export const productTypeSchema = z

View File

@@ -1,14 +1,16 @@
import deepmerge from "deepmerge"
import { unstable_cache } from "next/cache"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { toApiLang } from "@/server/utils"
import { getCacheClient } from "@/services/dataCache"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { metrics } from "./metrics"
import {
type Cities,
citiesByCountrySchema,
citiesSchema,
countriesSchema,
@@ -18,12 +20,10 @@ import {
import { getHotel } from "./query"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { HotelDataWithUrl } from "@/types/hotel"
import type {
CitiesGroupedByCountry,
CityLocation,
HotelLocation,
} from "@/types/trpc/routers/hotel/locations"
import type { Endpoint } from "@/lib/api/endpoints"
@@ -58,18 +58,21 @@ export function getPoiGroupByCategoryName(category: string | undefined) {
export const locationsAffix = "locations"
export const TWENTYFOUR_HOURS = 60 * 60 * 24
export async function getCity(
cityUrl: string,
options: RequestOptionsWithOutBody,
lang: Lang,
relationshipCity: HotelLocation["relationships"]["city"]
) {
return unstable_cache(
async function (locationCityUrl: string) {
const url = new URL(locationCityUrl)
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,
options,
{ headers: { Authorization: `Bearer ${serviceToken}` } },
url.searchParams
)
@@ -81,33 +84,44 @@ export async function getCity(
const city = citiesSchema.safeParse(cityJson)
if (!city.success) {
console.info(`Validation of city failed`)
console.info(`cityUrl: ${locationCityUrl}`)
console.info(`cityUrl: ${cityUrl}`)
console.error(city.error)
return null
}
return city.data
},
[cityUrl, `${lang}:${relationshipCity}`],
{ revalidate: TWENTYFOUR_HOURS }
)(cityUrl)
"1d"
)
}
export async function getCountries(
options: RequestOptionsWithOutBody,
params: URLSearchParams,
export async function getCountries({
lang,
serviceToken,
}: {
lang: Lang
) {
return unstable_cache(
async function (searchParams) {
serviceToken: string
}) {
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,
options,
searchParams
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
return null
throw new Error("Unable to fetch countries")
}
const countriesJson = await countryResponse.json()
@@ -120,114 +134,128 @@ export async function getCountries(
return countries.data
},
[`${lang}:${locationsAffix}:countries`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
)(params)
"1d"
)
}
export async function getCitiesByCountry(
countries: string[],
options: RequestOptionsWithOutBody,
params: URLSearchParams,
lang: Lang,
onlyPublished = false, // false by default as it might be used in other places
affix: string = locationsAffix
) {
return unstable_cache(
async function (
searchParams: URLSearchParams,
searchedCountries: string[]
) {
const citiesGroupedByCountry: CitiesGroupedByCountry = {}
await Promise.all(
searchedCountries.map(async (country) => {
export async function getCitiesByCountry({
countries,
lang,
onlyPublished = false,
affix = locationsAffix,
serviceToken,
}: {
countries: string[]
lang: Lang
onlyPublished?: boolean // false by default as it might be used in other places
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),
options,
searchParams
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
return null
throw new Error(`Unable to fetch cities by country ${country}`)
}
const countryJson = await countryResponse.json()
const citiesByCountry = citiesByCountrySchema.safeParse(countryJson)
if (!citiesByCountry.success) {
console.info(`Failed to validate Cities by Country payload`)
console.error(`Unable to parse cities by country ${country}`)
console.error(citiesByCountry.error)
return null
throw new Error(`Unable to parse cities by country ${country}`)
}
const cities = onlyPublished
? citiesByCountry.data.data.filter((city) => city.isPublished)
: citiesByCountry.data.data
citiesGroupedByCountry[country] = cities
return true
})
return { ...citiesByCountry.data, country }
},
"1d"
)
})
)
return citiesGroupedByCountry
},
[
`${lang}:${affix}:cities-by-country`,
params.toString(),
JSON.stringify(countries),
],
{ revalidate: TWENTYFOUR_HOURS }
)(params, countries)
const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({
...country,
data: onlyPublished
? country.data.filter((city) => city.isPublished)
: country.data,
}))
const groupedCitiesByCountry: CitiesGroupedByCountry =
filteredCitiesByCountries.reduce((acc, { country, data }) => {
acc[country] = data
return acc
}, {} as CitiesGroupedByCountry)
return groupedCitiesByCountry
}
export async function getLocations(
lang: Lang,
options: RequestOptionsWithOutBody,
params: URLSearchParams,
export async function getLocations({
lang,
citiesByCountry,
serviceToken,
}: {
lang: Lang
citiesByCountry: CitiesGroupedByCountry | null
) {
return unstable_cache(
async function (
searchParams: URLSearchParams,
groupedCitiesByCountry: CitiesGroupedByCountry | null
) {
serviceToken: string
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:locations`.toLowerCase(),
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.locations,
options,
searchParams
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const
throw new Error("unauthorized")
} else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const
throw new Error("forbidden")
}
return null
throw new Error("downstream error")
}
const apiJson = await apiResponse.json()
const verifiedLocations = locationsSchema.safeParse(apiJson)
if (!verifiedLocations.success) {
console.info(`Locations Verification Failed`)
console.error(verifiedLocations.error)
return null
throw new Error("Unable to parse locations")
}
return await Promise.all(
verifiedLocations.data.data.map(async (location) => {
if (location.type === "cities") {
if (groupedCitiesByCountry) {
const country = Object.keys(groupedCitiesByCountry).find(
(country) => {
if (
groupedCitiesByCountry[country].find(
(loc) => loc.name === location.name
)
) {
return true
}
return false
}
if (citiesByCountry) {
const country = Object.keys(citiesByCountry).find((country) =>
citiesByCountry[country].find(
(loc) => loc.name === location.name
)
)
if (country) {
return {
@@ -243,12 +271,10 @@ export async function getLocations(
}
} else if (location.type === "hotels") {
if (location.relationships.city?.url) {
const city = await getCity(
location.relationships.city.url,
options,
lang,
location.relationships.city
)
const city = await getCity({
cityUrl: location.relationships.city.url,
serviceToken,
})
if (city) {
return deepmerge(location, {
relationships: {
@@ -263,44 +289,51 @@ export async function getLocations(
})
)
},
[
`${lang}:${locationsAffix}`,
params.toString(),
JSON.stringify(citiesByCountry),
],
{ revalidate: TWENTYFOUR_HOURS }
)(params, citiesByCountry)
"1d"
)
}
export async function getHotelIdsByCityId(
cityId: string,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
metrics.hotelIds.counter.add(1, { params: params.toString() })
export async function getHotelIdsByCityId({
cityId,
serviceToken,
}: {
cityId: string
serviceToken: string
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${cityId}:hotelsByCityId`,
async () => {
const searchParams = new URLSearchParams({
city: cityId,
})
metrics.hotelIds.counter.add(1, { params: searchParams.toString() })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ params: params.toString() })
JSON.stringify({ params: searchParams.toString() })
)
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
searchParams
)
if (!apiResponse.ok) {
const responseMessage = await apiResponse.text()
metrics.hotelIds.fail.add(1, {
params: params.toString(),
params: searchParams.toString(),
error_type: "http_error",
error: responseMessage,
})
console.error(
"api.hotel.hotel-ids fetch error",
JSON.stringify({
params: params.toString(),
params: searchParams.toString(),
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
@@ -309,59 +342,73 @@ export async function getHotelIdsByCityId(
})
)
return []
throw new Error("Unable to fetch hotelIds by cityId")
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metrics.hotelIds.fail.add(1, {
params: params.toString(),
params: searchParams.toString(),
error_type: "validation_error",
error: JSON.stringify(validatedHotelIds.error),
})
console.error(
"api.hotel.hotel-ids validation error",
JSON.stringify({
params: params.toString(),
params: searchParams.toString(),
error: validatedHotelIds.error,
})
)
return []
throw new Error("Unable to parse data for hotelIds by cityId")
}
metrics.hotelIds.success.add(1, { cityId })
console.info(
"api.hotel.hotel-ids success",
JSON.stringify({
params: params.toString(),
params: searchParams.toString(),
response: validatedHotelIds.data,
})
)
return validatedHotelIds.data
},
[`hotelsByCityId`, params.toString()],
{ revalidate: env.CACHE_TIME_HOTELS }
)(params)
env.CACHE_TIME_HOTELS
)
}
export async function getHotelIdsByCountry(
country: string,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
export async function getHotelIdsByCountry({
country,
serviceToken,
}: {
country: string
serviceToken: string
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${country}:hotelsByCountry`,
async () => {
metrics.hotelIds.counter.add(1, { country })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ query: { country } })
)
const hotelIdsParams = new URLSearchParams({
country,
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
hotelIdsParams
)
if (!apiResponse.ok) {
@@ -383,7 +430,7 @@ export async function getHotelIdsByCountry(
})
)
return []
throw new Error("Unable to fetch hotelIds by country")
}
const apiJson = await apiResponse.json()
@@ -401,7 +448,7 @@ export async function getHotelIdsByCountry(
error: validatedHotelIds.error,
})
)
return []
throw new Error("Unable to parse hotelIds by country")
}
metrics.hotelIds.success.add(1, { country })
@@ -412,62 +459,45 @@ export async function getHotelIdsByCountry(
return validatedHotelIds.data
},
[`hotelsByCountry`, params.toString()],
{ revalidate: env.CACHE_TIME_HOTELS }
)(params)
env.CACHE_TIME_HOTELS
)
}
export async function getHotelIdsByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const apiLang = toApiLang(Lang.en)
const city = await getCityByCityIdentifier(cityIdentifier, serviceToken)
const city = await getCityByCityIdentifier({
cityIdentifier,
lang: Lang.en,
serviceToken,
})
if (!city) {
return []
}
const hotelIdsParams = new URLSearchParams({
language: apiLang,
city: city.id,
const hotelIds = await getHotelIdsByCityId({
cityId: city.id,
serviceToken,
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const hotelIds = await getHotelIdsByCityId(city.id, options, hotelIdsParams)
return hotelIds
}
export async function getCityByCityIdentifier(
cityIdentifier: string,
export async function getCityByCityIdentifier({
cityIdentifier,
lang,
serviceToken,
}: {
cityIdentifier: string
lang: Lang
serviceToken: string
) {
const lang = Lang.en
const apiLang = toApiLang(lang)
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const params = new URLSearchParams({
language: apiLang,
}) {
const locations = await getLocations({
lang,
citiesByCountry: null,
serviceToken,
})
const locations = await getLocations(lang, options, params, null)
if (!locations || "error" in locations) {
return null
}
@@ -479,11 +509,15 @@ export async function getCityByCityIdentifier(
return city ?? null
}
export async function getHotelsByHotelIds(
hotelIds: string[],
lang: Lang,
export async function getHotelsByHotelIds({
hotelIds,
lang,
serviceToken,
}: {
hotelIds: string[]
lang: Lang
serviceToken: string
) {
}) {
const hotelPages = await getHotelPageUrls(lang)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {

View File

@@ -1,5 +1,7 @@
import { publicProcedure, router } from "@/server/trpc"
import { getCacheClient } from "@/services/dataCache"
import { jobylonFeedSchema } from "./output"
import {
getJobylonFeedCounter,
@@ -29,66 +31,74 @@ export const jobylonQueryRouter = router({
JSON.stringify({ query: { url: urlString } })
)
const response = await fetch(url, {
cache: "force-cache",
next: {
revalidate: TWENTYFOUR_HOURS,
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
"jobylon:feed",
async () => {
const response = await fetch(url, {
cache: "no-cache",
})
if (!response.ok) {
const text = await response.text()
const error = {
status: response.status,
statusText: response.statusText,
text,
}
getJobylonFeedFailCounter.add(1, {
url: urlString,
error_type: "http_error",
error: JSON.stringify(error),
})
console.error(
"jobylon.feed error",
JSON.stringify({
query: { url: urlString },
error,
})
)
throw new Error(
`Failed to fetch Jobylon feed: ${JSON.stringify(error)}`
)
}
const responseJson = await response.json()
const validatedResponse = jobylonFeedSchema.safeParse(responseJson)
if (!validatedResponse.success) {
getJobylonFeedFailCounter.add(1, {
urlString,
error_type: "validation_error",
error: JSON.stringify(validatedResponse.error),
})
const errorData = JSON.stringify({
query: { url: urlString },
error: validatedResponse.error,
})
console.error("jobylon.feed error", errorData)
throw new Error(
`Failed to parse Jobylon feed: ${JSON.stringify(errorData)}`
)
}
getJobylonFeedSuccessCounter.add(1, {
url: urlString,
})
console.info(
"jobylon.feed success",
JSON.stringify({
query: { url: urlString },
})
)
return validatedResponse.data
},
})
if (!response.ok) {
const text = await response.text()
const error = {
status: response.status,
statusText: response.statusText,
text,
}
getJobylonFeedFailCounter.add(1, {
url: urlString,
error_type: "http_error",
error: JSON.stringify(error),
})
console.error(
"jobylon.feed error",
JSON.stringify({
query: { url: urlString },
error,
})
)
return null
}
const responseJson = await response.json()
const validatedResponse = jobylonFeedSchema.safeParse(responseJson)
if (!validatedResponse.success) {
getJobylonFeedFailCounter.add(1, {
urlString,
error_type: "validation_error",
error: JSON.stringify(validatedResponse.error),
})
console.error(
"jobylon.feed error",
JSON.stringify({
query: { url: urlString },
error: validatedResponse.error,
})
)
return null
}
getJobylonFeedSuccessCounter.add(1, {
url: urlString,
})
console.info(
"jobylon.feed success",
JSON.stringify({
query: { url: urlString },
})
"1d"
)
return validatedResponse.data
}),
}),
})

View File

@@ -641,11 +641,9 @@ export const userQueryRouter = router({
const apiResponse = await api.get(
api.endpoints.v1.Profile.Transaction.friendTransactions,
{
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: 30 * 60 * 1000 },
}
)