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

@@ -1,3 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -1,6 +1,6 @@
nodeLinker: node-modules
packageExtensions:
eslint-config-next@*:
dependencies:
next: '*'
eslint-config-next@*:
dependencies:
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-lock.json
.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.
## Session management in Next
We use [Auth.js](https://authjs.dev) to handle everything regarding auth in the web. We use the JWT session strategy, which means that everything regarding the session is stored in a JWT, which is stored in the browser in an encrypted cookie.
## Keeping the access token alive
When the user performs a navigation the web app often does multiple requests to Next. If the access token has expired Next will do a request to Curity to renew the tokens. Since we only allow a single refresh token to be used only once only the first request will succeed and the following requests will fail.
To avoid that we have a component whose only purpose is to keep the access token alive. As long as no other request is happening at the same time this will work fine.

View File

@@ -18,6 +18,19 @@ yarn dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### Caching
You have the choice to either use redis (via redis-api; a tiny http proxy) or in-memory/unstable_cache (depending on edge or node).
Setting `REDIS_API_HOST` will configure it to use the distributed cache, not providing it will fall back to in-memory/unstable_cache
When pointing to the azure hosted variant you also need to provide `REDIS_API_KEY`
Locally it's easiest is to spin everything up using docker/podman - `podman compose up` or `docker-compose up`
This will also spin up [Redis Insight ](https://redis.io/insight/) so that you can debug the cache.
- Navigate to `http://localhost:5540`
- Click **'Add Redis database'**
- Provide Connection URL `redis://redis:6379`
## Learn More
To learn more about Next.js, take a look at the following resources:

View File

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

View File

@@ -25,8 +25,7 @@ export default async function CurrentContentPage({
{
locale: params.lang,
url: searchParams.uri,
},
{ cache: "no-store" }
}
)
if (!response.data?.all_current_blocks_page?.total) {
@@ -39,8 +38,7 @@ export default async function CurrentContentPage({
// This is currently to be considered a temporary solution to provide the tracking with a few values in english to align with existing reports
const pageDataForTracking = await request<TrackingData>(
GetCurrentBlockPageTrackingData,
{ uid: response.data.all_current_blocks_page.items[0].system.uid },
{ cache: "no-store" }
{ uid: response.data.all_current_blocks_page.items[0].system.uid }
)
const pageData = response.data.all_current_blocks_page.items[0]

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { headers } from "next/headers"
import { env } from "@/env/server"
import { badRequest, internalServerError } from "@/server/errors/next"
import { getCacheClient } from "@/services/dataCache"
import { generateTag } from "@/utils/generateTag"
import type { Lang } from "@/constants/languages"
@@ -27,23 +28,8 @@ export async function POST() {
const affix = headersList.get("x-affix")
const identifier = headersList.get("x-identifier")
const lang = headersList.get("x-lang")
if (lang && identifier) {
if (affix) {
const tag = generateTag(lang as Lang, identifier, affix)
console.info(
`Revalidated tag for [lang: ${lang}, identifier: ${identifier}, affix: ${affix}]`
)
console.info(`Tag: ${tag}`)
revalidateTag(tag)
} else {
const tag = generateTag(lang as Lang, identifier)
console.info(
`Revalidated tag for [lang: ${lang}, identifier: ${identifier}]`
)
console.info(`Tag: ${tag}`)
revalidateTag(tag)
}
} else {
if (!lang || !identifier) {
console.info(`Missing lang and/or identifier`)
console.info(`lang: ${lang}, identifier: ${identifier}`)
return badRequest({
@@ -52,6 +38,18 @@ export async function POST() {
})
}
const cacheClient = await getCacheClient()
const tag = generateTag(lang as Lang, identifier, affix)
console.info(
`Revalidated tag for [lang: ${lang}, identifier: ${identifier}${affix ? `, affix: ${affix}` : ""}]`
)
console.info(`Tag: ${tag}`)
revalidateTag(tag)
cacheClient.deleteKey(tag, { fuzzy: true })
return Response.json({ revalidated: true, now: Date.now() })
} catch (error) {
console.error("Failed to revalidate tag(s)")

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

View File

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

View File

@@ -15,7 +15,10 @@
}
.partial {
grid-template-columns: minmax(auto, 150px) min-content minmax(auto, 150px) auto;
grid-template-columns: minmax(auto, 150px) min-content minmax(
auto,
150px
) auto;
}
.icon {

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
background-image: linear-gradient(
background-image:
linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
@@ -24,7 +25,8 @@
}
.link:nth-of-type(2) .promo {
background-image: linear-gradient(
background-image:
linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,

View File

@@ -152,9 +152,9 @@ export default function Details({ user }: DetailsProps) {
{isPaymentNext
? intl.formatMessage({ id: "Proceed to payment method" })
: intl.formatMessage(
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: roomNr + 1 }
)}
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: roomNr + 1 }
)}
</Button>
</footer>
<MemberPriceModal

View File

@@ -2,7 +2,8 @@
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);

View File

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

View File

@@ -107,7 +107,8 @@
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
background-image:
linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);

View File

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

View File

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

View File

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

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()
.default(10 * 60)
.transform((val) => (process.env.CMS_ENVIRONMENT === "test" ? 60 : val)),
REDIS_API_HOST: z.string().optional(),
REDIS_API_KEY: z.string().optional(),
BRANCH:
process.env.NODE_ENV !== "development"
? z.string()
: z.string().optional().default("dev"),
GIT_SHA: z.string().optional(),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -273,6 +280,11 @@ export const env = createEnv({
CACHE_TIME_HOTELDATA: process.env.CACHE_TIME_HOTELDATA,
CACHE_TIME_HOTELS: process.env.CACHE_TIME_HOTELS,
CACHE_TIME_CITY_SEARCH: process.env.CACHE_TIME_CITY_SEARCH,
REDIS_API_HOST: process.env.REDIS_API_HOST,
REDIS_API_KEY: process.env.REDIS_API_KEY,
BRANCH: process.env.BRANCH,
GIT_SHA: process.env.GIT_SHA,
},
})

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
import fetchRetry from "fetch-retry"
import { GraphQLClient } from "graphql-request"
import { cache } from "react"
import { cache as reactCache } from "react"
import { env } from "@/env/server"
import { getPreviewHash, isPreviewByUid } from "@/lib/previewContext"
import { type CacheTime, getCacheClient } from "@/services/dataCache"
import { request as _request } from "./_request"
import type { DocumentNode } from "graphql"
@@ -14,7 +16,28 @@ import type { Data } from "@/types/request"
export async function request<T>(
query: string | DocumentNode,
variables?: Record<string, any>,
params?: RequestInit
cacheOptions?: {
key: string | string[]
ttl: CacheTime
}
): Promise<Data<T>> {
const doCall = () => internalRequest<T>(query, variables)
if (!cacheOptions) {
console.warn("[NO CACHE] for query", query)
return doCall()
}
const cacheKey: string = Array.isArray(cacheOptions.key)
? cacheOptions.key.join("_")
: cacheOptions.key
const _dataCache = await getCacheClient()
return _dataCache.cacheOrGet(cacheKey, doCall, cacheOptions.ttl)
}
function internalRequest<T>(
query: string | DocumentNode,
variables?: Record<string, any>
): Promise<Data<T>> {
const shouldUsePreview = variables?.uid
? isPreviewByUid(variables.uid)
@@ -24,7 +47,10 @@ export async function request<T>(
// Creating a new client for each request to avoid conflicting parameters
const client = new GraphQLClient(cmsUrl, {
fetch: cache(async function (url: URL | RequestInfo, params?: RequestInit) {
fetch: reactCache(async function (
url: URL | RequestInfo,
params?: RequestInit
) {
const wrappedFetch = fetchRetry(fetch, {
retries: 3,
retryDelay: function (attempt) {
@@ -38,16 +64,12 @@ export async function request<T>(
const mergedParams =
shouldUsePreview && previewHash
? {
...params,
headers: {
...params?.headers,
live_preview: previewHash,
preview_token: env.CMS_PREVIEW_TOKEN,
},
cache: undefined,
next: undefined,
}
: params
: {}
return _request(client, query, variables, mergedParams)
}

View File

@@ -1,4 +1,4 @@
import { type NextMiddleware,NextResponse } from "next/server"
import { type NextMiddleware, NextResponse } from "next/server"
import { REDEMPTION, SEARCHTYPE } from "@/constants/booking"
import { login } from "@/constants/routes/handleAuth"

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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import { metrics } from "@opentelemetry/api"
import { revalidateTag, unstable_cache } from "next/cache"
import { metrics, trace } from "@opentelemetry/api"
import { env } from "@/env/server"
import { generateServiceTokenTag } from "@/utils/generateTag"
import { getCacheClient } from "@/services/dataCache"
import type { ServiceTokenResponse } from "@/types/tokens"
@@ -12,13 +11,49 @@ const meter = metrics.getMeter("trpc.context.serviceToken")
const fetchServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-new-token"
)
const fetchTempServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-temporary"
)
const fetchServiceTokenFailCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-fail"
)
export async function getServiceToken() {
const tracer = trace.getTracer("getServiceToken")
return await tracer.startActiveSpan("getServiceToken", async () => {
let scopes: string[] = []
if (env.ENABLE_BOOKING_FLOW) {
scopes = ["profile", "hotel", "booking", "package", "availability"]
} else {
scopes = ["profile"]
}
const cacheKey = getServiceTokenCacheKey(scopes)
const cacheClient = await getCacheClient()
const token =
await cacheClient.get<Awaited<ReturnType<typeof getJwt>>>(cacheKey)
console.log("[DEBUG] getServiceToken", typeof token, token)
if (!token || token.expiresAt < Date.now()) {
return await tracer.startActiveSpan("fetch new token", async () => {
const newToken = await getJwt(scopes)
const relativeTime = (newToken.expiresAt - Date.now()) / 1000
await cacheClient.set(cacheKey, newToken, relativeTime)
return newToken.jwt
})
}
return token.jwt
})
}
async function getJwt(scopes: string[]) {
fetchServiceTokenCounter.add(1)
const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000
return { expiresAt, jwt }
}
async function fetchServiceToken(scopes: string[]) {
fetchServiceTokenCounter.add(1)
@@ -69,41 +104,6 @@ async function fetchServiceToken(scopes: string[]) {
return response.json() as Promise<ServiceTokenResponse>
}
export async function getServiceToken() {
let scopes: string[] = []
if (env.ENABLE_BOOKING_FLOW) {
scopes = ["profile", "hotel", "booking", "package", "availability"]
} else {
scopes = ["profile"]
}
const tag = generateServiceTokenTag(scopes)
const getCachedJwt = unstable_cache(
async (scopes) => {
const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000
return { expiresAt, jwt }
},
[tag],
{ tags: [tag] }
)
const cachedJwt = await getCachedJwt(scopes)
if (cachedJwt.expiresAt < Date.now()) {
console.log(
"trpc.context.serviceToken: Service token expired, revalidating tag"
)
revalidateTag(tag)
console.log(
"trpc.context.serviceToken: Fetching new temporary service token."
)
fetchTempServiceTokenCounter.add(1)
const newToken = await fetchServiceToken(scopes)
return newToken
}
return cachedJwt.jwt
function getServiceTokenCacheKey(scopes: string[]): string {
return `serviceToken:${scopes.join(",")}`
}

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