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,11 +3,13 @@
The web is using OAuth 2.0 to handle auth. We host our own instance of [Curity](https://curity.io), which is our identity and access management solution.
## Session management in Next
We use [Auth.js](https://authjs.dev) to handle everything regarding auth in the web. We use the JWT session strategy, which means that everything regarding the session is stored in a JWT, which is stored in the browser in an encrypted cookie.
## Keeping the access token alive
When the user performs a navigation the web app often does multiple requests to Next. If the access token has expired Next will do a request to Curity to renew the tokens. Since we only allow a single refresh token to be used only once only the first request will succeed and the following requests will fail.
To avoid that we have a component whose only purpose is to keep the access token alive. As long as no other request is happening at the same time this will work fine.
To avoid a session that keeps on refreshing forever, if the user have the page open in the background e.g., we have a timeout that stops the refreshing if the user is not active.
To avoid a session that keeps on refreshing forever, if the user have the page open in the background e.g., we have a timeout that stops the refreshing if the user is not active.

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

@@ -20,4 +20,4 @@
border-radius: 18px;
outline: 0 none;
padding: 5px 15px;
}
}

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

@@ -41,4 +41,4 @@
gap: var(--Spacing-x2);
justify-self: flex-end;
}
}
}

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,13 +1,14 @@
import * as Sentry from "@sentry/nextjs"
import { env } from "./env/server"
import { isEdge } from "./utils/isEdge"
export async function register() {
/*
Order matters!
Sentry hooks into OpenTelemetry, modifying its behavior.
Application Insights relies on OpenTelemetry exporters,
Application Insights relies on OpenTelemetry exporters,
and these may not work correctly if Sentry has already altered the instrumentation pipeline.
*/
await configureApplicationInsights()
@@ -45,12 +46,9 @@ async function configureApplicationInsights() {
}
async function configureSentry() {
switch (process.env.NEXT_RUNTIME) {
case "edge": {
await import("./sentry.edge.config")
}
case "nodejs": {
await import("./sentry.server.config")
}
if (isEdge) {
await import("./sentry.edge.config")
} else {
await import("./sentry.server.config")
}
}

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
}),
}),
})

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