Merged in feature/redis (pull request #1478)

Distributed cache

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

* merge

* remove debug logs and cleanup

* cleanup

* add fault handling

* add fault handling

* add pid when logging redis client creation

* add identifier when logging redis client creation

* cleanup

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

* feature: use http wrapper for redis

* feat: add the possibility to fallback to unstable_cache

* Add error handling if redis cache is unresponsive

* add logging for unstable_cache

* merge

* don't cache errors

* fix: metadatabase on branchdeploys

* Handle when /en/destinations throws
add ErrorBoundary

* Add sentry-logging when ErrorBoundary catches exception

* Fix error handling for distributed cache

* cleanup code

* Added Application Insights back

* Update generateApiKeys script and remove duplicate

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

* merge


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

View File

@@ -3,4 +3,4 @@ nodeLinker: node-modules
packageExtensions: packageExtensions:
eslint-config-next@*: eslint-config-next@*:
dependencies: dependencies:
next: '*' next: "*"

View File

@@ -0,0 +1 @@
.env.local

175
apps/redis-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

21
apps/redis-api/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Use the official Bun image
FROM oven/bun:latest
ENV CI=true
# Set the working directory
WORKDIR /app
COPY package.json ./
# Install dependencies
RUN bun install --production
# Copy the rest of the application code
COPY . .
ENV NODE_ENV=production
# Expose the port the app runs on
EXPOSE 3000
# Start the Bun server
CMD ["bun", "./src/index.ts"]

39
apps/redis-api/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Redis API
A thin wrapper around redis so that we can communicate to it via HTTP instead of TCP
## Deployment
Make sure you have access to Azure and have PIMed yourself to
- `Web-App-Frontend prod` where the ACR is located
- `Web Components Prod` or `Web Components Test` depending on where you want to deploy
Login with `az login` and select `Web-App-Frontend prod`
### Build container image
Standing in `/apps/redis-api` run
```bash
az acr build . --image redis-api:latest -r acrscandicfrontend
```
### Deploy container image
| Subscription | Environment | SubscriptionId |
| ------------------- | ----------- | ------------------------------------ |
| Web Components Prod | prod | 799cbffe-5209-41fd-adf9-4ffa3d1feead |
| Web Components Test | test | 3b657fc5-85b0-4a43-aba2-e77618ef98c4 |
```bash
# Replace with appropriate values
az deployment sub create \
--location westeurope \
--template-file ci/bicep/main.bicep \
--subscription {{SUBSCRIPTION_ID}} \
--parameters environment={{ENVIRONMENT}} \
containerImageTag=latest \
primaryApiKey={{PRIMARY API KEY}} \ # API keys are used for communicating with the api
secondaryApiKey={{SECONDARY API KEY}}
```

View File

@@ -0,0 +1,38 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": true,
},
"files": {
"ignoreUnknown": false,
"ignore": ["node_modules"],
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
},
"organizeImports": {
"enabled": true,
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noBarrelFile": "error",
},
"style": {
"useImportType": "error",
"useExportType": "error",
},
},
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
},
},
}

View File

@@ -0,0 +1,103 @@
# Docker
# Build a Docker image
# https://docs.microsoft.com/azure/devops/pipelines/languages/docker
name: 1.0.0-$(SourceBranchName)-$(Rev:r)
trigger:
- main
parameters:
- name: forcePush
displayName: Force push
type: boolean
default: false
resources:
- repo: self
variables:
tag: "$(Build.BuildNumber)"
imageName: "redis-api"
isMaster: $[eq(variables['Build.SourceBranchName'], 'master')]
shouldPush: $[or(eq(${{parameters.forcePush}}, True), eq(variables['isMaster'], True))]
tags: |
stages:
- stage: Build
displayName: Set version
jobs:
- job: CreateArtifact
displayName: Create version artifact
steps:
- task: Bash@3
displayName: Write buildnumber
inputs:
targetType: "inline"
script: |
echo '$(Build.BuildNumber)' > $(Pipeline.Workspace)/.version
- task: PublishPipelineArtifact@1
inputs:
targetPath: "$(Pipeline.Workspace)/.version"
artifact: "Version"
publishLocation: "pipeline"
- task: Bash@3
displayName: Add tag main-latest if main branch
inputs:
targetType: "inline"
script: |
localTags = $(tag)
localTags += "\nlatest"
if [ $[isMaster] ]; then
localTags += "\nlatest-main"
echo -e "##vso[task.setvariable variable=tags;]$localTags"
fi
echo -e $localTags
- job: Build
displayName: Build
pool:
vmImage: ubuntu-latest
steps:
- task: Bash@3
inputs:
targetType: "inline"
script: |
echo "VERSION=$(tag)" >> .env.production
echo "ShouldPush=$(shouldPush)"
echo "ForcePush=${{ parameters.forcePush }}"
echo "isMaster=$(isMaster)"
- task: AzureCLI@2
displayName: Login to ACR
inputs:
azureSubscription: "mi-devops"
scriptType: "bash"
scriptLocation: "inlineScript"
workingDirectory: "$(build.sourcesDirectory)"
inlineScript: az acr login --name acrscandicfrontend
- task: AzureCLI@2
displayName: Build and push to ACR
inputs:
azureSubscription: "mi-devops"
scriptType: "bash"
scriptLocation: "inlineScript"
workingDirectory: "$(build.sourcesDirectory)"
inlineScript: |
if [ "$(shouldPush)" != "True" ]; then
echo "Not pushing to ACR"
noPush="--no-push"
else
echo "Pushing to ACR"
noPush=""
fi
echo "isMaster: $(isMaster)"
if [ "$(isMaster)" == "True" ]; then
echo "Building with latest tag"
az acr build . --image $(imageName):latest -r acrscandicfrontend $noPush
fi
echo "Building with $(tag) tag"
az acr build . --image $(imageName):$(tag) -r acrscandicfrontend $noPush

View File

@@ -0,0 +1,44 @@
trigger: none
pr: none
resources:
pipelines:
- pipeline: buildPipeline
source: "Build App BFF"
trigger:
branches:
include:
- main
pool:
vmImage: ubuntu-latest
parameters:
- name: containerTag
displayName: Select tag to deploy
type: string
default: "latest"
variables:
- name: containerTag
value: ${{ parameters.containerTag }}
stages:
- stage: Deploy_test
variables:
- group: "BFF test"
jobs:
- template: ./azure-pipelines.deploywebapptemplate.yml
parameters:
environment: test
subscriptionId: 1a126a59-4703-4e36-ad7b-2503d36526c0
containerTag: $(containerTag)
# - stage: Deploy_prod
# variables:
# - group: 'BFF prod'
# jobs:
# - template: ./azure-pipelines.deploywebapptemplate.yml
# parameters:
# environment: prod
# subscriptionId: 1e6ef69e-8719-4924-a311-e66fe00399c7
# containerTag: $(containerTag)

View File

@@ -0,0 +1,75 @@
import { Environment, EnvironmentVar } from '../types.bicep'
param environment Environment
param location string
param containerAppName string
param containerImage string
param containerPort int
param minReplicas int = 1
param maxReplicas int = 3
param envVars EnvironmentVar[] = []
param userAssignedIdentityId string
resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = {
name: 'acrscandicfrontend'
scope: resourceGroup('1e6ef69e-8719-4924-a311-e66fe00399c7', 'rg-shared')
}
resource containerApp 'Microsoft.App/containerApps@2024-10-02-preview' = {
name: containerAppName
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${userAssignedIdentityId}': {}
}
}
properties: {
environmentId: resourceId('Microsoft.App/managedEnvironments', 'cae-redis-api-${environment}')
configuration: {
activeRevisionsMode: 'Single'
registries: [
{
identity: userAssignedIdentityId
server: acr.properties.loginServer
}
]
ingress: {
external: true
targetPort: containerPort
}
}
template: {
containers: [
{
name: containerAppName
image: containerImage
imageType: 'ContainerImage'
env: [
for envVar in envVars: {
name: envVar.name
value: envVar.value
}
]
probes: [
{
type: 'Liveness'
httpGet: {
port: containerPort
path: '/health'
}
}
]
resources: {
cpu: json('0.25')
memory: '0.5Gi'
}
}
]
scale: {
minReplicas: minReplicas
maxReplicas: maxReplicas
}
}
}
}

View File

@@ -0,0 +1,42 @@
import { Environment, EnvironmentVar } from '../types.bicep'
targetScope = 'subscription'
param environment Environment
param containerImageTag string
param redisConnection string
param primaryApiKey string
param secondaryApiKey string
param timestamp string = utcNow()
@description('The location for the resource group')
param location string = 'westeurope'
resource rgRedisApi 'Microsoft.Resources/resourceGroups@2021-04-01' existing = {
name: 'rg-redis-api-${environment}'
}
resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = {
name: 'mi-redis-api-${environment}'
scope: rgRedisApi
}
module containerApp 'containerApp.bicep' = {
name: 'containerApp'
scope: rgRedisApi
params: {
location: location
environment: environment
userAssignedIdentityId: mi.id
containerAppName: 'ca-redis-api-${environment}'
containerImage: 'acrscandicfrontend.azurecr.io/redis-api:${containerImageTag}'
containerPort: 3001
envVars: [
{ name: 'REDIS_CONNECTION', value: redisConnection }
{ name: 'PRIMARY_API_KEY', value: primaryApiKey }
{ name: 'SECONDARY_API_KEY', value: secondaryApiKey }
{ name: 'timestamp', value: timestamp }
]
}
}

View File

@@ -0,0 +1,49 @@
import { Environment } from '../types.bicep'
param environment Environment
@description('The location for the resource group')
param location string = 'westeurope'
var testSKU = {
name: 'Basic'
family: 'C'
capacity: 0
}
var prodSKU = {
name: 'Standard'
family: 'C'
capacity: 1
}
var sku = environment == 'prod' ? prodSKU : testSKU
resource redisResource 'Microsoft.Cache/Redis@2024-11-01' = {
name: 'redis-scandic-frontend-${environment}'
location: location
properties: {
redisVersion: '6.0'
sku: {
name: sku.name
family: sku.family
capacity: sku.capacity
}
enableNonSslPort: false
minimumTlsVersion: '1.2'
publicNetworkAccess: 'Enabled'
redisConfiguration: {
'aad-enabled': 'false'
'maxmemory-reserved': '30'
'maxfragmentationmemory-reserved': '30'
'maxmemory-delta': '30'
}
updateChannel: 'Stable'
disableAccessKeyAuthentication: false
}
}
output hostname string = redisResource.properties.hostName
output connectionString string = '${redisResource.properties.hostName}:6380,password=${redisResource.properties.accessKeys.primaryKey},ssl=True,abortConnect=False'
output primaryAccessKey string = redisResource.properties.accessKeys.primaryKey

View File

@@ -0,0 +1,19 @@
param principalId string
module acrPull '../roles/acr-pull.bicep' = {
name: 'acrPull'
}
resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = {
name: 'acrscandicfrontend'
}
resource rbac 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(registry.name, 'ServicePrincipal', principalId, acrPull.name)
scope: registry
properties: {
principalType: 'ServicePrincipal'
principalId: principalId
roleDefinitionId: acrPull.outputs.id
}
}

View File

@@ -0,0 +1,26 @@
import { Environment } from '../types.bicep'
param location string = 'westeurope'
param environment Environment
param userAssignedIdentityId string
resource containerEnv 'Microsoft.App/managedEnvironments@2024-02-02-preview' = {
name: 'cae-redis-api-${environment}'
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${userAssignedIdentityId}': {}
}
}
properties: {
publicNetworkAccess: 'Enabled'
workloadProfiles: [
{
name: 'Consumption'
workloadProfileType: 'Consumption'
}
]
zoneRedundant: false
}
}

View File

@@ -0,0 +1,40 @@
import { Environment } from '../types.bicep'
targetScope = 'subscription'
param environment Environment
var location = deployment().location
var productionSubscriptionId = '799cbffe-5209-41fd-adf9-4ffa3d1feead'
resource rgBff 'Microsoft.Resources/resourceGroups@2021-04-01' = {
name: 'rg-redis-api-${environment}'
location: location
}
module mi '../managedIdentity.bicep' = {
name: 'mi-redis-api-${environment}'
scope: rgBff
params: {
principalName: 'mi-redis-api-${environment}'
location: location
}
}
module allowAcrPull 'allow-acr-pull.bicep' = {
name: 'allowAcrPull'
scope: resourceGroup('1e6ef69e-8719-4924-a311-e66fe00399c7', 'rg-shared')
params: {
principalId: mi.outputs.principalId
}
}
module containerEnv 'containerEnvironment.bicep' = {
name: 'containerEnv'
scope: rgBff
params: {
location: location
environment: environment
userAssignedIdentityId: mi.outputs.id
}
}

View File

@@ -0,0 +1,47 @@
import { Environment, EnvironmentVar } from 'types.bicep'
targetScope = 'subscription'
param environment Environment
param containerImageTag string = 'latest'
param primaryApiKey string
param secondaryApiKey string
@description('The location for the resource group')
param location string = 'westeurope'
resource rgRedisApi 'Microsoft.Resources/resourceGroups@2021-04-01' = {
name: 'rg-redis-api-${environment}'
location: location
}
module mi 'managedIdentity.bicep' = {
name: 'mi-redis-api-${environment}'
scope: rgRedisApi
params: {
principalName: 'mi-redis-api-${environment}'
location: location
}
}
module redis 'cache/redis.bicep' = {
name: 'redisCache'
scope: rgRedisApi
params: {
location: location
environment: environment
}
}
module containerApp 'app/main.bicep' = {
name: 'containerApp'
params: {
location: location
environment: environment
containerImageTag: containerImageTag
redisConnection: 'default:${redis.outputs.primaryAccessKey}@${redis.outputs.hostname}:6380'
primaryApiKey: primaryApiKey
secondaryApiKey: secondaryApiKey
}
}

View File

@@ -0,0 +1,10 @@
param location string = 'westeurope'
param principalName string
resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: principalName
location: location
}
output principalId string = mi.properties.principalId
output id string = mi.id

View File

@@ -0,0 +1,5 @@
@description('Pull artifacts from a container registry. Ref: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/containers#acrpull')
resource rd 'Microsoft.Authorization/roleDefinitions@2022-05-01-preview' existing = {
name: '7f951dda-4ed3-4680-a7ca-43fe172d538d'
}
output id string = rd.id

View File

@@ -0,0 +1,9 @@
@export()
@description('Type with allowed environments.')
type Environment = 'test' | 'prod'
@export()
type EnvironmentVar = {
name: string
value: string
}

View File

@@ -0,0 +1,23 @@
{
"name": "redis-api",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun --watch src/index.ts | pino-pretty -o '{if module}[{module}] {end}{msg}' -i pid,hostname"
},
"dependencies": {
"@elysiajs/server-timing": "1.2.1",
"@elysiajs/swagger": "1.2.2",
"@t3-oss/env-core": "0.12.0",
"elysia": "1.2.25",
"ioredis": "5.6.0",
"pino": "9.6.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest",
"pino-pretty": "^13.0.0",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,13 @@
import crypto from "node:crypto";
function generateApiKey(length = 32): string {
return crypto.randomBytes(length).toString("base64");
}
// If this file is run directly, generate and log an API key.
if (require.main === module) {
console.log("Primary API Key:", generateApiKey());
console.log("Secondary API Key:", generateApiKey());
}
export { generateApiKey };

56
apps/redis-api/src/env.ts Normal file
View File

@@ -0,0 +1,56 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
const redisConnectionRegex =
/^((?<username>.*?):(?<password>.*?)@)?(?<host>.*?):(?<port>\d+)$/;
export const env = createEnv({
server: {
IS_PROD: z
.boolean()
.default(false)
.transform(
() =>
process.env.BUN_ENV === "production" ||
process.env.NODE_ENV === "production"
),
IS_DEV: z
.boolean()
.default(false)
.transform(
() =>
process.env.BUN_ENV === "development" ||
process.env.NODE_ENV === "development"
),
VERSION: z.string().min(1).default("development"),
PORT: z.coerce.number().default(3001),
REDIS_CONNECTION: z.string().regex(redisConnectionRegex),
PRIMARY_API_KEY:
process.env.NODE_ENV === "development"
? z.string().optional()
: z.string().min(10),
SECONDARY_API_KEY:
process.env.NODE_ENV === "development"
? z.string().optional()
: z.string().min(10),
},
runtimeEnv: {
...process.env,
},
});
const redisMatch = env.REDIS_CONNECTION.match(redisConnectionRegex);
if (!redisMatch?.groups) {
throw new Error("Invalid REDIS_CONNECTION format");
}
export const redisConfig = {
host: redisMatch.groups.host,
port: Number(redisMatch.groups.port),
username: redisMatch.groups.username,
password: redisMatch.groups.password,
};
console.log("env", env);
console.log("redisConfig", redisConfig);

View File

@@ -0,0 +1,6 @@
export class AuthenticationError extends Error {
constructor(public message: string) {
super(message);
this.name = "AuthenticationError";
}
}

View File

@@ -0,0 +1,6 @@
export class ModelValidationError extends Error {
constructor(public message: string) {
super(message);
this.name = "ModelValidationError";
}
}

View File

@@ -0,0 +1,59 @@
import { Elysia } from "elysia";
import { swagger } from "@elysiajs/swagger";
import { apiRoutes } from "@/routes/api";
import { healthRoutes } from "@/routes/health";
import { baseLogger } from "@/utils/logger";
import { env } from "@/env";
import serverTiming from "@elysiajs/server-timing";
import { AuthenticationError } from "@/errors/AuthenticationError";
import { ModelValidationError } from "@/errors/ModelValidationError";
const app = new Elysia()
.use(serverTiming())
.error("AUTHENTICATION_ERROR", AuthenticationError)
.error("MODEL_VALIDATION_ERROR", ModelValidationError)
.onError(({ code, error, set }) => {
switch (code) {
case "MODEL_VALIDATION_ERROR":
set.status = 400;
return getErrorReturn(error);
case "AUTHENTICATION_ERROR":
set.status = 401;
return getErrorReturn(error);
case "NOT_FOUND":
set.status = 404;
return getErrorReturn(error);
case "INTERNAL_SERVER_ERROR":
set.status = 500;
return getErrorReturn(error);
}
});
if (env.IS_DEV) {
app.use(
swagger({
documentation: {
info: {
title: "Redis API",
version: "1.0.0",
},
},
})
);
}
app.use(apiRoutes);
app.use(healthRoutes);
app.listen(env.PORT, (server) => {
baseLogger.info(`🦊 REDISAPI@${env.VERSION} running on ${server.url}`);
});
function getErrorReturn(error: Error) {
return {
status: "error",
message: error.toString(),
};
}

View File

@@ -0,0 +1,28 @@
import { AuthenticationError } from "@/errors/AuthenticationError";
import type { Context } from "elysia";
import { env } from "@/env";
const API_KEY_HEADER = "x-api-key";
export const apiKeyMiddleware = ({ headers }: Context) => {
if (!isApiKeyRequired()) {
return;
}
const apiKey = headers[API_KEY_HEADER];
if (!apiKey) {
throw new AuthenticationError("No API KEY provided");
}
if (!validateApiKey(apiKey)) {
throw new AuthenticationError("Invalid API key");
}
};
function isApiKeyRequired(): boolean {
return Boolean(env.PRIMARY_API_KEY) || Boolean(env.SECONDARY_API_KEY);
}
function validateApiKey(apiKey: string): boolean {
return apiKey === env.PRIMARY_API_KEY || apiKey === env.SECONDARY_API_KEY;
}

View File

@@ -0,0 +1,93 @@
import { Elysia, t, ValidationError } from "elysia";
import { redis } from "@/services/redis";
import { ModelValidationError } from "@/errors/ModelValidationError";
const MIN_LENGTH = 1;
const QUERY_TYPE = t.Object({ key: t.String({ minLength: MIN_LENGTH }) });
export const cacheRoutes = new Elysia({ prefix: "/cache" })
.get(
"/",
async ({ query: { key }, error }) => {
key = validateKey(key);
console.log("GET /cache", key);
const value = await redis.get(key);
if (!value) {
return error("Not Found", "Not Found");
}
try {
const output = JSON.parse(value);
return { data: output };
} catch (e) {
redis.del(key);
throw e;
}
},
{
query: QUERY_TYPE,
response: { 200: t.Object({ data: t.Any() }), 404: t.String() },
}
)
.put(
"/",
async ({ query: { key }, body, error, set }) => {
key = validateKey(key);
console.log("PUT /cache", key);
if (!body.ttl || body.ttl < 0) {
return error("Bad Request", "ttl is required");
}
await redis.set(key, JSON.stringify(body.data), "EX", body.ttl);
set.status = 204;
return;
},
{
body: t.Object({ data: t.Any(), ttl: t.Number() }),
query: QUERY_TYPE,
response: { 204: t.Void(), 400: t.String() },
}
)
.delete(
"/",
async ({ query: { key, fuzzy }, set }) => {
key = validateKey(key);
console.log("DELETE /cache", key);
if (fuzzy) {
key = `*${key}*`;
}
await redis.del(key);
set.status = 204;
return;
},
{
query: t.Object({
...QUERY_TYPE.properties,
...t.Object({ fuzzy: t.Optional(t.Boolean()) }).properties,
}),
response: { 204: t.Void(), 400: t.String() },
}
);
function validateKey(key: string) {
const parsedKey = decodeURIComponent(key);
if (parsedKey.length < MIN_LENGTH) {
throw new ModelValidationError(
"Key has to be atleast 1 character long"
);
}
if (parsedKey.includes("*")) {
throw new ModelValidationError("Key cannot contain wildcards");
}
return parsedKey;
}

View File

@@ -0,0 +1,7 @@
import { Elysia } from "elysia";
import { cacheRoutes } from "./cache";
import { apiKeyMiddleware } from "@/middleware/apiKeyMiddleware";
export const apiRoutes = new Elysia({ prefix: "/api" })
.guard({ beforeHandle: apiKeyMiddleware })
.use(cacheRoutes);

View File

@@ -0,0 +1,34 @@
import Elysia, { t } from "elysia";
import { redis } from "@/services/redis";
import { baseLogger } from "@/utils/logger";
export const healthRoutes = new Elysia().get(
"/health",
async ({ set, error }) => {
const perf = performance.now();
try {
await redis.ping();
} catch (e) {
baseLogger.error("Redis connection error:", e);
console.log("Redis connection error:", e);
return error(503, { healthy: false });
}
const duration = performance.now() - perf;
baseLogger.info(`Service healthy: ${duration.toFixed(2)} ms`);
return { healthy: true };
},
{
response: {
200: t.Object({
healthy: t.Boolean(),
}),
503: t.Object({
healthy: t.Boolean(),
}),
},
}
);

View File

@@ -0,0 +1,19 @@
import { redisConfig, env } from "@/env";
import ioredis from "ioredis";
const redis = new ioredis({
host: redisConfig.host,
port: redisConfig.port,
username: redisConfig.username,
password: redisConfig.password,
maxRetriesPerRequest: 1, // Avoid excessive retries,
tls: !env.IS_DEV
? {
rejectUnauthorized: true,
}
: undefined,
lazyConnect: true,
connectTimeout: 10_000,
});
export { redis };

View File

@@ -0,0 +1,34 @@
import pino from "pino";
import { mask } from "./mask";
import { env } from "@/env";
const serializers: { [key: string]: pino.SerializerFn } = {
password: (payload) => {
if (payload) {
return env.IS_DEV
? mask(payload)
: mask(payload, {
visibleStart: 0,
visibleEnd: 0,
});
}
return payload;
},
email: (payload) => {
if (payload) {
return env.IS_DEV ? payload : mask(payload);
}
return payload;
},
};
export const baseLogger = pino({
level: process.env.LOG_LEVEL || "info",
timestamp: pino.stdTimeFunctions.isoTime,
serializers,
});
export const loggerModule = (loggerName: string) => {
return baseLogger.child({ module: loggerName });
};

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "bun:test";
import { mask } from "./mask";
describe("mask", () => {
it("should return empty string for empty input", () => {
expect(mask("")).toBe("");
});
it("should mask string with default parameters", () => {
expect(mask("1234567890")).toBe("12******90");
});
it("should show custom number of characters at start", () => {
expect(mask("1234567890", { visibleStart: 3 })).toBe("123*****90");
});
it("should show custom number of characters at end", () => {
expect(mask("1234567890", { visibleStart: 2, visibleEnd: 3 })).toBe(
"12*****890",
);
});
it("should mask entire string when visible parts exceed length", () => {
expect(mask("123", { visibleStart: 2, visibleEnd: 2 })).toBe("***");
});
it("should handle undefined end part", () => {
expect(mask("1234567890", { visibleStart: 2, visibleEnd: 0 })).toBe(
"12********",
);
});
it("should handle long strings", () => {
expect(mask("12345678901234567890")).toBe("12**********90");
});
it("should handle emails", () => {
expect(mask("test.testsson@scandichotels.com")).toBe(
"te*********on@sc*********ls.com",
);
});
});

View File

@@ -0,0 +1,42 @@
/**
* Masks a string by replacing characters with a mask character
* @param value - The string to mask
* @param visibleStart - Number of characters to show at start (default: 0)
* @param visibleEnd - Number of characters to show at end (default: 4)
* @param maskChar - Character to use for masking (default: '*')
* @returns The masked string
*/
const maskChar = "*";
export function mask(
value: string,
options?: { visibleStart?: number; visibleEnd?: number; maxLength?: number },
): string {
if (!value) return "";
const { visibleStart = 2, visibleEnd = 2, maxLength = 10 } = options ?? {};
if (isEmail(value)) {
return maskEmail(value);
}
const totalVisible = visibleStart + visibleEnd;
if (value.length <= totalVisible) {
return maskChar.repeat(value.length);
}
const start = value.slice(0, visibleStart);
const middle = value.slice(visibleStart, -visibleEnd || undefined);
const end = visibleEnd ? value.slice(-visibleEnd) : "";
const maskedLength = Math.min(middle.length, maxLength);
return start + maskChar.repeat(maskedLength) + end;
}
function maskEmail(email: string): string {
const [local, domain] = email.split("@");
if (!domain || !local) return mask(email);
const [subDomain, tld] = domain.split(/\.(?=[^.]+$)/);
return `${mask(local)}@${mask(subDomain ?? "")}.${tld}`;
}
const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -12,3 +12,5 @@ netlify.toml
package.json package.json
package-lock.json package-lock.json
.gitignore .gitignore
*.bicep
*.ico

View File

@@ -3,9 +3,11 @@
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. 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 ## 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. 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 ## 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. 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 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.

View File

@@ -18,6 +18,19 @@ yarn dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 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 ## Learn More
To learn more about Next.js, take a look at the following resources: To learn more about Next.js, take a look at the following resources:

View File

@@ -6,6 +6,7 @@ import { startTransition, useEffect, useRef } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { login } from "@/constants/routes/handleAuth" import { login } from "@/constants/routes/handleAuth"
import { env } from "@/env/client"
import { SESSION_EXPIRED } from "@/server/errors/trpc" import { SESSION_EXPIRED } from "@/server/errors/trpc"
import styles from "./error.module.css" import styles from "./error.module.css"
@@ -61,6 +62,9 @@ export default function Error({
<section className={styles.layout}> <section className={styles.layout}>
<div className={styles.content}> <div className={styles.content}>
{intl.formatMessage({ id: "Something went wrong!" })} {intl.formatMessage({ id: "Something went wrong!" })}
{env.NEXT_PUBLIC_NODE_ENV === "development" && (
<pre>{error.stack || error.message}</pre>
)}
</div> </div>
</section> </section>
) )

View File

@@ -25,8 +25,7 @@ export default async function CurrentContentPage({
{ {
locale: params.lang, locale: params.lang,
url: searchParams.uri, url: searchParams.uri,
}, }
{ cache: "no-store" }
) )
if (!response.data?.all_current_blocks_page?.total) { 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 // 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>( const pageDataForTracking = await request<TrackingData>(
GetCurrentBlockPageTrackingData, GetCurrentBlockPageTrackingData,
{ uid: response.data.all_current_blocks_page.items[0].system.uid }, { uid: response.data.all_current_blocks_page.items[0].system.uid }
{ cache: "no-store" }
) )
const pageData = response.data.all_current_blocks_page.items[0] const pageData = response.data.all_current_blocks_page.items[0]

View File

@@ -6,6 +6,7 @@ import { Lang } from "@/constants/languages"
import { env } from "@/env/server" import { env } from "@/env/server"
import { badRequest, internalServerError, notFound } from "@/server/errors/next" import { badRequest, internalServerError, notFound } from "@/server/errors/next"
import { getCacheClient } from "@/services/dataCache"
import { generateHotelUrlTag } from "@/utils/generateTag" import { generateHotelUrlTag } from "@/utils/generateTag"
import type { NextRequest } from "next/server" import type { NextRequest } from "next/server"
@@ -63,6 +64,8 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating hotel url tag: ${tag}`) console.info(`Revalidating hotel url tag: ${tag}`)
revalidateTag(tag) revalidateTag(tag)
const cacheClient = await getCacheClient()
await cacheClient.deleteKey(tag, { fuzzy: true })
return Response.json({ revalidated: true, now: Date.now() }) return Response.json({ revalidated: true, now: Date.now() })
} catch (error) { } catch (error) {

View File

@@ -6,6 +6,7 @@ import { Lang } from "@/constants/languages"
import { env } from "@/env/server" import { env } from "@/env/server"
import { badRequest, internalServerError, notFound } from "@/server/errors/next" import { badRequest, internalServerError, notFound } from "@/server/errors/next"
import { getCacheClient } from "@/services/dataCache"
import { generateLoyaltyConfigTag } from "@/utils/generateTag" import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import type { NextRequest } from "next/server" import type { NextRequest } from "next/server"
@@ -82,6 +83,9 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating loyalty config tag: ${tag}`) console.info(`Revalidating loyalty config tag: ${tag}`)
revalidateTag(tag) revalidateTag(tag)
const cacheClient = await getCacheClient()
await cacheClient.deleteKey(tag, { fuzzy: true })
return Response.json({ revalidated: true, now: Date.now() }) return Response.json({ revalidated: true, now: Date.now() })
} catch (error) { } catch (error) {
console.error("Failed to revalidate tag(s) for loyalty config") console.error("Failed to revalidate tag(s) for loyalty config")

View File

@@ -4,6 +4,7 @@ import { headers } from "next/headers"
import { env } from "@/env/server" import { env } from "@/env/server"
import { badRequest, internalServerError } from "@/server/errors/next" import { badRequest, internalServerError } from "@/server/errors/next"
import { getCacheClient } from "@/services/dataCache"
import { generateTag } from "@/utils/generateTag" import { generateTag } from "@/utils/generateTag"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
@@ -27,23 +28,8 @@ export async function POST() {
const affix = headersList.get("x-affix") const affix = headersList.get("x-affix")
const identifier = headersList.get("x-identifier") const identifier = headersList.get("x-identifier")
const lang = headersList.get("x-lang") const lang = headersList.get("x-lang")
if (lang && identifier) {
if (affix) { if (!lang || !identifier) {
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 {
console.info(`Missing lang and/or identifier`) console.info(`Missing lang and/or identifier`)
console.info(`lang: ${lang}, identifier: ${identifier}`) console.info(`lang: ${lang}, identifier: ${identifier}`)
return badRequest({ 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() }) return Response.json({ revalidated: true, now: Date.now() })
} catch (error) { } catch (error) {
console.error("Failed to revalidate tag(s)") console.error("Failed to revalidate tag(s)")

View File

@@ -10,6 +10,7 @@ import { languageSwitcherAffix } from "@/server/routers/contentstack/languageSwi
import { affix as metadataAffix } from "@/server/routers/contentstack/metadata/utils" import { affix as metadataAffix } from "@/server/routers/contentstack/metadata/utils"
import { affix as pageSettingsAffix } from "@/server/routers/contentstack/pageSettings/utils" import { affix as pageSettingsAffix } from "@/server/routers/contentstack/pageSettings/utils"
import { getCacheClient } from "@/services/dataCache"
import { import {
generateRefsResponseTag, generateRefsResponseTag,
generateRefTag, generateRefTag,
@@ -87,23 +88,31 @@ export async function POST(request: NextRequest) {
) )
const metadataTag = generateTag(entryLocale, entry.uid, metadataAffix) const metadataTag = generateTag(entryLocale, entry.uid, metadataAffix)
const cacheClient = await getCacheClient()
console.info(`Revalidating refsTag: ${refsTag}`) console.info(`Revalidating refsTag: ${refsTag}`)
revalidateTag(refsTag) revalidateTag(refsTag)
await cacheClient.deleteKey(refsTag, { fuzzy: true })
console.info(`Revalidating refTag: ${refTag}`) console.info(`Revalidating refTag: ${refTag}`)
revalidateTag(refTag) revalidateTag(refTag)
await cacheClient.deleteKey(refTag, { fuzzy: true })
console.info(`Revalidating tag: ${tag}`) console.info(`Revalidating tag: ${tag}`)
revalidateTag(tag) revalidateTag(tag)
await cacheClient.deleteKey(tag, { fuzzy: true })
console.info(`Revalidating language switcher tag: ${languageSwitcherTag}`) console.info(`Revalidating language switcher tag: ${languageSwitcherTag}`)
revalidateTag(languageSwitcherTag) revalidateTag(languageSwitcherTag)
await cacheClient.deleteKey(languageSwitcherTag, { fuzzy: true })
console.info(`Revalidating metadataTag: ${metadataTag}`) console.info(`Revalidating metadataTag: ${metadataTag}`)
revalidateTag(metadataTag) revalidateTag(metadataTag)
await cacheClient.deleteKey(metadataTag, { fuzzy: true })
console.info(`Revalidating contentEntryTag: ${contentEntryTag}`) console.info(`Revalidating contentEntryTag: ${contentEntryTag}`)
revalidateTag(contentEntryTag) revalidateTag(contentEntryTag)
await cacheClient.deleteKey(contentEntryTag, { fuzzy: true })
if (entry.breadcrumbs) { if (entry.breadcrumbs) {
const breadcrumbsRefsTag = generateRefsResponseTag( const breadcrumbsRefsTag = generateRefsResponseTag(
@@ -119,9 +128,11 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating breadcrumbsRefsTag: ${breadcrumbsRefsTag}`) console.info(`Revalidating breadcrumbsRefsTag: ${breadcrumbsRefsTag}`)
revalidateTag(breadcrumbsRefsTag) revalidateTag(breadcrumbsRefsTag)
await cacheClient.deleteKey(breadcrumbsRefsTag, { fuzzy: true })
console.info(`Revalidating breadcrumbsTag: ${breadcrumbsTag}`) console.info(`Revalidating breadcrumbsTag: ${breadcrumbsTag}`)
revalidateTag(breadcrumbsTag) revalidateTag(breadcrumbsTag)
await cacheClient.deleteKey(breadcrumbsTag, { fuzzy: true })
} }
if (entry.page_settings) { if (entry.page_settings) {
@@ -133,6 +144,7 @@ export async function POST(request: NextRequest) {
console.info(`Revalidating pageSettingsTag: ${pageSettingsTag}`) console.info(`Revalidating pageSettingsTag: ${pageSettingsTag}`)
revalidateTag(pageSettingsTag) revalidateTag(pageSettingsTag)
await cacheClient.deleteKey(pageSettingsTag, { fuzzy: true })
} }
return Response.json({ revalidated: true, now: Date.now() }) return Response.json({ revalidated: true, now: Date.now() })

View File

@@ -24,7 +24,7 @@ export function SessionRefresher() {
const session = useSession() const session = useSession()
const pathname = usePathname() const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const timeoutId = useRef<NodeJS.Timeout>() const timeoutId = useRef<Timer>()
// Simple inactivity control. Reset when the URL changes. // Simple inactivity control. Reset when the URL changes.
const stopPreRefreshAt = useMemo( const stopPreRefreshAt = useMemo(

View File

@@ -15,7 +15,10 @@
} }
.partial { .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 { .icon {

View File

@@ -6,6 +6,7 @@ import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
import { type PropsWithChildren, useEffect } from "react" import { type PropsWithChildren, useEffect } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary"
import { CloseLargeIcon, MinusIcon, PlusIcon } from "@/components/Icons" import { CloseLargeIcon, MinusIcon, PlusIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
@@ -84,7 +85,9 @@ export default function DynamicMap({
return ( return (
<div className={styles.mapWrapper}> <div className={styles.mapWrapper}>
<ErrorBoundary fallback={<h2>Unable to display map</h2>}>
<Map {...mapOptions}>{children}</Map> <Map {...mapOptions}>{children}</Map>
</ErrorBoundary>
<div className={styles.ctaButtons}> <div className={styles.ctaButtons}>
{onClose && ( {onClose && (
<Button <Button

View File

@@ -69,8 +69,8 @@
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.pageContainer { .pageContainer {
--hotel-page-scroll-margin-top: calc( --hotel-page-scroll-margin-top: calc(
var(--hotel-page-navigation-height) + var(--booking-widget-desktop-height) + var(--hotel-page-navigation-height) +
var(--Spacing-x2) var(--booking-widget-desktop-height) + var(--Spacing-x2)
); );
grid-template-areas: grid-template-areas:
"header mapContainer" "header mapContainer"

View File

@@ -4,7 +4,9 @@ export default function OfflineBanner() {
return ( return (
<div className={`${styles.banner} ${styles.hidden}`}> <div className={`${styles.banner} ${styles.hidden}`}>
You are offline, some content may be out of date. 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> </div>
) )
} }

View File

@@ -12,17 +12,11 @@ export default function Breadcrumbs({
<ul className={styles.list}> <ul className={styles.list}>
{parent ? ( {parent ? (
<li className={styles.parent}> <li className={styles.parent}>
<a href={parent.href}> <a href={parent.href}>{parent.title}</a>
{parent.title}
</a>
</li> </li>
) : null} ) : null}
{breadcrumbs.map((breadcrumb) => ( {breadcrumbs.map((breadcrumb) => (
<li <li className={styles.li} itemProp="breadcrumb" key={breadcrumb.href}>
className={styles.li}
itemProp="breadcrumb"
key={breadcrumb.href}
>
<a className={styles.link} href={breadcrumb.href}> <a className={styles.link} href={breadcrumb.href}>
{breadcrumb.title} {breadcrumb.title}
</a> </a>

View File

@@ -11,16 +11,12 @@ export default async function SubnavMobile({
<ul className="breadcrumb-list hidden-small hidden-medium hidden-large"> <ul className="breadcrumb-list hidden-small hidden-medium hidden-large">
{parent ? ( {parent ? (
<li className="breadcrumb-list__parent hidden-medium hidden-large"> <li className="breadcrumb-list__parent hidden-medium hidden-large">
<a href={parent.href}> <a href={parent.href}>{parent.title}</a>
{parent.title}
</a>
</li> </li>
) : null} ) : null}
{breadcrumbs.map((breadcrumb) => ( {breadcrumbs.map((breadcrumb) => (
<li className="breadcrumb-list__body" key={breadcrumb.href}> <li className="breadcrumb-list__body" key={breadcrumb.href}>
<a href={breadcrumb.href}> <a href={breadcrumb.href}>{breadcrumb.title}</a>
{breadcrumb.title}
</a>
</li> </li>
))} ))}
<li className="breadcrumb-list__body"> <li className="breadcrumb-list__body">

View File

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

View File

@@ -14,7 +14,8 @@
} }
.link:nth-of-type(1) .promo { .link:nth-of-type(1) .promo {
background-image: linear-gradient( background-image:
linear-gradient(
180deg, 180deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%, rgba(0, 0, 0, 0.36) 37.88%,
@@ -24,7 +25,8 @@
} }
.link:nth-of-type(2) .promo { .link:nth-of-type(2) .promo {
background-image: linear-gradient( background-image:
linear-gradient(
180deg, 180deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%, rgba(0, 0, 0, 0.36) 37.88%,

View File

@@ -2,7 +2,8 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: #fff; 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, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%), linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%); linear-gradient(-45deg, transparent 75%, #000000 75%);

View File

@@ -19,7 +19,8 @@
} }
.link .promo { .link .promo {
background-image: linear-gradient( background-image:
linear-gradient(
180deg, 180deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%, rgba(0, 0, 0, 0.36) 37.88%,

View File

@@ -107,7 +107,8 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: #fff; 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, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%), linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%); linear-gradient(-45deg, transparent 75%, #000000 75%);

View File

@@ -18,13 +18,11 @@ import type { Rate } from "@/types/components/hotelReservation/selectRate/select
export default function SelectedRoomPanel() { export default function SelectedRoomPanel() {
const intl = useIntl() const intl = useIntl()
const { isUserLoggedIn, roomCategories } = useRatesStore( const { isUserLoggedIn, roomCategories } = useRatesStore((state) => ({
(state) => ({
isUserLoggedIn: state.isUserLoggedIn, isUserLoggedIn: state.isUserLoggedIn,
rateDefinitions: state.roomsAvailability?.rateDefinitions, rateDefinitions: state.roomsAvailability?.rateDefinitions,
roomCategories: state.roomCategories, roomCategories: state.roomCategories,
}) }))
)
const { const {
actions: { modifyRate }, actions: { modifyRate },
isMainRoom, isMainRoom,

View File

@@ -40,7 +40,7 @@ export function useRoomsAvailability(
toDateString: string, toDateString: string,
lang: Lang, lang: Lang,
childArray?: Child[], childArray?: Child[],
bookingCode?: string, bookingCode?: string
) { ) {
const returnValue = const returnValue =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({ trpc.hotel.availability.roomsCombinedAvailability.useQuery({

View File

@@ -33,7 +33,8 @@
aspect-ratio: 16/9; aspect-ratio: 16/9;
width: 100%; width: 100%;
background-color: #fff; 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, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%), linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%); linear-gradient(-45deg, transparent 75%, #000000 75%);

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

View File

@@ -185,6 +185,13 @@ export const env = createEnv({
.number() .number()
.default(10 * 60) .default(10 * 60)
.transform((val) => (process.env.CMS_ENVIRONMENT === "test" ? 60 : val)), .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, emptyStringAsUndefined: true,
runtimeEnv: { runtimeEnv: {
@@ -273,6 +280,11 @@ export const env = createEnv({
CACHE_TIME_HOTELDATA: process.env.CACHE_TIME_HOTELDATA, CACHE_TIME_HOTELDATA: process.env.CACHE_TIME_HOTELDATA,
CACHE_TIME_HOTELS: process.env.CACHE_TIME_HOTELS, CACHE_TIME_HOTELS: process.env.CACHE_TIME_HOTELS,
CACHE_TIME_CITY_SEARCH: process.env.CACHE_TIME_CITY_SEARCH, 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,
}, },
}) })

View File

@@ -1,6 +1,7 @@
import * as Sentry from "@sentry/nextjs" import * as Sentry from "@sentry/nextjs"
import { env } from "./env/server" import { env } from "./env/server"
import { isEdge } from "./utils/isEdge"
export async function register() { export async function register() {
/* /*
@@ -45,12 +46,9 @@ async function configureApplicationInsights() {
} }
async function configureSentry() { async function configureSentry() {
switch (process.env.NEXT_RUNTIME) { if (isEdge) {
case "edge": {
await import("./sentry.edge.config") await import("./sentry.edge.config")
} } else {
case "nodejs": {
await import("./sentry.server.config") await import("./sentry.server.config")
} }
}
} }

View File

@@ -9,14 +9,20 @@ import { request } from "./request"
import type { BatchRequestDocument } from "graphql-request" import type { BatchRequestDocument } from "graphql-request"
import type { Data } from "@/types/request" import type { Data } from "@/types/request"
import type { CacheTime } from "@/services/dataCache"
export async function batchRequest<T>( export async function batchRequest<T>(
queries: (BatchRequestDocument & { options?: RequestInit })[] queries: (BatchRequestDocument & {
cacheOptions?: {
key: string | string[]
ttl: CacheTime
}
})[]
): Promise<Data<T>> { ): Promise<Data<T>> {
try { try {
const response = await Promise.allSettled( const response = await Promise.allSettled(
queries.map((query) => queries.map((query) =>
request<T>(query.document, query.variables, query.options) request<T>(query.document, query.variables, query.cacheOptions)
) )
) )

View File

@@ -1,10 +1,12 @@
import fetchRetry from "fetch-retry" import fetchRetry from "fetch-retry"
import { GraphQLClient } from "graphql-request" import { GraphQLClient } from "graphql-request"
import { cache } from "react" import { cache as reactCache } from "react"
import { env } from "@/env/server" import { env } from "@/env/server"
import { getPreviewHash, isPreviewByUid } from "@/lib/previewContext" import { getPreviewHash, isPreviewByUid } from "@/lib/previewContext"
import { type CacheTime, getCacheClient } from "@/services/dataCache"
import { request as _request } from "./_request" import { request as _request } from "./_request"
import type { DocumentNode } from "graphql" import type { DocumentNode } from "graphql"
@@ -14,7 +16,28 @@ import type { Data } from "@/types/request"
export async function request<T>( export async function request<T>(
query: string | DocumentNode, query: string | DocumentNode,
variables?: Record<string, any>, 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>> { ): Promise<Data<T>> {
const shouldUsePreview = variables?.uid const shouldUsePreview = variables?.uid
? isPreviewByUid(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 // Creating a new client for each request to avoid conflicting parameters
const client = new GraphQLClient(cmsUrl, { 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, { const wrappedFetch = fetchRetry(fetch, {
retries: 3, retries: 3,
retryDelay: function (attempt) { retryDelay: function (attempt) {
@@ -38,16 +64,12 @@ export async function request<T>(
const mergedParams = const mergedParams =
shouldUsePreview && previewHash shouldUsePreview && previewHash
? { ? {
...params,
headers: { headers: {
...params?.headers,
live_preview: previewHash, live_preview: previewHash,
preview_token: env.CMS_PREVIEW_TOKEN, preview_token: env.CMS_PREVIEW_TOKEN,
}, },
cache: undefined,
next: undefined,
} }
: params : {}
return _request(client, query, variables, mergedParams) return _request(client, query, variables, mergedParams)
} }

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 { REDEMPTION, SEARCHTYPE } from "@/constants/booking"
import { login } from "@/constants/routes/handleAuth" import { login } from "@/constants/routes/handleAuth"

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" import { Lang } from "@/constants/languages"

View File

@@ -40,4 +40,4 @@ schedule = "@daily"
[[headers]] [[headers]]
for = "/_next/static/*" for = "/_next/static/*"
[headers.values] [headers.values]
cache-control = "public, max-age=31536001, immutable" cache-control = "public, max-age=31536000, immutable"

View File

@@ -13,6 +13,10 @@ jiti("./env/client")
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
env: {
BRANCH: process.env.BRANCH || "local",
GIT_SHA: process.env.COMMIT_REF || "",
},
poweredByHeader: false, poweredByHeader: false,
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
trailingSlash: false, trailingSlash: false,

View File

@@ -77,6 +77,7 @@
"ics": "^3.8.1", "ics": "^3.8.1",
"immer": "10.1.1", "immer": "10.1.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"ioredis": "^5.5.0",
"json-stable-stringify-without-jsonify": "^1.0.1", "json-stable-stringify-without-jsonify": "^1.0.1",
"libphonenumber-js": "^1.10.60", "libphonenumber-js": "^1.10.60",
"nanoid": "^5.0.9", "nanoid": "^5.0.9",

View File

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

View File

@@ -20,6 +20,7 @@ import { notFound } from "@/server/errors/trpc"
import { contentstackBaseProcedure, router } from "@/server/trpc" import { contentstackBaseProcedure, router } from "@/server/trpc"
import { langInput } from "@/server/utils" import { langInput } from "@/server/utils"
import { getCacheClient } from "@/services/dataCache"
import { import {
generateRefsResponseTag, generateRefsResponseTag,
generateTag, generateTag,
@@ -107,10 +108,8 @@ const getContactConfig = cache(async (lang: Lang) => {
locale: lang, locale: lang,
}, },
{ {
cache: "force-cache", key: `${lang}:contact`,
next: { ttl: "max",
tags: [`${lang}:contact`],
},
} }
) )
@@ -176,10 +175,8 @@ export const baseQueryRouter = router({
locale: lang, locale: lang,
}, },
{ {
cache: "force-cache", key: generateRefsResponseTag(lang, "header"),
next: { ttl: "max",
tags: [generateRefsResponseTag(lang, "header")],
},
} }
) )
@@ -244,7 +241,7 @@ export const baseQueryRouter = router({
const response = await request<GetHeaderData>( const response = await request<GetHeaderData>(
GetHeader, GetHeader,
{ locale: lang }, { locale: lang },
{ cache: "force-cache", next: { tags } } { key: tags, ttl: "max" }
) )
if (!response.data) { if (!response.data) {
@@ -305,10 +302,8 @@ export const baseQueryRouter = router({
locale: input.lang, locale: input.lang,
}, },
{ {
cache: "force-cache", key: generateRefsResponseTag(input.lang, "current_header"),
next: { ttl: "max",
tags: [generateRefsResponseTag(input.lang, "current_header")],
},
} }
) )
getCurrentHeaderCounter.add(1, { lang: input.lang }) getCurrentHeaderCounter.add(1, { lang: input.lang })
@@ -326,10 +321,8 @@ export const baseQueryRouter = router({
GetCurrentHeader, GetCurrentHeader,
{ locale: input.lang }, { locale: input.lang },
{ {
cache: "force-cache", key: generateTag(input.lang, currentHeaderUID),
next: { ttl: "max",
tags: [generateTag(input.lang, currentHeaderUID)],
},
} }
) )
@@ -397,10 +390,8 @@ export const baseQueryRouter = router({
locale: input.lang, locale: input.lang,
}, },
{ {
cache: "force-cache", key: generateRefsResponseTag(input.lang, "current_footer"),
next: { ttl: "max",
tags: [generateRefsResponseTag(input.lang, "current_footer")],
},
} }
) )
// There's currently no error handling/validation for the responseRef, should it be added? // 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, locale: input.lang,
}, },
{ {
cache: "force-cache", key: generateTag(input.lang, currentFooterUID),
next: { ttl: "max",
tags: [generateTag(input.lang, currentFooterUID)],
},
} }
) )
@@ -486,10 +475,8 @@ export const baseQueryRouter = router({
locale: lang, locale: lang,
}, },
{ {
cache: "force-cache", key: generateRefsResponseTag(lang, "footer"),
next: { ttl: "max",
tags: [generateRefsResponseTag(lang, "footer")],
},
} }
) )
@@ -563,10 +550,8 @@ export const baseQueryRouter = router({
locale: lang, locale: lang,
}, },
{ {
cache: "force-cache", key: tags,
next: { ttl: "max",
tags,
},
} }
) )
@@ -620,7 +605,10 @@ export const baseQueryRouter = router({
.input(langInput) .input(langInput)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const lang = input.lang ?? ctx.lang const lang = input.lang ?? ctx.lang
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
generateRefsResponseTag(lang, "site_config", "root"),
async () => {
getSiteConfigRefCounter.add(1, { lang }) getSiteConfigRefCounter.add(1, { lang })
console.info( console.info(
"contentstack.siteConfig.ref start", "contentstack.siteConfig.ref start",
@@ -632,10 +620,8 @@ export const baseQueryRouter = router({
locale: lang, locale: lang,
}, },
{ {
cache: "force-cache", key: generateRefsResponseTag(lang, "site_config"),
next: { ttl: "max",
tags: [generateRefsResponseTag(lang, "site_config")],
},
} }
) )
@@ -680,8 +666,11 @@ export const baseQueryRouter = router({
return null return null
} }
const connections = getSiteConfigConnections(validatedSiteConfigRef.data) const connections = getSiteConfigConnections(
const siteConfigUid = responseRef.data.all_site_config.items[0].system.uid validatedSiteConfigRef.data
)
const siteConfigUid =
responseRef.data.all_site_config.items[0].system.uid
const tags = [ const tags = [
generateTagsFromSystem(lang, connections), generateTagsFromSystem(lang, connections),
@@ -706,8 +695,8 @@ export const baseQueryRouter = router({
locale: lang, locale: lang,
}, },
{ {
cache: "force-cache", key: tags,
next: { tags }, ttl: "max",
} }
), ),
getContactConfig(lang), getContactConfig(lang),
@@ -772,5 +761,8 @@ export const baseQueryRouter = router({
} }
: null, : null,
} }
},
"max"
)
}), }),
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { env } from "@/env/server"
import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql" import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql"
import { GetCountryPageUrls } from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPageUrl.graphql" import { GetCountryPageUrls } from "@/lib/graphql/Query/DestinationCountryPage/DestinationCountryPageUrl.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { toApiLang } from "@/server/utils"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag" import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
@@ -20,7 +18,6 @@ import {
import { ApiCountry, type Country } from "@/types/enums/country" import { ApiCountry, type Country } from "@/types/enums/country"
import { DestinationCountryPageEnum } from "@/types/enums/destinationCountryPage" import { DestinationCountryPageEnum } from "@/types/enums/destinationCountryPage"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { System } from "@/types/requests/system" import type { System } from "@/types/requests/system"
import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage" import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type { import type {
@@ -85,7 +82,7 @@ export async function getCityListDataByCityIdentifier(
"contentstack.cityListData start", "contentstack.cityListData start",
JSON.stringify({ query: { lang, cityIdentifier } }) JSON.stringify({ query: { lang, cityIdentifier } })
) )
const tag = `${lang}:city_list_data:${cityIdentifier}`
const response = await request<GetDestinationCityListDataResponse>( const response = await request<GetDestinationCityListDataResponse>(
GetDestinationCityListData, GetDestinationCityListData,
{ {
@@ -93,10 +90,8 @@ export async function getCityListDataByCityIdentifier(
cityIdentifier, cityIdentifier,
}, },
{ {
cache: "force-cache", key: `${lang}:city_list_data:${cityIdentifier}`,
next: { ttl: "max",
tags: [tag],
},
} }
) )
@@ -148,23 +143,12 @@ export async function getCityPages(
serviceToken: string, serviceToken: string,
country: Country 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 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) const publishedCities = cities[apiCountry].filter((city) => city.isPublished)
@@ -201,10 +185,8 @@ export async function getCountryPageUrls(lang: Lang) {
locale: lang, locale: lang,
}, },
{ {
cache: "force-cache", key: tag,
next: { ttl: "max",
tags: [tag],
},
} }
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { ApiLang, type Lang } from "@/constants/languages" import { type Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { getFiltersFromHotels } from "@/stores/destination-data/helper" import { getFiltersFromHotels } from "@/stores/destination-data/helper"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
@@ -12,7 +11,6 @@ import {
} from "../../hotels/utils" } from "../../hotels/utils"
import { ApiCountry } from "@/types/enums/country" import { ApiCountry } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { RTETypeEnum } from "@/types/rte/enums" import { RTETypeEnum } from "@/types/rte/enums"
import type { import type {
MetadataInputSchema, MetadataInputSchema,
@@ -218,17 +216,19 @@ export async function getCityData(
const cityIdentifier = cities[0] const cityIdentifier = cities[0]
if (cityIdentifier) { if (cityIdentifier) {
const cityData = await getCityByCityIdentifier( const cityData = await getCityByCityIdentifier({
cityIdentifier, cityIdentifier,
serviceToken serviceToken,
) lang,
})
const hotelIds = await getHotelIdsByCityIdentifier( const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier, cityIdentifier,
serviceToken serviceToken
) )
const hotels = await getHotelsByHotelIds(hotelIds, lang, serviceToken)
let filterType
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
let filterType
if (filter) { if (filter) {
const allFilters = getFiltersFromHotels(hotels) const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find( const facilityFilter = allFilters.facilityFilters.find(
@@ -264,28 +264,12 @@ export async function getCountryData(
const translatedCountry = ApiCountry[lang][country] const translatedCountry = ApiCountry[lang][country]
let filterType let filterType
const options: RequestOptionsWithOutBody = { const hotelIds = await getHotelIdsByCountry({
// 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,
country, 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) { if (filter) {
const allFilters = getFiltersFromHotels(hotels) const allFilters = getFiltersFromHotels(hotels)

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ import {
} from "@/server/trpc" } from "@/server/trpc"
import { langInput } from "@/server/utils" import { langInput } from "@/server/utils"
import { getCacheClient } from "@/services/dataCache"
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query" import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import { import {
rewardsAllInput, rewardsAllInput,
@@ -46,8 +48,6 @@ import {
getUnwrapSurpriseSuccessCounter, getUnwrapSurpriseSuccessCounter,
} from "./utils" } from "./utils"
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({ export const rewardQueryRouter = router({
all: contentStackBaseWithServiceProcedure all: contentStackBaseWithServiceProcedure
.input(rewardsAllInput) .input(rewardsAllInput)
@@ -174,12 +174,15 @@ export const rewardQueryRouter = router({
? api.endpoints.v1.Profile.Reward.reward ? api.endpoints.v1.Profile.Reward.reward
: api.endpoints.v1.Profile.reward : api.endpoints.v1.Profile.reward
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
endpoint,
async () => {
const apiResponse = await api.get(endpoint, { const apiResponse = await api.get(endpoint, {
cache: undefined, // override defaultOptions
headers: { headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`, Authorization: `Bearer ${ctx.session.token.access_token}`,
}, },
next: { revalidate: ONE_HOUR },
}) })
if (!apiResponse.ok) { if (!apiResponse.ok) {
@@ -292,13 +295,18 @@ export const rewardQueryRouter = router({
: [], : [],
couponCode: firstRedeemableCouponToExpire, couponCode: firstRedeemableCouponToExpire,
coupons: coupons:
apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [], apiReward && "coupon" in apiReward
? apiReward.coupon || []
: [],
} }
}) })
getCurrentRewardSuccessCounter.add(1) getCurrentRewardSuccessCounter.add(1)
return { rewards } return { rewards }
},
"1h"
)
}), }),
surprises: contentStackBaseWithProtectedProcedure surprises: contentStackBaseWithProtectedProcedure
.input(langInput.optional()) // lang is required for client, but not for server .input(langInput.optional()) // lang is required for client, but not for server
@@ -310,12 +318,15 @@ export const rewardQueryRouter = router({
? api.endpoints.v1.Profile.Reward.reward ? api.endpoints.v1.Profile.Reward.reward
: api.endpoints.v1.Profile.reward : api.endpoints.v1.Profile.reward
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
endpoint,
async () => {
const apiResponse = await api.get(endpoint, { const apiResponse = await api.get(endpoint, {
cache: undefined, cache: undefined,
headers: { headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`, Authorization: `Bearer ${ctx.session.token.access_token}`,
}, },
next: { revalidate: ONE_HOUR },
}) })
if (!apiResponse.ok) { if (!apiResponse.ok) {
@@ -410,14 +421,17 @@ export const rewardQueryRouter = router({
rewardType: surprise.rewardType, rewardType: surprise.rewardType,
rewardTierLevel: undefined, rewardTierLevel: undefined,
redeemLocation: surprise.redeemLocation, redeemLocation: surprise.redeemLocation,
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
categories: categories:
"categories" in surprise ? surprise.categories || [] : [], "categories" in surprise ? surprise.categories || [] : [],
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
} }
}) })
.flatMap((surprises) => (surprises ? [surprises] : [])) .flatMap((surprises) => (surprises ? [surprises] : []))
return surprises return surprises
},
"1h"
)
}), }),
unwrap: protectedProcedure unwrap: protectedProcedure
.input(rewardsUpdateInput) .input(rewardsUpdateInput)

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,3 @@
import { unstable_cache } from "next/cache"
import { ApiLang } from "@/constants/languages"
import { env } from "@/env/server" import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
@@ -16,6 +13,7 @@ import {
import { toApiLang } from "@/server/utils" import { toApiLang } from "@/server/utils"
import { generateChildrenString } from "@/components/HotelReservation/utils" import { generateChildrenString } from "@/components/HotelReservation/utils"
import { getCacheClient } from "@/services/dataCache"
import { cache } from "@/utils/cache" import { cache } from "@/utils/cache"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils" import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
@@ -67,7 +65,6 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType" import { HotelTypeEnum } from "@/types/enums/hotelType"
import { RateTypeEnum } from "@/types/enums/rateType" import { RateTypeEnum } from "@/types/enums/rateType"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { HotelDataWithUrl } from "@/types/hotel" import type { HotelDataWithUrl } from "@/types/hotel"
import type { import type {
HotelsAvailabilityInputSchema, HotelsAvailabilityInputSchema,
@@ -78,8 +75,7 @@ import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
export const getHotel = cache( export const getHotel = cache(
async (input: HotelInput, serviceToken: string) => { async (input: HotelInput, serviceToken: string) => {
const callable = unstable_cache( const callable = async function (
async function (
hotelId: HotelInput["hotelId"], hotelId: HotelInput["hotelId"],
language: HotelInput["language"], language: HotelInput["language"],
isCardOnlyPayment?: HotelInput["isCardOnlyPayment"] isCardOnlyPayment?: HotelInput["isCardOnlyPayment"]
@@ -113,13 +109,6 @@ export const getHotel = cache(
headers: { headers: {
Authorization: `Bearer ${serviceToken}`, Authorization: `Bearer ${serviceToken}`,
}, },
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
next: {
revalidate: env.CACHE_TIME_HOTELS,
tags: [`${language}:hotel:${hotelId}`],
},
}, },
params params
) )
@@ -198,17 +187,16 @@ export const getHotel = cache(
} }
return hotelData return hotelData
},
[`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`],
{
revalidate: env.CACHE_TIME_HOTELS,
tags: [
`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
],
} }
)
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
async () => {
return callable(input.hotelId, input.language, input.isCardOnlyPayment) return callable(input.hotelId, input.language, input.isCardOnlyPayment)
},
"1d"
)
} }
) )
@@ -226,7 +214,10 @@ export const getHotelsAvailabilityByCity = async (
bookingCode, bookingCode,
redemption, redemption,
} = input } = input
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${cityId}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`,
async () => {
const params: Record<string, string | number> = { const params: Record<string, string | number> = {
roomStayStartDate, roomStayStartDate,
roomStayEndDate, roomStayEndDate,
@@ -252,13 +243,9 @@ export const getHotelsAvailabilityByCity = async (
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.Availability.city(cityId), api.endpoints.v1.Availability.city(cityId),
{ {
cache: undefined,
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
}, },
params params
) )
@@ -271,7 +258,6 @@ export const getHotelsAvailabilityByCity = async (
adults, adults,
children, children,
bookingCode, bookingCode,
redemption,
error_type: "http_error", error_type: "http_error",
error: JSON.stringify({ error: JSON.stringify({
status: apiResponse.status, status: apiResponse.status,
@@ -290,10 +276,13 @@ export const getHotelsAvailabilityByCity = async (
}, },
}) })
) )
return null
throw new Error("Failed to fetch hotels availability by city")
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) const validateAvailabilityData =
hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) { if (!validateAvailabilityData.success) {
metrics.hotelsAvailability.fail.add(1, { metrics.hotelsAvailability.fail.add(1, {
cityId, cityId,
@@ -335,6 +324,9 @@ export const getHotelsAvailabilityByCity = async (
(hotels) => hotels.attributes (hotels) => hotels.attributes
), ),
} }
},
"1h"
)
} }
export const getHotelsAvailabilityByHotelIds = async ( export const getHotelsAvailabilityByHotelIds = async (
@@ -351,12 +343,6 @@ export const getHotelsAvailabilityByHotelIds = async (
bookingCode, bookingCode,
} = input } = input
/**
* Since API expects the params appended and not just
* a comma separated string we need to initialize the
* SearchParams with a sequence of pairs
* (hotelIds=810&hotelIds=879&hotelIds=222 etc.)
**/
const params = new URLSearchParams([ const params = new URLSearchParams([
["roomStayStartDate", roomStayStartDate], ["roomStayStartDate", roomStayStartDate],
["roomStayEndDate", roomStayEndDate], ["roomStayEndDate", roomStayEndDate],
@@ -365,7 +351,21 @@ export const getHotelsAvailabilityByHotelIds = async (
["bookingCode", bookingCode], ["bookingCode", bookingCode],
["language", apiLang], ["language", apiLang],
]) ])
hotelIds.forEach((hotelId) => params.append("hotelIds", hotelId.toString()))
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
`${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`,
async () => {
/**
* Since API expects the params appended and not just
* a comma separated string we need to initialize the
* SearchParams with a sequence of pairs
* (hotelIds=810&hotelIds=879&hotelIds=222 etc.)
**/
hotelIds.forEach((hotelId) =>
params.append("hotelIds", hotelId.toString())
)
metrics.hotelsByHotelIdAvailability.counter.add(1, { metrics.hotelsByHotelIdAvailability.counter.add(1, {
hotelIds, hotelIds,
roomStayStartDate, roomStayStartDate,
@@ -381,13 +381,9 @@ export const getHotelsAvailabilityByHotelIds = async (
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.Availability.hotels(), api.endpoints.v1.Availability.hotels(),
{ {
cache: undefined,
headers: { headers: {
Authorization: `Bearer ${serviceToken}`, Authorization: `Bearer ${serviceToken}`,
}, },
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
}, },
params params
) )
@@ -418,10 +414,12 @@ export const getHotelsAvailabilityByHotelIds = async (
}, },
}) })
) )
return null
throw new Error("Failed to fetch hotels availability by hotelIds")
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) const validateAvailabilityData =
hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) { if (!validateAvailabilityData.success) {
metrics.hotelsByHotelIdAvailability.fail.add(1, { metrics.hotelsByHotelIdAvailability.fail.add(1, {
hotelIds, hotelIds,
@@ -461,6 +459,9 @@ export const getHotelsAvailabilityByHotelIds = async (
(hotels) => hotels.attributes (hotels) => hotels.attributes
), ),
} }
},
env.CACHE_TIME_CITY_SEARCH
)
} }
export const hotelQueryRouter = router({ export const hotelQueryRouter = router({
@@ -654,7 +655,8 @@ export const hotelQueryRouter = router({
}, },
}) })
) )
return null
throw new Error("Failed to fetch selected room availability")
} }
const apiJsonAvailability = await apiResponseAvailability.json() const apiJsonAvailability = await apiResponseAvailability.json()
const validateAvailabilityData = const validateAvailabilityData =
@@ -913,28 +915,12 @@ export const hotelQueryRouter = router({
const { lang, serviceToken } = ctx const { lang, serviceToken } = ctx
const { country } = input const { country } = input
const options: RequestOptionsWithOutBody = { const hotelIds = await getHotelIdsByCountry({
// 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,
country, country,
serviceToken: ctx.serviceToken,
}) })
const hotelIds = await getHotelIdsByCountry(
country,
options,
hotelIdsParams
)
return await getHotelsByHotelIds(hotelIds, lang, serviceToken) return await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
}), }),
}), }),
byCityIdentifier: router({ byCityIdentifier: router({
@@ -949,7 +935,7 @@ export const hotelQueryRouter = router({
serviceToken serviceToken
) )
return await getHotelsByHotelIds(hotelIds, lang, serviceToken) return await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
}), }),
}), }),
byCSFilter: router({ byCSFilter: router({
@@ -959,19 +945,6 @@ export const hotelQueryRouter = router({
const { locationFilter, hotelsToInclude } = input const { locationFilter, hotelsToInclude } = input
const language = ctx.lang const language = ctx.lang
const apiLang = toApiLang(language)
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,
},
}
let hotelsToFetch: string[] = [] let hotelsToFetch: string[] = []
metrics.hotels.counter.add(1, { metrics.hotels.counter.add(1, {
@@ -991,15 +964,11 @@ export const hotelQueryRouter = router({
if (hotelsToInclude.length) { if (hotelsToInclude.length) {
hotelsToFetch = hotelsToInclude hotelsToFetch = hotelsToInclude
} else if (locationFilter?.city) { } else if (locationFilter?.city) {
const locationsParams = new URLSearchParams({ const locations = await getLocations({
language: apiLang, lang: language,
serviceToken: ctx.serviceToken,
citiesByCountry: null,
}) })
const locations = await getLocations(
language,
options,
locationsParams,
null
)
if (!locations || "error" in locations) { if (!locations || "error" in locations) {
return [] return []
} }
@@ -1028,15 +997,11 @@ export const hotelQueryRouter = router({
) )
return [] return []
} }
const hotelIdsParams = new URLSearchParams({
language: apiLang, const hotelIds = await getHotelIdsByCityId({
city: cityId,
})
const hotelIds = await getHotelIdsByCityId(
cityId, cityId,
options, serviceToken: ctx.serviceToken,
hotelIdsParams })
)
if (!hotelIds?.length) { if (!hotelIds?.length) {
metrics.hotels.fail.add(1, { metrics.hotels.fail.add(1, {
@@ -1062,15 +1027,10 @@ export const hotelQueryRouter = router({
hotelsToFetch = filteredHotelIds hotelsToFetch = filteredHotelIds
} else if (locationFilter?.country) { } else if (locationFilter?.country) {
const hotelIdsParams = new URLSearchParams({ const hotelIds = await getHotelIdsByCountry({
language: ApiLang.En,
country: locationFilter.country, country: locationFilter.country,
serviceToken: ctx.serviceToken,
}) })
const hotelIds = await getHotelIdsByCountry(
locationFilter.country,
options,
hotelIdsParams
)
if (!hotelIds?.length) { if (!hotelIds?.length) {
metrics.hotels.fail.add(1, { metrics.hotels.fail.add(1, {
@@ -1154,43 +1114,29 @@ export const hotelQueryRouter = router({
}), }),
getAllHotels: router({ getAllHotels: router({
get: serviceProcedure.query(async function ({ ctx }) { get: serviceProcedure.query(async function ({ ctx }) {
const apiLang = toApiLang(ctx.lang) const countries = await getCountries({
const params = new URLSearchParams({ lang: ctx.lang,
language: apiLang, 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)
if (!countries) { if (!countries) {
return null return null
} }
const countryNames = countries.data.map((country) => country.name) const countryNames = countries.data.map((country) => country.name)
const hotelData: HotelDataWithUrl[] = ( const hotelData: HotelDataWithUrl[] = (
await Promise.all( await Promise.all(
countryNames.map(async (country) => { countryNames.map(async (country) => {
const countryParams = new URLSearchParams({ const hotelIds = await getHotelIdsByCountry({
country: country,
})
const hotelIds = await getHotelIdsByCountry(
country, country,
options, serviceToken: ctx.serviceToken,
countryParams })
)
const hotels = await getHotelsByHotelIds( const hotels = await getHotelsByHotelIds({
hotelIds, hotelIds,
ctx.lang, lang: ctx.lang,
ctx.serviceToken serviceToken: ctx.serviceToken,
) })
return hotels return hotels
}) })
) )
@@ -1280,50 +1226,43 @@ export const hotelQueryRouter = router({
}), }),
locations: router({ locations: router({
get: serviceProcedure.input(getLocationsInput).query(async function ({ get: serviceProcedure.input(getLocationsInput).query(async function ({
input,
ctx, ctx,
input,
}) { }) {
const lang = input.lang ?? ctx.lang const lang = input.lang ?? ctx.lang
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${ctx.lang}:getLocations`,
async () => {
const countries = await getCountries({
lang: lang,
serviceToken: ctx.serviceToken,
})
const searchParams = new URLSearchParams()
searchParams.set("language", toApiLang(lang))
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, searchParams, lang)
if (!countries) { if (!countries) {
throw new Error("Unable to fetch countries") throw new Error("Unable to fetch countries")
} }
const countryNames = countries.data.map((country) => country.name) const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry( const citiesByCountry = await getCitiesByCountry({
countryNames, countries: countryNames,
options, serviceToken: ctx.serviceToken,
searchParams,
lang
)
const locations = await getLocations(
lang, lang,
options, })
searchParams,
citiesByCountry const locations = await getLocations({
) lang,
serviceToken: ctx.serviceToken,
citiesByCountry,
})
if (!locations || "error" in locations) { if (!locations || "error" in locations) {
throw new Error("Unable to fetch locations") throw new Error("Unable to fetch locations")
} }
return locations return locations
},
"max"
)
}), }),
}), }),
map: router({ map: router({
@@ -1380,16 +1319,16 @@ export const hotelQueryRouter = router({
JSON.stringify({ query: { hotelId, params } }) JSON.stringify({ query: { hotelId, params } })
) )
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
`${language}:hotels:meetingRooms:${hotelId}`,
async () => {
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.meetingRooms(input.hotelId), api.endpoints.v1.Hotel.Hotels.meetingRooms(input.hotelId),
{ {
cache: undefined,
headers: { headers: {
Authorization: `Bearer ${ctx.serviceToken}`, Authorization: `Bearer ${ctx.serviceToken}`,
}, },
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}, },
params params
) )
@@ -1416,7 +1355,8 @@ export const hotelQueryRouter = router({
}, },
}) })
) )
return []
throw new Error("Failed to fetch meeting rooms")
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
@@ -1430,7 +1370,7 @@ export const hotelQueryRouter = router({
error: validatedMeetingRooms.error, error: validatedMeetingRooms.error,
}) })
) )
return [] throw badRequestError()
} }
metrics.meetingRooms.success.add(1, { metrics.meetingRooms.success.add(1, {
hotelId, hotelId,
@@ -1441,6 +1381,9 @@ export const hotelQueryRouter = router({
) )
return validatedMeetingRooms.data.data return validatedMeetingRooms.data.data
},
env.CACHE_TIME_HOTELS
)
}), }),
additionalData: safeProtectedServiceProcedure additionalData: safeProtectedServiceProcedure
.input(getAdditionalDataInputSchema) .input(getAdditionalDataInputSchema)
@@ -1458,16 +1401,16 @@ export const hotelQueryRouter = router({
JSON.stringify({ query: { hotelId, params } }) JSON.stringify({ query: { hotelId, params } })
) )
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
`${language}:hotels:additionalData:${hotelId}`,
async () => {
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.additionalData(input.hotelId), api.endpoints.v1.Hotel.Hotels.additionalData(input.hotelId),
{ {
cache: undefined,
headers: { headers: {
Authorization: `Bearer ${ctx.serviceToken}`, Authorization: `Bearer ${ctx.serviceToken}`,
}, },
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}, },
params params
) )
@@ -1494,11 +1437,13 @@ export const hotelQueryRouter = router({
}, },
}) })
) )
return null
throw new Error("Unable to fetch additional data for hotel")
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const validatedAdditionalData = additionalDataSchema.safeParse(apiJson) const validatedAdditionalData =
additionalDataSchema.safeParse(apiJson)
if (!validatedAdditionalData.success) { if (!validatedAdditionalData.success) {
console.error( console.error(
@@ -1519,6 +1464,9 @@ export const hotelQueryRouter = router({
) )
return validatedAdditionalData.data return validatedAdditionalData.data
},
env.CACHE_TIME_HOTELS
)
}), }),
packages: router({ packages: router({
get: serviceProcedure get: serviceProcedure
@@ -1628,16 +1576,16 @@ export const hotelQueryRouter = router({
JSON.stringify({ query: metricsData }) JSON.stringify({ query: metricsData })
) )
const cacheClient = await getCacheClient()
const breakfastPackages = await cacheClient.cacheOrGet(
`${apiLang}:adults${input.adults}:startDate:${params.StartDate}:endDate:${params.EndDate}`,
async () => {
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.Package.Breakfast.hotel(input.hotelId), api.endpoints.v1.Package.Breakfast.hotel(input.hotelId),
{ {
cache: undefined,
headers: { headers: {
Authorization: `Bearer ${ctx.serviceToken}`, Authorization: `Bearer ${ctx.serviceToken}`,
}, },
next: {
revalidate: 60,
},
}, },
params params
) )
@@ -1664,7 +1612,7 @@ export const hotelQueryRouter = router({
}, },
}) })
) )
return null throw new Error("Unable to fetch breakfast packages")
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
@@ -1682,7 +1630,8 @@ export const hotelQueryRouter = router({
error: breakfastPackages.error, error: breakfastPackages.error,
}) })
) )
return null
throw new Error("Unable to parse breakfast packages")
} }
metrics.breakfastPackage.success.add(1, metricsData) metrics.breakfastPackage.success.add(1, metricsData)
@@ -1693,6 +1642,11 @@ export const hotelQueryRouter = router({
}) })
) )
return breakfastPackages.data
},
"1h"
)
if (ctx.session?.token) { if (ctx.session?.token) {
const apiUser = await getVerifiedUser({ session: ctx.session }) const apiUser = await getVerifiedUser({ session: ctx.session })
if (apiUser && !("error" in apiUser)) { if (apiUser && !("error" in apiUser)) {
@@ -1701,7 +1655,7 @@ export const hotelQueryRouter = router({
user.membership && user.membership &&
["L6", "L7"].includes(user.membership.membershipLevel) ["L6", "L7"].includes(user.membership.membershipLevel)
) { ) {
const freeBreakfastPackage = breakfastPackages.data.find( const freeBreakfastPackage = breakfastPackages.find(
(pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST (pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
) )
if (freeBreakfastPackage?.localPrice) { if (freeBreakfastPackage?.localPrice) {
@@ -1711,7 +1665,7 @@ export const hotelQueryRouter = router({
} }
} }
return breakfastPackages.data.filter( return breakfastPackages.filter(
(pkg) => pkg.code !== BreakfastPackageEnum.FREE_MEMBER_BREAKFAST (pkg) => pkg.code !== BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
) )
}), }),
@@ -1727,23 +1681,22 @@ export const hotelQueryRouter = router({
language: apiLang, language: apiLang,
} }
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${apiLang}:hotel:${input.hotelId}:ancillaries:startDate:${params.StartDate}:endDate:${params.EndDate}`,
async () => {
const metricsData = { ...params, hotelId: input.hotelId } const metricsData = { ...params, hotelId: input.hotelId }
metrics.ancillaryPackage.counter.add(1, metricsData) metrics.ancillaryPackage.counter.add(1, metricsData)
console.info( console.info(
"api.package.ancillary start", "api.package.ancillary start",
JSON.stringify({ query: metricsData }) JSON.stringify({ query: metricsData })
) )
const apiResponse = await api.get( const apiResponse = await api.get(
api.endpoints.v1.Package.Ancillary.hotel(input.hotelId), api.endpoints.v1.Package.Ancillary.hotel(input.hotelId),
{ {
cache: undefined,
headers: { headers: {
Authorization: `Bearer ${ctx.serviceToken}`, Authorization: `Bearer ${ctx.serviceToken}`,
}, },
next: {
revalidate: 60,
},
}, },
params params
) )
@@ -1799,6 +1752,9 @@ export const hotelQueryRouter = router({
}) })
) )
return ancillaryPackages.data return ancillaryPackages.data
},
"1h"
)
}), }),
}), }),
}) })

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { publicProcedure, router } from "@/server/trpc" import { publicProcedure, router } from "@/server/trpc"
import { getCacheClient } from "@/services/dataCache"
import { jobylonFeedSchema } from "./output" import { jobylonFeedSchema } from "./output"
import { import {
getJobylonFeedCounter, getJobylonFeedCounter,
@@ -29,11 +31,12 @@ export const jobylonQueryRouter = router({
JSON.stringify({ query: { url: urlString } }) JSON.stringify({ query: { url: urlString } })
) )
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
"jobylon:feed",
async () => {
const response = await fetch(url, { const response = await fetch(url, {
cache: "force-cache", cache: "no-cache",
next: {
revalidate: TWENTYFOUR_HOURS,
},
}) })
if (!response.ok) { if (!response.ok) {
@@ -55,7 +58,10 @@ export const jobylonQueryRouter = router({
error, error,
}) })
) )
return null
throw new Error(
`Failed to fetch Jobylon feed: ${JSON.stringify(error)}`
)
} }
const responseJson = await response.json() const responseJson = await response.json()
@@ -68,14 +74,15 @@ export const jobylonQueryRouter = router({
error: JSON.stringify(validatedResponse.error), error: JSON.stringify(validatedResponse.error),
}) })
console.error( const errorData = JSON.stringify({
"jobylon.feed error",
JSON.stringify({
query: { url: urlString }, query: { url: urlString },
error: validatedResponse.error, error: validatedResponse.error,
}) })
console.error("jobylon.feed error", errorData)
throw new Error(
`Failed to parse Jobylon feed: ${JSON.stringify(errorData)}`
) )
return null
} }
getJobylonFeedSuccessCounter.add(1, { getJobylonFeedSuccessCounter.add(1, {
@@ -89,6 +96,9 @@ export const jobylonQueryRouter = router({
) )
return validatedResponse.data return validatedResponse.data
},
"1d"
)
}), }),
}), }),
}) })

View File

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

View File

@@ -1,9 +1,8 @@
import { metrics } from "@opentelemetry/api" import { metrics, trace } from "@opentelemetry/api"
import { revalidateTag, unstable_cache } from "next/cache"
import { env } from "@/env/server" import { env } from "@/env/server"
import { generateServiceTokenTag } from "@/utils/generateTag" import { getCacheClient } from "@/services/dataCache"
import type { ServiceTokenResponse } from "@/types/tokens" import type { ServiceTokenResponse } from "@/types/tokens"
@@ -12,13 +11,49 @@ const meter = metrics.getMeter("trpc.context.serviceToken")
const fetchServiceTokenCounter = meter.createCounter( const fetchServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-new-token" "trpc.context.serviceToken.fetch-new-token"
) )
const fetchTempServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-temporary"
)
const fetchServiceTokenFailCounter = meter.createCounter( const fetchServiceTokenFailCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-fail" "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[]) { async function fetchServiceToken(scopes: string[]) {
fetchServiceTokenCounter.add(1) fetchServiceTokenCounter.add(1)
@@ -69,41 +104,6 @@ async function fetchServiceToken(scopes: string[]) {
return response.json() as Promise<ServiceTokenResponse> return response.json() as Promise<ServiceTokenResponse>
} }
export async function getServiceToken() { function getServiceTokenCacheKey(scopes: string[]): string {
let scopes: string[] = [] return `serviceToken:${scopes.join(",")}`
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
} }

View File

@@ -138,7 +138,9 @@ export const safeProtectedProcedure = baseProcedure.use(async function (opts) {
}) })
export const serviceProcedure = baseProcedure.use(async (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) { if (!access_token) {
throw internalServerError(`[serviceProcedure] No service token`) throw internalServerError(`[serviceProcedure] No service token`)
} }

View File

@@ -1,50 +1,23 @@
import { getCacheClient } from "@/services/dataCache"
import { resolve as resolveEntry } from "@/utils/entry" import { resolve as resolveEntry } from "@/utils/entry"
import type { Lang } from "@/constants/languages" 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) => { export const fetchAndCacheEntry = async (path: string, lang: Lang) => {
const cacheKey = `${path + lang}` path = path || "/"
const cachedResponse = entryResponseCache.get(cacheKey) const cacheKey = `${lang}:resolveentry:${path}`
const cache = await getCacheClient()
if (cachedResponse && cachedResponse.expiresAt > Date.now() / 1000) {
console.log("[CMS MIDDLEWARE]: CACHE HIT")
return cachedResponse
}
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")
}
return cache.cacheOrGet(
cacheKey,
async () => {
const { contentType, uid } = await resolveEntry(path, lang) 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 { return {
contentType, contentType,
uid, uid,
} }
},
"1d"
)
} }

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