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
+2
View File
@@ -12,3 +12,5 @@ netlify.toml
package.json
package-lock.json
.gitignore
*.bicep
*.ico
+3 -1
View File
@@ -3,11 +3,13 @@
The web is using OAuth 2.0 to handle auth. We host our own instance of [Curity](https://curity.io), which is our identity and access management solution.
## Session management in Next
We use [Auth.js](https://authjs.dev) to handle everything regarding auth in the web. We use the JWT session strategy, which means that everything regarding the session is stored in a JWT, which is stored in the browser in an encrypted cookie.
## Keeping the access token alive
When the user performs a navigation the web app often does multiple requests to Next. If the access token has expired Next will do a request to Curity to renew the tokens. Since we only allow a single refresh token to be used only once only the first request will succeed and the following requests will fail.
To avoid that we have a component whose only purpose is to keep the access token alive. As long as no other request is happening at the same time this will work fine.
To avoid a session that keeps on refreshing forever, if the user have the page open in the background e.g., we have a timeout that stops the refreshing if the user is not active.
To avoid a session that keeps on refreshing forever, if the user have the page open in the background e.g., we have a timeout that stops the refreshing if the user is not active.
+13
View File
@@ -18,6 +18,19 @@ yarn dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### Caching
You have the choice to either use redis (via redis-api; a tiny http proxy) or in-memory/unstable_cache (depending on edge or node).
Setting `REDIS_API_HOST` will configure it to use the distributed cache, not providing it will fall back to in-memory/unstable_cache
When pointing to the azure hosted variant you also need to provide `REDIS_API_KEY`
Locally it's easiest is to spin everything up using docker/podman - `podman compose up` or `docker-compose up`
This will also spin up [Redis Insight ](https://redis.io/insight/) so that you can debug the cache.
- Navigate to `http://localhost:5540`
- Click **'Add Redis database'**
- Provide Connection URL `redis://redis:6379`
## Learn More
To learn more about Next.js, take a look at the following resources:
@@ -6,6 +6,7 @@ import { startTransition, useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { login } from "@/constants/routes/handleAuth"
import { env } from "@/env/client"
import { SESSION_EXPIRED } from "@/server/errors/trpc"
import styles from "./error.module.css"
@@ -61,6 +62,9 @@ export default function Error({
<section className={styles.layout}>
<div className={styles.content}>
{intl.formatMessage({ id: "Something went wrong!" })}
{env.NEXT_PUBLIC_NODE_ENV === "development" && (
<pre>{error.stack || error.message}</pre>
)}
</div>
</section>
)
@@ -25,8 +25,7 @@ export default async function CurrentContentPage({
{
locale: params.lang,
url: searchParams.uri,
},
{ cache: "no-store" }
}
)
if (!response.data?.all_current_blocks_page?.total) {
@@ -39,8 +38,7 @@ export default async function CurrentContentPage({
// This is currently to be considered a temporary solution to provide the tracking with a few values in english to align with existing reports
const pageDataForTracking = await request<TrackingData>(
GetCurrentBlockPageTrackingData,
{ uid: response.data.all_current_blocks_page.items[0].system.uid },
{ cache: "no-store" }
{ uid: response.data.all_current_blocks_page.items[0].system.uid }
)
const pageData = response.data.all_current_blocks_page.items[0]
@@ -6,6 +6,7 @@ import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { badRequest, internalServerError, notFound } from "@/server/errors/next"
import { getCacheClient } from "@/services/dataCache"
import { generateHotelUrlTag } from "@/utils/generateTag"
import type { NextRequest } from "next/server"
@@ -63,6 +64,8 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating hotel url tag: ${tag}`)
revalidateTag(tag)
const cacheClient = await getCacheClient()
await cacheClient.deleteKey(tag, { fuzzy: true })
return Response.json({ revalidated: true, now: Date.now() })
} catch (error) {
@@ -6,6 +6,7 @@ import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { badRequest, internalServerError, notFound } from "@/server/errors/next"
import { getCacheClient } from "@/services/dataCache"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import type { NextRequest } from "next/server"
@@ -82,6 +83,9 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating loyalty config tag: ${tag}`)
revalidateTag(tag)
const cacheClient = await getCacheClient()
await cacheClient.deleteKey(tag, { fuzzy: true })
return Response.json({ revalidated: true, now: Date.now() })
} catch (error) {
console.error("Failed to revalidate tag(s) for loyalty config")
@@ -4,6 +4,7 @@ import { headers } from "next/headers"
import { env } from "@/env/server"
import { badRequest, internalServerError } from "@/server/errors/next"
import { getCacheClient } from "@/services/dataCache"
import { generateTag } from "@/utils/generateTag"
import type { Lang } from "@/constants/languages"
@@ -27,23 +28,8 @@ export async function POST() {
const affix = headersList.get("x-affix")
const identifier = headersList.get("x-identifier")
const lang = headersList.get("x-lang")
if (lang && identifier) {
if (affix) {
const tag = generateTag(lang as Lang, identifier, affix)
console.info(
`Revalidated tag for [lang: ${lang}, identifier: ${identifier}, affix: ${affix}]`
)
console.info(`Tag: ${tag}`)
revalidateTag(tag)
} else {
const tag = generateTag(lang as Lang, identifier)
console.info(
`Revalidated tag for [lang: ${lang}, identifier: ${identifier}]`
)
console.info(`Tag: ${tag}`)
revalidateTag(tag)
}
} else {
if (!lang || !identifier) {
console.info(`Missing lang and/or identifier`)
console.info(`lang: ${lang}, identifier: ${identifier}`)
return badRequest({
@@ -52,6 +38,18 @@ export async function POST() {
})
}
const cacheClient = await getCacheClient()
const tag = generateTag(lang as Lang, identifier, affix)
console.info(
`Revalidated tag for [lang: ${lang}, identifier: ${identifier}${affix ? `, affix: ${affix}` : ""}]`
)
console.info(`Tag: ${tag}`)
revalidateTag(tag)
cacheClient.deleteKey(tag, { fuzzy: true })
return Response.json({ revalidated: true, now: Date.now() })
} catch (error) {
console.error("Failed to revalidate tag(s)")
@@ -10,6 +10,7 @@ import { languageSwitcherAffix } from "@/server/routers/contentstack/languageSwi
import { affix as metadataAffix } from "@/server/routers/contentstack/metadata/utils"
import { affix as pageSettingsAffix } from "@/server/routers/contentstack/pageSettings/utils"
import { getCacheClient } from "@/services/dataCache"
import {
generateRefsResponseTag,
generateRefTag,
@@ -87,23 +88,31 @@ export async function POST(request: NextRequest) {
)
const metadataTag = generateTag(entryLocale, entry.uid, metadataAffix)
const cacheClient = await getCacheClient()
console.info(`Revalidating refsTag: ${refsTag}`)
revalidateTag(refsTag)
await cacheClient.deleteKey(refsTag, { fuzzy: true })
console.info(`Revalidating refTag: ${refTag}`)
revalidateTag(refTag)
await cacheClient.deleteKey(refTag, { fuzzy: true })
console.info(`Revalidating tag: ${tag}`)
revalidateTag(tag)
await cacheClient.deleteKey(tag, { fuzzy: true })
console.info(`Revalidating language switcher tag: ${languageSwitcherTag}`)
revalidateTag(languageSwitcherTag)
await cacheClient.deleteKey(languageSwitcherTag, { fuzzy: true })
console.info(`Revalidating metadataTag: ${metadataTag}`)
revalidateTag(metadataTag)
await cacheClient.deleteKey(metadataTag, { fuzzy: true })
console.info(`Revalidating contentEntryTag: ${contentEntryTag}`)
revalidateTag(contentEntryTag)
await cacheClient.deleteKey(contentEntryTag, { fuzzy: true })
if (entry.breadcrumbs) {
const breadcrumbsRefsTag = generateRefsResponseTag(
@@ -119,9 +128,11 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating breadcrumbsRefsTag: ${breadcrumbsRefsTag}`)
revalidateTag(breadcrumbsRefsTag)
await cacheClient.deleteKey(breadcrumbsRefsTag, { fuzzy: true })
console.info(`Revalidating breadcrumbsTag: ${breadcrumbsTag}`)
revalidateTag(breadcrumbsTag)
await cacheClient.deleteKey(breadcrumbsTag, { fuzzy: true })
}
if (entry.page_settings) {
@@ -133,6 +144,7 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating pageSettingsTag: ${pageSettingsTag}`)
revalidateTag(pageSettingsTag)
await cacheClient.deleteKey(pageSettingsTag, { fuzzy: true })
}
return Response.json({ revalidated: true, now: Date.now() })
@@ -24,7 +24,7 @@ export function SessionRefresher() {
const session = useSession()
const pathname = usePathname()
const searchParams = useSearchParams()
const timeoutId = useRef<NodeJS.Timeout>()
const timeoutId = useRef<Timer>()
// Simple inactivity control. Reset when the URL changes.
const stopPreRefreshAt = useMemo(
@@ -15,7 +15,10 @@
}
.partial {
grid-template-columns: minmax(auto, 150px) min-content minmax(auto, 150px) auto;
grid-template-columns: minmax(auto, 150px) min-content minmax(
auto,
150px
) auto;
}
.icon {
@@ -6,6 +6,7 @@ import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
import { type PropsWithChildren, useEffect } from "react"
import { useIntl } from "react-intl"
import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary"
import { CloseLargeIcon, MinusIcon, PlusIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
@@ -84,7 +85,9 @@ export default function DynamicMap({
return (
<div className={styles.mapWrapper}>
<Map {...mapOptions}>{children}</Map>
<ErrorBoundary fallback={<h2>Unable to display map</h2>}>
<Map {...mapOptions}>{children}</Map>
</ErrorBoundary>
<div className={styles.ctaButtons}>
{onClose && (
<Button
@@ -69,8 +69,8 @@
@media screen and (min-width: 1367px) {
.pageContainer {
--hotel-page-scroll-margin-top: calc(
var(--hotel-page-navigation-height) + var(--booking-widget-desktop-height) +
var(--Spacing-x2)
var(--hotel-page-navigation-height) +
var(--booking-widget-desktop-height) + var(--Spacing-x2)
);
grid-template-areas:
"header mapContainer"
@@ -20,4 +20,4 @@
border-radius: 18px;
outline: 0 none;
padding: 5px 15px;
}
}
@@ -4,7 +4,9 @@ export default function OfflineBanner() {
return (
<div className={`${styles.banner} ${styles.hidden}`}>
You are offline, some content may be out of date.
<button className={styles.reloadBtn} type="button">Reload</button>
<button className={styles.reloadBtn} type="button">
Reload
</button>
</div>
)
}
@@ -12,17 +12,11 @@ export default function Breadcrumbs({
<ul className={styles.list}>
{parent ? (
<li className={styles.parent}>
<a href={parent.href}>
{parent.title}
</a>
<a href={parent.href}>{parent.title}</a>
</li>
) : null}
{breadcrumbs.map((breadcrumb) => (
<li
className={styles.li}
itemProp="breadcrumb"
key={breadcrumb.href}
>
<li className={styles.li} itemProp="breadcrumb" key={breadcrumb.href}>
<a className={styles.link} href={breadcrumb.href}>
{breadcrumb.title}
</a>
@@ -11,16 +11,12 @@ export default async function SubnavMobile({
<ul className="breadcrumb-list hidden-small hidden-medium hidden-large">
{parent ? (
<li className="breadcrumb-list__parent hidden-medium hidden-large">
<a href={parent.href}>
{parent.title}
</a>
<a href={parent.href}>{parent.title}</a>
</li>
) : null}
{breadcrumbs.map((breadcrumb) => (
<li className="breadcrumb-list__body" key={breadcrumb.href}>
<a href={breadcrumb.href}>
{breadcrumb.title}
</a>
<a href={breadcrumb.href}>{breadcrumb.title}</a>
</li>
))}
<li className="breadcrumb-list__body">
@@ -0,0 +1,40 @@
import * as Sentry from "@sentry/nextjs"
import React from "react"
type ErrorBoundaryProps = {
children: React.ReactNode
fallback?: React.ReactNode
}
type ErrorBoundaryState = { hasError: boolean; error?: Error }
class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo)
Sentry.captureException(error, { extra: { errorInfo } })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return <h2>Something went wrong.</h2>
}
return this.props.children
}
}
export default ErrorBoundary
@@ -41,4 +41,4 @@
gap: var(--Spacing-x2);
justify-self: flex-end;
}
}
}
@@ -14,7 +14,8 @@
}
.link:nth-of-type(1) .promo {
background-image: linear-gradient(
background-image:
linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
@@ -24,7 +25,8 @@
}
.link:nth-of-type(2) .promo {
background-image: linear-gradient(
background-image:
linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
@@ -152,9 +152,9 @@ export default function Details({ user }: DetailsProps) {
{isPaymentNext
? intl.formatMessage({ id: "Proceed to payment method" })
: intl.formatMessage(
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: roomNr + 1 }
)}
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: roomNr + 1 }
)}
</Button>
</footer>
<MemberPriceModal
@@ -2,7 +2,8 @@
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
@@ -19,7 +19,8 @@
}
.link .promo {
background-image: linear-gradient(
background-image:
linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
@@ -107,7 +107,8 @@
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
@@ -18,13 +18,11 @@ import type { Rate } from "@/types/components/hotelReservation/selectRate/select
export default function SelectedRoomPanel() {
const intl = useIntl()
const { isUserLoggedIn, roomCategories } = useRatesStore(
(state) => ({
isUserLoggedIn: state.isUserLoggedIn,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
})
)
const { isUserLoggedIn, roomCategories } = useRatesStore((state) => ({
isUserLoggedIn: state.isUserLoggedIn,
rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories,
}))
const {
actions: { modifyRate },
isMainRoom,
@@ -40,7 +40,7 @@ export function useRoomsAvailability(
toDateString: string,
lang: Lang,
childArray?: Child[],
bookingCode?: string,
bookingCode?: string
) {
const returnValue =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({
@@ -33,7 +33,8 @@
aspect-ratio: 16/9;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
+25
View File
@@ -0,0 +1,25 @@
services:
redis-api:
build:
context: ../redis-api
dockerfile: Dockerfile
ports:
- "3001:3001"
depends_on:
- redis
environment:
- REDIS_CONNECTION=redis:6379
- PRIMARY_API_KEY=
- SECONDARY_API_KEY=
- NODE_ENV=development
redis:
image: redis:6
ports:
- "6379:6379"
redisinsight:
image: redis/redisinsight:latest
ports:
- "5540:5540"
depends_on:
- redis
+12
View File
@@ -185,6 +185,13 @@ export const env = createEnv({
.number()
.default(10 * 60)
.transform((val) => (process.env.CMS_ENVIRONMENT === "test" ? 60 : val)),
REDIS_API_HOST: z.string().optional(),
REDIS_API_KEY: z.string().optional(),
BRANCH:
process.env.NODE_ENV !== "development"
? z.string()
: z.string().optional().default("dev"),
GIT_SHA: z.string().optional(),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -273,6 +280,11 @@ export const env = createEnv({
CACHE_TIME_HOTELDATA: process.env.CACHE_TIME_HOTELDATA,
CACHE_TIME_HOTELS: process.env.CACHE_TIME_HOTELS,
CACHE_TIME_CITY_SEARCH: process.env.CACHE_TIME_CITY_SEARCH,
REDIS_API_HOST: process.env.REDIS_API_HOST,
REDIS_API_KEY: process.env.REDIS_API_KEY,
BRANCH: process.env.BRANCH,
GIT_SHA: process.env.GIT_SHA,
},
})
+7 -9
View File
@@ -1,13 +1,14 @@
import * as Sentry from "@sentry/nextjs"
import { env } from "./env/server"
import { isEdge } from "./utils/isEdge"
export async function register() {
/*
Order matters!
Sentry hooks into OpenTelemetry, modifying its behavior.
Application Insights relies on OpenTelemetry exporters,
Application Insights relies on OpenTelemetry exporters,
and these may not work correctly if Sentry has already altered the instrumentation pipeline.
*/
await configureApplicationInsights()
@@ -45,12 +46,9 @@ async function configureApplicationInsights() {
}
async function configureSentry() {
switch (process.env.NEXT_RUNTIME) {
case "edge": {
await import("./sentry.edge.config")
}
case "nodejs": {
await import("./sentry.server.config")
}
if (isEdge) {
await import("./sentry.edge.config")
} else {
await import("./sentry.server.config")
}
}
+8 -2
View File
@@ -9,14 +9,20 @@ import { request } from "./request"
import type { BatchRequestDocument } from "graphql-request"
import type { Data } from "@/types/request"
import type { CacheTime } from "@/services/dataCache"
export async function batchRequest<T>(
queries: (BatchRequestDocument & { options?: RequestInit })[]
queries: (BatchRequestDocument & {
cacheOptions?: {
key: string | string[]
ttl: CacheTime
}
})[]
): Promise<Data<T>> {
try {
const response = await Promise.allSettled(
queries.map((query) =>
request<T>(query.document, query.variables, query.options)
request<T>(query.document, query.variables, query.cacheOptions)
)
)
+30 -8
View File
@@ -1,10 +1,12 @@
import fetchRetry from "fetch-retry"
import { GraphQLClient } from "graphql-request"
import { cache } from "react"
import { cache as reactCache } from "react"
import { env } from "@/env/server"
import { getPreviewHash, isPreviewByUid } from "@/lib/previewContext"
import { type CacheTime, getCacheClient } from "@/services/dataCache"
import { request as _request } from "./_request"
import type { DocumentNode } from "graphql"
@@ -14,7 +16,28 @@ import type { Data } from "@/types/request"
export async function request<T>(
query: string | DocumentNode,
variables?: Record<string, any>,
params?: RequestInit
cacheOptions?: {
key: string | string[]
ttl: CacheTime
}
): Promise<Data<T>> {
const doCall = () => internalRequest<T>(query, variables)
if (!cacheOptions) {
console.warn("[NO CACHE] for query", query)
return doCall()
}
const cacheKey: string = Array.isArray(cacheOptions.key)
? cacheOptions.key.join("_")
: cacheOptions.key
const _dataCache = await getCacheClient()
return _dataCache.cacheOrGet(cacheKey, doCall, cacheOptions.ttl)
}
function internalRequest<T>(
query: string | DocumentNode,
variables?: Record<string, any>
): Promise<Data<T>> {
const shouldUsePreview = variables?.uid
? isPreviewByUid(variables.uid)
@@ -24,7 +47,10 @@ export async function request<T>(
// Creating a new client for each request to avoid conflicting parameters
const client = new GraphQLClient(cmsUrl, {
fetch: cache(async function (url: URL | RequestInfo, params?: RequestInit) {
fetch: reactCache(async function (
url: URL | RequestInfo,
params?: RequestInit
) {
const wrappedFetch = fetchRetry(fetch, {
retries: 3,
retryDelay: function (attempt) {
@@ -38,16 +64,12 @@ export async function request<T>(
const mergedParams =
shouldUsePreview && previewHash
? {
...params,
headers: {
...params?.headers,
live_preview: previewHash,
preview_token: env.CMS_PREVIEW_TOKEN,
},
cache: undefined,
next: undefined,
}
: params
: {}
return _request(client, query, variables, mergedParams)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type NextMiddleware,NextResponse } from "next/server"
import { type NextMiddleware, NextResponse } from "next/server"
import { REDEMPTION, SEARCHTYPE } from "@/constants/booking"
import { login } from "@/constants/routes/handleAuth"
+1 -1
View File
@@ -1,4 +1,4 @@
import { type NextMiddleware,NextResponse } from "next/server"
import { type NextMiddleware, NextResponse } from "next/server"
import { Lang } from "@/constants/languages"
+1 -1
View File
@@ -40,4 +40,4 @@ schedule = "@daily"
[[headers]]
for = "/_next/static/*"
[headers.values]
cache-control = "public, max-age=31536001, immutable"
cache-control = "public, max-age=31536000, immutable"
+4
View File
@@ -13,6 +13,10 @@ jiti("./env/client")
/** @type {import('next').NextConfig} */
const nextConfig = {
env: {
BRANCH: process.env.BRANCH || "local",
GIT_SHA: process.env.COMMIT_REF || "",
},
poweredByHeader: false,
eslint: { ignoreDuringBuilds: true },
trailingSlash: false,
+1
View File
@@ -77,6 +77,7 @@
"ics": "^3.8.1",
"immer": "10.1.1",
"input-otp": "^1.4.2",
"ioredis": "^5.5.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"libphonenumber-js": "^1.10.60",
"nanoid": "^5.0.9",
@@ -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",
}
)
@@ -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"
)
}),
})
@@ -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",
}
)
@@ -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",
}
)
@@ -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) {
@@ -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",
},
},
])
@@ -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",
},
},
])
@@ -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, {
@@ -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)
@@ -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) {
@@ -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",
}
)
@@ -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,
}
@@ -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) {
@@ -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",
})
)
)
@@ -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,
},
},
])
@@ -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 (
@@ -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",
}
)
@@ -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) {
@@ -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)
@@ -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",
}
)
@@ -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",
}
)
@@ -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)
@@ -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" }
)
}
@@ -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",
}
)
@@ -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
@@ -1,8 +1,8 @@
import { z } from "zod"
import {
productTypePriceSchema,
productTypePointsSchema,
productTypePriceSchema,
} from "../productTypePrice"
export const productTypeSchema = z
+218 -184
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) => {
@@ -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
}),
}),
})
@@ -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 },
}
)
+43 -43
View File
@@ -1,9 +1,8 @@
import { metrics } from "@opentelemetry/api"
import { revalidateTag, unstable_cache } from "next/cache"
import { metrics, trace } from "@opentelemetry/api"
import { env } from "@/env/server"
import { generateServiceTokenTag } from "@/utils/generateTag"
import { getCacheClient } from "@/services/dataCache"
import type { ServiceTokenResponse } from "@/types/tokens"
@@ -12,13 +11,49 @@ const meter = metrics.getMeter("trpc.context.serviceToken")
const fetchServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-new-token"
)
const fetchTempServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-temporary"
)
const fetchServiceTokenFailCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-fail"
)
export async function getServiceToken() {
const tracer = trace.getTracer("getServiceToken")
return await tracer.startActiveSpan("getServiceToken", async () => {
let scopes: string[] = []
if (env.ENABLE_BOOKING_FLOW) {
scopes = ["profile", "hotel", "booking", "package", "availability"]
} else {
scopes = ["profile"]
}
const cacheKey = getServiceTokenCacheKey(scopes)
const cacheClient = await getCacheClient()
const token =
await cacheClient.get<Awaited<ReturnType<typeof getJwt>>>(cacheKey)
console.log("[DEBUG] getServiceToken", typeof token, token)
if (!token || token.expiresAt < Date.now()) {
return await tracer.startActiveSpan("fetch new token", async () => {
const newToken = await getJwt(scopes)
const relativeTime = (newToken.expiresAt - Date.now()) / 1000
await cacheClient.set(cacheKey, newToken, relativeTime)
return newToken.jwt
})
}
return token.jwt
})
}
async function getJwt(scopes: string[]) {
fetchServiceTokenCounter.add(1)
const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000
return { expiresAt, jwt }
}
async function fetchServiceToken(scopes: string[]) {
fetchServiceTokenCounter.add(1)
@@ -69,41 +104,6 @@ async function fetchServiceToken(scopes: string[]) {
return response.json() as Promise<ServiceTokenResponse>
}
export async function getServiceToken() {
let scopes: string[] = []
if (env.ENABLE_BOOKING_FLOW) {
scopes = ["profile", "hotel", "booking", "package", "availability"]
} else {
scopes = ["profile"]
}
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
function getServiceTokenCacheKey(scopes: string[]): string {
return `serviceToken:${scopes.join(",")}`
}
+3 -1
View File
@@ -138,7 +138,9 @@ export const safeProtectedProcedure = baseProcedure.use(async function (opts) {
})
export const serviceProcedure = baseProcedure.use(async (opts) => {
const { access_token } = await getServiceToken()
const token = await getServiceToken()
console.log("[DEBUG] token", typeof token, token)
const { access_token } = token
if (!access_token) {
throw internalServerError(`[serviceProcedure] No service token`)
}
@@ -1,50 +1,23 @@
import { getCacheClient } from "@/services/dataCache"
import { resolve as resolveEntry } from "@/utils/entry"
import type { Lang } from "@/constants/languages"
const entryResponseCache: Map<
string,
{
contentType: string | null
uid: string | null
expiresAt: number
}
> = new Map()
let size: number = 0
export const fetchAndCacheEntry = async (path: string, lang: Lang) => {
const cacheKey = `${path + lang}`
const cachedResponse = entryResponseCache.get(cacheKey)
path = path || "/"
const cacheKey = `${lang}:resolveentry:${path}`
const cache = await getCacheClient()
if (cachedResponse && cachedResponse.expiresAt > Date.now() / 1000) {
console.log("[CMS MIDDLEWARE]: CACHE HIT")
return cachedResponse
}
return cache.cacheOrGet(
cacheKey,
async () => {
const { contentType, uid } = await resolveEntry(path, lang)
if (cachedResponse && cachedResponse.expiresAt < Date.now() / 1000) {
console.log("[CMS MIDDLEWARE]: CACHE STALE")
size -= JSON.stringify(cachedResponse).length
entryResponseCache.delete(cacheKey)
} else {
console.log("[CMS MIDDLEWARE]: CACHE MISS")
}
const { contentType, uid } = await resolveEntry(path, lang)
let expiresAt = Date.now() / 1000
if (!contentType || !uid) {
expiresAt += 600
} else {
expiresAt += 3600 * 12
}
const entryCache = { contentType, uid, expiresAt }
size += JSON.stringify(entryCache).length
console.log("[CMS MIDDLEWARE] Adding to cache", entryCache)
console.log("[CMS MIDDLEWARE] Cache size (total)", size)
entryResponseCache.set(cacheKey, entryCache)
return {
contentType,
uid,
}
return {
contentType,
uid,
}
},
"1d"
)
}
@@ -0,0 +1,96 @@
const ONE_HOUR_IN_SECONDS = 3_600 as const
const ONE_DAY_IN_SECONDS = 86_400 as const
export const namedCacheTimeMap: Record<NamedCacheTimes, number> = {
"no cache": 0,
"1m": 60,
"5m": 300,
"10m": 600,
"1h": ONE_HOUR_IN_SECONDS,
"3h": ONE_HOUR_IN_SECONDS * 3,
"6h": ONE_HOUR_IN_SECONDS * 6,
"1d": ONE_DAY_IN_SECONDS,
"3d": ONE_DAY_IN_SECONDS * 3,
max: ONE_DAY_IN_SECONDS * 30,
} as const
export const namedCacheTimes = [
"no cache",
"1m",
"5m",
"10m",
"1h",
"3h",
"6h",
"1d",
"3d",
"max",
] as const
export type NamedCacheTimes = (typeof namedCacheTimes)[number]
/**
* Retrieves the cache time in seconds based on the given cache time.
* @param cacheTime - The time value to determine, either a named cache time or a number of seconds.
* @returns The cache time in seconds.
*/
export const getCacheTimeInSeconds = (cacheTime: CacheTime): number => {
if (typeof cacheTime === "number") {
if (cacheTime < 0 || !Number.isInteger(cacheTime)) {
return 0
}
return cacheTime
}
return namedCacheTimeMap[cacheTime] ?? 0
}
export type CacheTime = NamedCacheTimes | number
export type DataCache = {
/**
* Type of cache
*/
type: "edge" | "redis" | "in-memory" | "unstable-cache"
/**
* Helper function that retrieves from the cache if it exists, otherwise calls the callback and caches the result.
* If the call fails, the cache is not updated.
* @param key The cache key
* @param getDataFromSource An async function that provides a value to cache
* @param ttl Time to live, either a named cache time or a number of seconds
* @returns The cached value or the result from the callback
*/
cacheOrGet: <T>(
key: string | string[],
getDataFromSource: () => Promise<T>,
ttl: CacheTime
) => Promise<T>
/**
* Get a value from the cache, if it exists
* @see `cacheOrGet` for a more convenient way to cache values
* @param key The cache key to retrieve the value for
* @returns The cached value or undefined if not found
*/
get: <T>(key: string) => Promise<T | undefined>
/**
* Sets a value in the cache.
* @see `cacheOrGet` for a more convenient way to cache values
* @param key CacheKey to set
* @param obj Value to be cached
* @param ttl Time to live, either a named cache time or a number of seconds
* @returns A promise that resolves when the value has been cached
*/
set: <T>(key: string, obj: T, ttl: CacheTime) => Promise<void>
/**
* Deletes a key from the cache
* @param key CacheKey to delete
* @param fuzzy If true, does a wildcard delete. *key*
* @returns
*/
deleteKey: (key: string, opts?: { fuzzy?: boolean }) => Promise<void>
}
@@ -0,0 +1,29 @@
import { type CacheTime, type DataCache } from "@/services/dataCache/Cache"
import { cacheLogger } from "../logger"
import { generateCacheKey } from "./generateCacheKey"
import { get } from "./get"
import { set } from "./set"
export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
key: string | string[],
callback: () => Promise<T>,
ttl: CacheTime
) => {
const cacheKey = generateCacheKey(key)
const cachedValue = await get<T>(cacheKey)
if (!cachedValue) {
const perf = performance.now()
const data = await callback()
cacheLogger.debug(
`Getting data '${cacheKey}' took ${(performance.now() - perf).toFixed(2)}ms`
)
await set<T>(cacheKey, data, ttl)
return data
}
return cachedValue
}
@@ -0,0 +1,19 @@
import { env } from "@/env/server"
import { cacheOrGet } from "./cacheOrGet"
import { deleteKey } from "./deleteKey"
import { get } from "./get"
import { set } from "./set"
import type { DataCache } from "@/services/dataCache/Cache"
export const API_KEY = env.REDIS_API_KEY ?? ""
export async function createDistributedCache(): Promise<DataCache> {
return {
type: "redis",
get,
set,
cacheOrGet,
deleteKey,
} satisfies DataCache
}
@@ -0,0 +1,38 @@
import * as Sentry from "@sentry/nextjs"
import { cacheLogger } from "../logger"
import { API_KEY } from "./client"
import { getCacheEndpoint } from "./endpoints"
export async function deleteKey<T>(key: string) {
const perf = performance.now()
const response = await fetch(getCacheEndpoint(key), {
method: "DELETE",
cache: "no-cache",
headers: {
"x-api-key": API_KEY,
},
})
if (!response.ok) {
if (response.status !== 404) {
Sentry.captureMessage("Unable to DELETE cachekey", {
level: "error",
extra: {
cacheKey: key,
statusCode: response?.status,
statusText: response?.statusText,
},
})
}
return undefined
}
const data = (await response.json()) as { data: T }
cacheLogger.debug(
`Delete '${key}' took ${(performance.now() - perf).toFixed(2)}ms`
)
return data.data
}
@@ -0,0 +1,24 @@
import { env } from "@/env/server"
import { generateCacheKey } from "./generateCacheKey"
export function getCacheEndpoint(key: string) {
if (!env.REDIS_API_HOST) {
throw new Error("REDIS_API_HOST is not set")
}
const url = new URL(`/api/cache`, env.REDIS_API_HOST)
url.searchParams.set("key", encodeURIComponent(generateCacheKey(key)))
return url
}
export function getClearCacheEndpoint() {
if (!env.REDIS_API_HOST) {
throw new Error("REDIS_API_HOST is not set")
}
const url = new URL(`/api/cache/clear`, env.REDIS_API_HOST)
return url
}
@@ -0,0 +1,20 @@
import { env } from "@/env/server"
export function generateCacheKey(key: string | string[]): string {
const prefix = getPrefix()
key = Array.isArray(key) ? key.join("_") : key
return `${prefix ? `${prefix}:` : ""}${key}`
}
function getPrefix(): string {
if (process.env.NODE_ENV === "development") {
const devPrefix = process.env.USER || process.env.USERNAME || "dev"
return `${devPrefix}`
}
const branch = env.BRANCH.trim()
const gitSha = env.GIT_SHA?.trim().substring(0, 7)
return `${branch}:${gitSha}`
}
@@ -0,0 +1,62 @@
import * as Sentry from "@sentry/nextjs"
import { safeTry } from "@/utils/safeTry"
import { cacheLogger } from "../logger"
import { API_KEY } from "./client"
import { deleteKey } from "./deleteKey"
import { getCacheEndpoint } from "./endpoints"
export async function get<T>(key: string) {
const perf = performance.now()
const [response, error] = await safeTry(
fetch(getCacheEndpoint(key), {
method: "GET",
cache: "no-cache",
headers: {
"x-api-key": API_KEY,
},
})
)
if (!response || error || !response.ok) {
if (response?.status === 404) {
cacheLogger.debug(
`Miss '${key}' took ${(performance.now() - perf).toFixed(2)}ms`
)
return undefined
}
Sentry.captureMessage("Unable to GET cachekey", {
level: "error",
extra: {
cacheKey: key,
errorMessage: error instanceof Error ? error.message : undefined,
statusCode: response?.status,
statusText: response?.statusText,
},
})
return undefined
}
const [data, jsonError] = await safeTry(
response.json() as Promise<{ data: T }>
)
if (jsonError) {
cacheLogger.error("Failed to parse cache response", {
key,
error: jsonError,
})
await deleteKey(key)
return undefined
}
cacheLogger.debug(
`Hit '${key}' took ${(performance.now() - perf).toFixed(2)}ms`
)
return data?.data
}
@@ -0,0 +1 @@
export { createDistributedCache } from "./client"
@@ -0,0 +1,33 @@
import * as Sentry from "@sentry/nextjs"
import { safeTry } from "@/utils/safeTry"
import { type CacheTime, getCacheTimeInSeconds } from "../Cache"
import { API_KEY } from "./client"
import { getCacheEndpoint } from "./endpoints"
export async function set<T>(key: string, value: T, ttl: CacheTime) {
const [response, error] = await safeTry(
fetch(getCacheEndpoint(key), {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
body: JSON.stringify({ data: value, ttl: getCacheTimeInSeconds(ttl) }),
cache: "no-cache",
})
)
if (!response || error || !response.ok) {
Sentry.captureMessage("Unable to SET cachekey", {
level: "error",
extra: {
cacheKey: key,
errorMessage: error instanceof Error ? error.message : undefined,
statusCode: response?.status,
statusText: response?.statusText,
},
})
}
}
@@ -0,0 +1,5 @@
import { env } from "@/env/server"
export function shouldHaveFullTtl() {
return env.BRANCH === "release"
}
@@ -0,0 +1,9 @@
export const cacheMap = new Map<
string,
{
/** Absolute expiration timestamp (`Date.now()`) */
expiresAt: number
/** The cached data */
data: unknown
}
>()
@@ -0,0 +1,36 @@
import { type CacheTime, type DataCache } from "@/services/dataCache/Cache"
import { cacheLogger } from "@/services/dataCache/logger"
import { get } from "./get"
import { set } from "./set"
export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
key: string | string[],
callback: () => Promise<T>,
ttl: CacheTime
): Promise<T> => {
if (Array.isArray(key)) {
key = key.join("-")
}
const cached = await get(key)
if (cached) {
return cached as T
}
cacheLogger.debug(`Miss for key '${key}'`)
try {
const data = await callback()
await set(key, data, ttl)
return data
} catch (e) {
cacheLogger.error(
`Error while fetching data for key '${key}', avoid caching`,
e
)
throw e
}
}
@@ -0,0 +1,9 @@
import { cacheLogger } from "@/services/dataCache/logger"
import { cacheMap } from "./cacheMap"
export async function deleteAll() {
cacheLogger.debug("Deleting all keys")
cacheMap.clear()
}
@@ -0,0 +1,17 @@
import { cacheLogger } from "@/services/dataCache/logger"
import { cacheMap } from "./cacheMap"
export async function deleteKey(key: string, opts?: { fuzzy?: boolean }) {
cacheLogger.debug("Deleting key", key)
if (opts?.fuzzy) {
cacheMap.forEach((_, k) => {
if (k.includes(key)) {
cacheMap.delete(k)
}
})
return
}
cacheMap.delete(key)
}
@@ -0,0 +1,23 @@
import { cacheLogger } from "@/services/dataCache/logger"
import { cacheMap } from "./cacheMap"
export async function get<T>(key: string): Promise<T | undefined> {
const cached = cacheMap.get(key)
if (!cached) {
return undefined
}
if (cached.expiresAt < Date.now()) {
cacheLogger.debug(`Expired for key '${key}'`)
cacheMap.delete(key)
return undefined
}
if (cached.data === undefined) {
cacheLogger.debug(`Data is undefined for key '${key}'`)
cacheMap.delete(key)
return undefined
}
return cached.data as T
}
@@ -0,0 +1,10 @@
import { cacheOrGet } from "./cacheOrGet"
import { deleteKey } from "./deleteKey"
import { get } from "./get"
import { set } from "./set"
import type { DataCache } from "@/services/dataCache/Cache"
export async function createInMemoryCache(): Promise<DataCache> {
return { type: "in-memory", cacheOrGet, deleteKey, get, set }
}
@@ -0,0 +1,17 @@
import {
type CacheTime,
getCacheTimeInSeconds,
} from "@/services/dataCache/Cache"
import { cacheMap } from "./cacheMap"
export async function set<T>(
key: string,
data: T,
ttl: CacheTime
): Promise<void> {
cacheMap.set(key, {
data: data,
expiresAt: Date.now() + getCacheTimeInSeconds(ttl) * 1000,
})
}
@@ -0,0 +1,29 @@
import { unstable_cache } from "next/cache"
import {
type CacheTime,
type DataCache,
getCacheTimeInSeconds,
} from "@/services/dataCache/Cache"
import { cacheLogger } from "../../logger"
export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
key: string | string[],
callback: () => Promise<T>,
ttl: CacheTime
): Promise<T> => {
if (!Array.isArray(key)) {
key = [key]
}
const perf = performance.now()
const res = await unstable_cache(callback, key, {
revalidate: getCacheTimeInSeconds(ttl),
tags: key,
})()
cacheLogger.debug(`'${key}' took ${(performance.now() - perf).toFixed(2)}ms`)
return res
}
@@ -0,0 +1,5 @@
import { revalidateTag } from "next/cache"
export async function deleteKey(key: string) {
revalidateTag(key)
}
@@ -0,0 +1,14 @@
import type { DataCache } from "@/services/dataCache/Cache"
/**
* This function is not implemented for unstable_cache due to underlying cache implementation.
* @see cacheOrGet
* @param _key
* @returns
*/
export const get: DataCache["get"] = async <T>(
_key: string
): Promise<T | undefined> => {
console.warn("UnstableCache.get is not implemented, use cacheOrGet")
return undefined
}
@@ -0,0 +1,10 @@
import { cacheOrGet } from "./cacheOrGet"
import { deleteKey } from "./deleteKey"
import { get } from "./get"
import { set } from "./set"
import type { DataCache } from "@/services/dataCache/Cache"
export async function createUnstableCache(): Promise<DataCache> {
return { type: "unstable-cache", cacheOrGet, deleteKey, get, set }
}
@@ -0,0 +1,15 @@
import { type CacheTime, type DataCache } from "@/services/dataCache/Cache"
/**
* This function is not implemented for unstable_cache due to underlying cache implementation.
* @see cacheOrGet
* @param _key
* @returns
*/
export const set: DataCache["set"] = async <T>(
_key: string,
_data: T,
_ttl: CacheTime
): Promise<void> => {
console.warn("UnstableCache.set is not implemented, use cacheOrGet")
}
@@ -0,0 +1,17 @@
import { isEdge } from "@/utils/isEdge"
import { createInMemoryCache } from "./InMemoryCache"
import { createUnstableCache } from "./UnstableCache"
import type { DataCache } from "@/services/dataCache/Cache"
export function createMemoryCache(): Promise<DataCache> {
if (isEdge) {
/**
* unstable_cache is not available on the edge runtime
*/
return createInMemoryCache()
}
return createUnstableCache()
}
@@ -0,0 +1,28 @@
import { env } from "@/env/server"
import { isEdge } from "@/utils/isEdge"
import { createMemoryCache } from "./MemoryCache/createMemoryCache"
import { createDistributedCache } from "./DistributedCache"
import { cacheLogger } from "./logger"
import type { DataCache } from "./Cache"
export type { CacheTime, DataCache } from "./Cache"
export async function getCacheClient(): Promise<DataCache> {
if (global.cacheClient) {
return global.cacheClient
}
global.cacheClient = env.REDIS_API_HOST
? createDistributedCache()
: createMemoryCache()
const cacheClient = await global.cacheClient
cacheLogger.debug(
`Creating ${cacheClient.type} cache on ${isEdge ? "edge" : "server"} runtime`
)
return global.cacheClient
}
@@ -0,0 +1,44 @@
export const cacheLogger = {
async debug(message: string, ...args: unknown[]): Promise<void> {
console.debug(`${await loggerPrefix()} ${message}`, ...args)
},
async warn(message: string, ...args: unknown[]): Promise<void> {
console.warn(`${await loggerPrefix()} Warning - ${message}`, ...args)
},
async error(message: string, ...args: unknown[]): Promise<void> {
console.error(`${await loggerPrefix()} Error - ${message}`, ...args)
},
}
async function loggerPrefix() {
const instancePrefix = await getCachePrefix()
return `[Cache] ${instancePrefix ?? ""}`.trim()
}
async function getCachePrefix() {
const cacheCreated = await isPromiseResolved(global.cacheClient)
if (!cacheCreated.resolved) {
return null
}
const instanceType = cacheCreated.value?.type
if (!instanceType) {
return null
}
return `[${instanceType}]`
}
function isPromiseResolved<T>(promise: Promise<T> | undefined) {
if (!promise) {
return { resolved: false, value: undefined }
}
const check = Promise.race([promise, Promise.resolve("__PENDING__")])
return check.then((result) => ({
resolved: result !== "__PENDING__",
value: result !== "__PENDING__" ? (result as Awaited<T>) : undefined,
}))
}
@@ -258,7 +258,8 @@ export function createDetailsStore(
}
const stateTotalLocalPrice = state.totalPrice.local.price
const stateTotalLocalRegularPrice = state.totalPrice.local.regularPrice
const stateTotalLocalRegularPrice =
state.totalPrice.local.regularPrice
const addToTotalPrice =
(currentRoom.room.breakfast === undefined ||
@@ -292,9 +293,9 @@ export function createDetailsStore(
currency: breakfast.localPrice.currency,
price: stateTotalLocalPrice + breakfastTotalPrice,
regularPrice: stateTotalLocalRegularPrice
? stateTotalLocalRegularPrice + breakfastTotalPrice
: undefined,
},
? stateTotalLocalRegularPrice + breakfastTotalPrice
: undefined,
},
}
}
@@ -326,9 +327,9 @@ export function createDetailsStore(
if (localPrice < 0) {
localPrice = 0
}
let regularPrice = stateTotalLocalRegularPrice
? stateTotalLocalRegularPrice - currentBreakfastTotalPrice
: undefined
let regularPrice = stateTotalLocalRegularPrice
? stateTotalLocalRegularPrice - currentBreakfastTotalPrice
: undefined
state.totalPrice = {
requested: state.totalPrice.requested && {
+7 -7
View File
@@ -274,18 +274,18 @@ export function createRatesStore({
bookingRoom: room,
rooms: selectedPackage
? allRooms.filter((r) =>
r.features.find((f) => f.code === selectedPackage)
)
r.features.find((f) => f.code === selectedPackage)
)
: allRooms,
selectedPackage,
selectedRate:
selectedRate && product
? {
features: selectedRate.features,
product,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
features: selectedRate.features,
product,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
: null,
}
}),
@@ -1,3 +1,3 @@
import type { TextCols } from "@/types/trpc/routers/contentstack/blocks"
export interface TextColProps extends Pick<TextCols, "text_cols"> { }
export interface TextColProps extends Pick<TextCols, "text_cols"> {}
@@ -5,7 +5,7 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmat
import type { linkedReservationSchema } from "@/server/routers/booking/output"
export interface LinkedReservationSchema
extends z.output<typeof linkedReservationSchema> { }
extends z.output<typeof linkedReservationSchema> {}
export interface BookingConfirmationRoomsProps
extends Pick<BookingConfirmation, "booking"> {
+5
View File
@@ -0,0 +1,5 @@
import type { DataCache } from "@/services/dataCache"
declare global {
var cacheClient: Promise<DataCache> | undefined
}

Some files were not shown because too many files have changed in this diff Show More