diff --git a/.vscode/settings.json b/.vscode/settings.json index 25fa6215f..72446f434 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/.yarnrc.yml b/.yarnrc.yml index 97b9a2e7f..1567ae5bb 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,6 +1,6 @@ nodeLinker: node-modules packageExtensions: - eslint-config-next@*: - dependencies: - next: '*' + eslint-config-next@*: + dependencies: + next: "*" diff --git a/apps/redis-api/.dockerignore b/apps/redis-api/.dockerignore new file mode 100644 index 000000000..3a8fe5ede --- /dev/null +++ b/apps/redis-api/.dockerignore @@ -0,0 +1 @@ +.env.local \ No newline at end of file diff --git a/apps/redis-api/.gitignore b/apps/redis-api/.gitignore new file mode 100644 index 000000000..9b1ee42e8 --- /dev/null +++ b/apps/redis-api/.gitignore @@ -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 diff --git a/apps/redis-api/Dockerfile b/apps/redis-api/Dockerfile new file mode 100644 index 000000000..396e66dce --- /dev/null +++ b/apps/redis-api/Dockerfile @@ -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"] \ No newline at end of file diff --git a/apps/redis-api/README.md b/apps/redis-api/README.md new file mode 100644 index 000000000..7f2da5c2e --- /dev/null +++ b/apps/redis-api/README.md @@ -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}} +``` diff --git a/apps/redis-api/biome.jsonc b/apps/redis-api/biome.jsonc new file mode 100644 index 000000000..f222d0ce3 --- /dev/null +++ b/apps/redis-api/biome.jsonc @@ -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", + }, + }, +} diff --git a/apps/redis-api/ci/azure-pipelines.build.yml b/apps/redis-api/ci/azure-pipelines.build.yml new file mode 100644 index 000000000..f2104f8bc --- /dev/null +++ b/apps/redis-api/ci/azure-pipelines.build.yml @@ -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 diff --git a/apps/redis-api/ci/azure-pipelines.deploy.yml b/apps/redis-api/ci/azure-pipelines.deploy.yml new file mode 100644 index 000000000..cce7ecabe --- /dev/null +++ b/apps/redis-api/ci/azure-pipelines.deploy.yml @@ -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) diff --git a/apps/redis-api/ci/bicep/app/containerApp.bicep b/apps/redis-api/ci/bicep/app/containerApp.bicep new file mode 100644 index 000000000..b3bc354c3 --- /dev/null +++ b/apps/redis-api/ci/bicep/app/containerApp.bicep @@ -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 + } + } + } +} diff --git a/apps/redis-api/ci/bicep/app/main.bicep b/apps/redis-api/ci/bicep/app/main.bicep new file mode 100644 index 000000000..64df07f3c --- /dev/null +++ b/apps/redis-api/ci/bicep/app/main.bicep @@ -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 } + ] + } +} diff --git a/apps/redis-api/ci/bicep/cache/redis.bicep b/apps/redis-api/ci/bicep/cache/redis.bicep new file mode 100644 index 000000000..7572b46e4 --- /dev/null +++ b/apps/redis-api/ci/bicep/cache/redis.bicep @@ -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 diff --git a/apps/redis-api/ci/bicep/infra/allow-acr-pull.bicep b/apps/redis-api/ci/bicep/infra/allow-acr-pull.bicep new file mode 100644 index 000000000..f254135bf --- /dev/null +++ b/apps/redis-api/ci/bicep/infra/allow-acr-pull.bicep @@ -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 + } +} diff --git a/apps/redis-api/ci/bicep/infra/containerEnvironment.bicep b/apps/redis-api/ci/bicep/infra/containerEnvironment.bicep new file mode 100644 index 000000000..e8865cb4f --- /dev/null +++ b/apps/redis-api/ci/bicep/infra/containerEnvironment.bicep @@ -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 + } +} diff --git a/apps/redis-api/ci/bicep/infra/main.bicep b/apps/redis-api/ci/bicep/infra/main.bicep new file mode 100644 index 000000000..31e55a29f --- /dev/null +++ b/apps/redis-api/ci/bicep/infra/main.bicep @@ -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 + } +} diff --git a/apps/redis-api/ci/bicep/main.bicep b/apps/redis-api/ci/bicep/main.bicep new file mode 100644 index 000000000..0d703dcd2 --- /dev/null +++ b/apps/redis-api/ci/bicep/main.bicep @@ -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 + } +} diff --git a/apps/redis-api/ci/bicep/managedIdentity.bicep b/apps/redis-api/ci/bicep/managedIdentity.bicep new file mode 100644 index 000000000..97b27a9f8 --- /dev/null +++ b/apps/redis-api/ci/bicep/managedIdentity.bicep @@ -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 diff --git a/apps/redis-api/ci/bicep/roles/acr-pull.bicep b/apps/redis-api/ci/bicep/roles/acr-pull.bicep new file mode 100644 index 000000000..5d94973d0 --- /dev/null +++ b/apps/redis-api/ci/bicep/roles/acr-pull.bicep @@ -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 diff --git a/apps/redis-api/ci/bicep/types.bicep b/apps/redis-api/ci/bicep/types.bicep new file mode 100644 index 000000000..51e75fb31 --- /dev/null +++ b/apps/redis-api/ci/bicep/types.bicep @@ -0,0 +1,9 @@ +@export() +@description('Type with allowed environments.') +type Environment = 'test' | 'prod' + +@export() +type EnvironmentVar = { + name: string + value: string +} diff --git a/apps/redis-api/package.json b/apps/redis-api/package.json new file mode 100644 index 000000000..be0421e81 --- /dev/null +++ b/apps/redis-api/package.json @@ -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" + } +} diff --git a/apps/redis-api/scripts/generateApiKeys.ts b/apps/redis-api/scripts/generateApiKeys.ts new file mode 100644 index 000000000..4c16b6e4c --- /dev/null +++ b/apps/redis-api/scripts/generateApiKeys.ts @@ -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 }; diff --git a/apps/redis-api/src/env.ts b/apps/redis-api/src/env.ts new file mode 100644 index 000000000..f307368e6 --- /dev/null +++ b/apps/redis-api/src/env.ts @@ -0,0 +1,56 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; + +const redisConnectionRegex = + /^((?.*?):(?.*?)@)?(?.*?):(?\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); diff --git a/apps/redis-api/src/errors/AuthenticationError.ts b/apps/redis-api/src/errors/AuthenticationError.ts new file mode 100644 index 000000000..3794c8616 --- /dev/null +++ b/apps/redis-api/src/errors/AuthenticationError.ts @@ -0,0 +1,6 @@ +export class AuthenticationError extends Error { + constructor(public message: string) { + super(message); + this.name = "AuthenticationError"; + } +} diff --git a/apps/redis-api/src/errors/ModelValidationError.ts b/apps/redis-api/src/errors/ModelValidationError.ts new file mode 100644 index 000000000..861ee7e57 --- /dev/null +++ b/apps/redis-api/src/errors/ModelValidationError.ts @@ -0,0 +1,6 @@ +export class ModelValidationError extends Error { + constructor(public message: string) { + super(message); + this.name = "ModelValidationError"; + } +} diff --git a/apps/redis-api/src/index.ts b/apps/redis-api/src/index.ts new file mode 100644 index 000000000..7969a1f4f --- /dev/null +++ b/apps/redis-api/src/index.ts @@ -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(), + }; +} diff --git a/apps/redis-api/src/middleware/apiKeyMiddleware.ts b/apps/redis-api/src/middleware/apiKeyMiddleware.ts new file mode 100644 index 000000000..a7eb9c6b6 --- /dev/null +++ b/apps/redis-api/src/middleware/apiKeyMiddleware.ts @@ -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; +} diff --git a/apps/redis-api/src/routes/api/cache.ts b/apps/redis-api/src/routes/api/cache.ts new file mode 100644 index 000000000..a50dbfc65 --- /dev/null +++ b/apps/redis-api/src/routes/api/cache.ts @@ -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; +} diff --git a/apps/redis-api/src/routes/api/index.ts b/apps/redis-api/src/routes/api/index.ts new file mode 100644 index 000000000..4fbbcb6f7 --- /dev/null +++ b/apps/redis-api/src/routes/api/index.ts @@ -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); diff --git a/apps/redis-api/src/routes/health.ts b/apps/redis-api/src/routes/health.ts new file mode 100644 index 000000000..dd1e0a169 --- /dev/null +++ b/apps/redis-api/src/routes/health.ts @@ -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(), + }), + }, + } +); diff --git a/apps/redis-api/src/services/redis.ts b/apps/redis-api/src/services/redis.ts new file mode 100644 index 000000000..e01088be3 --- /dev/null +++ b/apps/redis-api/src/services/redis.ts @@ -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 }; diff --git a/apps/redis-api/src/utils/logger.ts b/apps/redis-api/src/utils/logger.ts new file mode 100644 index 000000000..4d532b731 --- /dev/null +++ b/apps/redis-api/src/utils/logger.ts @@ -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 }); +}; diff --git a/apps/redis-api/src/utils/mask.test.ts b/apps/redis-api/src/utils/mask.test.ts new file mode 100644 index 000000000..2238f27b1 --- /dev/null +++ b/apps/redis-api/src/utils/mask.test.ts @@ -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", + ); + }); +}); diff --git a/apps/redis-api/src/utils/mask.ts b/apps/redis-api/src/utils/mask.ts new file mode 100644 index 000000000..971583a37 --- /dev/null +++ b/apps/redis-api/src/utils/mask.ts @@ -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); diff --git a/apps/redis-api/tsconfig.json b/apps/redis-api/tsconfig.json new file mode 100644 index 000000000..f99ca7bc8 --- /dev/null +++ b/apps/redis-api/tsconfig.json @@ -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/*"] + } + } +} diff --git a/apps/scandic-web/.prettierignore b/apps/scandic-web/.prettierignore index 727f13dab..07104e830 100644 --- a/apps/scandic-web/.prettierignore +++ b/apps/scandic-web/.prettierignore @@ -12,3 +12,5 @@ netlify.toml package.json package-lock.json .gitignore +*.bicep +*.ico \ No newline at end of file diff --git a/apps/scandic-web/Auth.md b/apps/scandic-web/Auth.md index 7bb71b2ef..1e243e0f3 100644 --- a/apps/scandic-web/Auth.md +++ b/apps/scandic-web/Auth.md @@ -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. \ No newline at end of file +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. diff --git a/apps/scandic-web/README.md b/apps/scandic-web/README.md index 6be284568..ae6b0cc67 100644 --- a/apps/scandic-web/README.md +++ b/apps/scandic-web/README.md @@ -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: diff --git a/apps/scandic-web/app/[lang]/(live)/error.tsx b/apps/scandic-web/app/[lang]/(live)/error.tsx index 98416b33a..aead8a274 100644 --- a/apps/scandic-web/app/[lang]/(live)/error.tsx +++ b/apps/scandic-web/app/[lang]/(live)/error.tsx @@ -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({
{intl.formatMessage({ id: "Something went wrong!" })} + {env.NEXT_PUBLIC_NODE_ENV === "development" && ( +
{error.stack || error.message}
+ )}
) diff --git a/apps/scandic-web/app/[lang]/(live-current)/current-content-page/page.tsx b/apps/scandic-web/app/[lang]/(live-current)/current-content-page/page.tsx index bb452fa0a..1e626165e 100644 --- a/apps/scandic-web/app/[lang]/(live-current)/current-content-page/page.tsx +++ b/apps/scandic-web/app/[lang]/(live-current)/current-content-page/page.tsx @@ -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( 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] diff --git a/apps/scandic-web/app/api/web/revalidate/hotel/route.ts b/apps/scandic-web/app/api/web/revalidate/hotel/route.ts index 85ecf7f63..97147963f 100644 --- a/apps/scandic-web/app/api/web/revalidate/hotel/route.ts +++ b/apps/scandic-web/app/api/web/revalidate/hotel/route.ts @@ -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) { diff --git a/apps/scandic-web/app/api/web/revalidate/loyaltyConfig/route.ts b/apps/scandic-web/app/api/web/revalidate/loyaltyConfig/route.ts index b63cf9559..869b4774a 100644 --- a/apps/scandic-web/app/api/web/revalidate/loyaltyConfig/route.ts +++ b/apps/scandic-web/app/api/web/revalidate/loyaltyConfig/route.ts @@ -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") diff --git a/apps/scandic-web/app/api/web/revalidate/manually/route.ts b/apps/scandic-web/app/api/web/revalidate/manually/route.ts index a4b3bfdc9..906f75020 100644 --- a/apps/scandic-web/app/api/web/revalidate/manually/route.ts +++ b/apps/scandic-web/app/api/web/revalidate/manually/route.ts @@ -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)") diff --git a/apps/scandic-web/app/api/web/revalidate/route.ts b/apps/scandic-web/app/api/web/revalidate/route.ts index 05847a6f1..b26f7ad89 100644 --- a/apps/scandic-web/app/api/web/revalidate/route.ts +++ b/apps/scandic-web/app/api/web/revalidate/route.ts @@ -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() }) diff --git a/apps/scandic-web/components/Auth/TokenRefresher.tsx b/apps/scandic-web/components/Auth/TokenRefresher.tsx index f3f235df2..361947679 100644 --- a/apps/scandic-web/components/Auth/TokenRefresher.tsx +++ b/apps/scandic-web/components/Auth/TokenRefresher.tsx @@ -24,7 +24,7 @@ export function SessionRefresher() { const session = useSession() const pathname = usePathname() const searchParams = useSearchParams() - const timeoutId = useRef() + const timeoutId = useRef() // Simple inactivity control. Reset when the URL changes. const stopPreRefreshAt = useMemo( diff --git a/apps/scandic-web/components/BookingWidget/MobileToggleButton/button.module.css b/apps/scandic-web/components/BookingWidget/MobileToggleButton/button.module.css index 1742ee38a..fac2dbce0 100644 --- a/apps/scandic-web/components/BookingWidget/MobileToggleButton/button.module.css +++ b/apps/scandic-web/components/BookingWidget/MobileToggleButton/button.module.css @@ -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 { diff --git a/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx index 0321597b4..7084a6e8c 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx @@ -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 (
- {children} + Unable to display map}> + {children} +
{onClose && ( +
) } diff --git a/apps/scandic-web/components/Current/Preamble/Breadcrumbs/index.tsx b/apps/scandic-web/components/Current/Preamble/Breadcrumbs/index.tsx index 0fbb477c2..60f7ce969 100644 --- a/apps/scandic-web/components/Current/Preamble/Breadcrumbs/index.tsx +++ b/apps/scandic-web/components/Current/Preamble/Breadcrumbs/index.tsx @@ -12,17 +12,11 @@ export default function Breadcrumbs({
    {parent ? (
  • - - {parent.title} - + {parent.title}
  • ) : null} {breadcrumbs.map((breadcrumb) => ( -
  • +
  • {breadcrumb.title} diff --git a/apps/scandic-web/components/Current/SubnavMobile.tsx b/apps/scandic-web/components/Current/SubnavMobile.tsx index 04eea5fd9..9f2276647 100644 --- a/apps/scandic-web/components/Current/SubnavMobile.tsx +++ b/apps/scandic-web/components/Current/SubnavMobile.tsx @@ -11,16 +11,12 @@ export default async function SubnavMobile({
      {parent ? (
    • - - {parent.title} - + {parent.title}
    • ) : null} {breadcrumbs.map((breadcrumb) => (
    • - - {breadcrumb.title} - + {breadcrumb.title}
    • ))}
    • diff --git a/apps/scandic-web/components/ErrorBoundary/ErrorBoundary.tsx b/apps/scandic-web/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 000000000..7bea81b5e --- /dev/null +++ b/apps/scandic-web/components/ErrorBoundary/ErrorBoundary.tsx @@ -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

      Something went wrong.

      + } + return this.props.children + } +} + +export default ErrorBoundary diff --git a/apps/scandic-web/components/Forms/Edit/Profile/form.module.css b/apps/scandic-web/components/Forms/Edit/Profile/form.module.css index 1cf12c80d..506e245c8 100644 --- a/apps/scandic-web/components/Forms/Edit/Profile/form.module.css +++ b/apps/scandic-web/components/Forms/Edit/Profile/form.module.css @@ -41,4 +41,4 @@ gap: var(--Spacing-x2); justify-self: flex-end; } -} \ No newline at end of file +} diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/Promo/promo.module.css b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/Promo/promo.module.css index 69bf014a5..2d157d83c 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/Promo/promo.module.css +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Promos/Promo/promo.module.css @@ -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%, diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx index b41ace619..074d14481 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/RoomOne/index.tsx @@ -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 } + )} ({ - 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, diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/utils.ts b/apps/scandic-web/components/HotelReservation/SelectRate/utils.ts index e319e0558..fc4952c4a 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/utils.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/utils.ts @@ -40,7 +40,7 @@ export function useRoomsAvailability( toDateString: string, lang: Lang, childArray?: Child[], - bookingCode?: string, + bookingCode?: string ) { const returnValue = trpc.hotel.availability.roomsCombinedAvailability.useQuery({ diff --git a/apps/scandic-web/components/ImageGallery/imageGallery.module.css b/apps/scandic-web/components/ImageGallery/imageGallery.module.css index 7177983b5..1397562fc 100644 --- a/apps/scandic-web/components/ImageGallery/imageGallery.module.css +++ b/apps/scandic-web/components/ImageGallery/imageGallery.module.css @@ -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%); diff --git a/apps/scandic-web/docker-compose.yaml b/apps/scandic-web/docker-compose.yaml new file mode 100644 index 000000000..b9392b81a --- /dev/null +++ b/apps/scandic-web/docker-compose.yaml @@ -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 diff --git a/apps/scandic-web/env/server.ts b/apps/scandic-web/env/server.ts index a7baf30c8..77247ee6e 100644 --- a/apps/scandic-web/env/server.ts +++ b/apps/scandic-web/env/server.ts @@ -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, }, }) diff --git a/apps/scandic-web/instrumentation.ts b/apps/scandic-web/instrumentation.ts index 0268d8e78..1b1158491 100644 --- a/apps/scandic-web/instrumentation.ts +++ b/apps/scandic-web/instrumentation.ts @@ -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") } } diff --git a/apps/scandic-web/lib/graphql/batchRequest.ts b/apps/scandic-web/lib/graphql/batchRequest.ts index 86361d527..8c9e74ecd 100644 --- a/apps/scandic-web/lib/graphql/batchRequest.ts +++ b/apps/scandic-web/lib/graphql/batchRequest.ts @@ -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( - queries: (BatchRequestDocument & { options?: RequestInit })[] + queries: (BatchRequestDocument & { + cacheOptions?: { + key: string | string[] + ttl: CacheTime + } + })[] ): Promise> { try { const response = await Promise.allSettled( queries.map((query) => - request(query.document, query.variables, query.options) + request(query.document, query.variables, query.cacheOptions) ) ) diff --git a/apps/scandic-web/lib/graphql/request.ts b/apps/scandic-web/lib/graphql/request.ts index 6f3a51e18..b7e036c2c 100644 --- a/apps/scandic-web/lib/graphql/request.ts +++ b/apps/scandic-web/lib/graphql/request.ts @@ -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( query: string | DocumentNode, variables?: Record, - params?: RequestInit + cacheOptions?: { + key: string | string[] + ttl: CacheTime + } +): Promise> { + const doCall = () => internalRequest(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( + query: string | DocumentNode, + variables?: Record ): Promise> { const shouldUsePreview = variables?.uid ? isPreviewByUid(variables.uid) @@ -24,7 +47,10 @@ export async function request( // 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( 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) } diff --git a/apps/scandic-web/middlewares/bookingFlow.ts b/apps/scandic-web/middlewares/bookingFlow.ts index c48ef5ac0..f080b8782 100644 --- a/apps/scandic-web/middlewares/bookingFlow.ts +++ b/apps/scandic-web/middlewares/bookingFlow.ts @@ -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" diff --git a/apps/scandic-web/middlewares/sasXScandic.ts b/apps/scandic-web/middlewares/sasXScandic.ts index ba093e249..2f8eb14bd 100644 --- a/apps/scandic-web/middlewares/sasXScandic.ts +++ b/apps/scandic-web/middlewares/sasXScandic.ts @@ -1,4 +1,4 @@ -import { type NextMiddleware,NextResponse } from "next/server" +import { type NextMiddleware, NextResponse } from "next/server" import { Lang } from "@/constants/languages" diff --git a/apps/scandic-web/netlify.toml b/apps/scandic-web/netlify.toml index 0c6470b72..0d9ac94a2 100644 --- a/apps/scandic-web/netlify.toml +++ b/apps/scandic-web/netlify.toml @@ -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" diff --git a/apps/scandic-web/next.config.js b/apps/scandic-web/next.config.js index ebff44a71..4fa0b553d 100644 --- a/apps/scandic-web/next.config.js +++ b/apps/scandic-web/next.config.js @@ -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, diff --git a/apps/scandic-web/package.json b/apps/scandic-web/package.json index b23cc71cf..e5a1e1edb 100644 --- a/apps/scandic-web/package.json +++ b/apps/scandic-web/package.json @@ -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", diff --git a/apps/scandic-web/server/routers/contentstack/accountPage/query.ts b/apps/scandic-web/server/routers/contentstack/accountPage/query.ts index bb98d6981..5a1538e06 100644 --- a/apps/scandic-web/server/routers/contentstack/accountPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/accountPage/query.ts @@ -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", } ) diff --git a/apps/scandic-web/server/routers/contentstack/base/query.ts b/apps/scandic-web/server/routers/contentstack/base/query.ts index 0482f4462..3030a7384 100644 --- a/apps/scandic-web/server/routers/contentstack/base/query.ts +++ b/apps/scandic-web/server/routers/contentstack/base/query.ts @@ -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( 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( - 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( + 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( - 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( + 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" + ) }), }) diff --git a/apps/scandic-web/server/routers/contentstack/breadcrumbs/query.ts b/apps/scandic-web/server/routers/contentstack/breadcrumbs/query.ts index c7573bc22..e3b57eedf 100644 --- a/apps/scandic-web/server/routers/contentstack/breadcrumbs/query.ts +++ b/apps/scandic-web/server/routers/contentstack/breadcrumbs/query.ts @@ -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( 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( query, { locale: lang, uid }, { - cache: "force-cache", - next: { tags }, + key: tags, + ttl: "max", } ) diff --git a/apps/scandic-web/server/routers/contentstack/collectionPage/query.ts b/apps/scandic-web/server/routers/contentstack/collectionPage/query.ts index be78be120..7b5b8ea42 100644 --- a/apps/scandic-web/server/routers/contentstack/collectionPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/collectionPage/query.ts @@ -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", } ) diff --git a/apps/scandic-web/server/routers/contentstack/collectionPage/utils.ts b/apps/scandic-web/server/routers/contentstack/collectionPage/utils.ts index cfb9ceca9..c68864745 100644 --- a/apps/scandic-web/server/routers/contentstack/collectionPage/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/collectionPage/utils.ts @@ -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( - 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(GetCollectionPageRefs, { + locale: lang, + uid, + }), + "max" ) if (!refsResponse.data) { diff --git a/apps/scandic-web/server/routers/contentstack/contentPage/query.ts b/apps/scandic-web/server/routers/contentstack/contentPage/query.ts index 02c7419d7..8da6a9468 100644 --- a/apps/scandic-web/server/routers/contentstack/contentPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/contentPage/query.ts @@ -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", }, }, ]) diff --git a/apps/scandic-web/server/routers/contentstack/contentPage/utils.ts b/apps/scandic-web/server/routers/contentstack/contentPage/utils.ts index 680b9860e..35f1dee41 100644 --- a/apps/scandic-web/server/routers/contentstack/contentPage/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/contentPage/utils.ts @@ -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", }, }, ]) diff --git a/apps/scandic-web/server/routers/contentstack/destinationCityPage/query.ts b/apps/scandic-web/server/routers/contentstack/destinationCityPage/query.ts index 2a0222db5..56a862755 100644 --- a/apps/scandic-web/server/routers/contentstack/destinationCityPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/destinationCityPage/query.ts @@ -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, { diff --git a/apps/scandic-web/server/routers/contentstack/destinationCityPage/utils.ts b/apps/scandic-web/server/routers/contentstack/destinationCityPage/utils.ts index 01b746903..aa2e516b3 100644 --- a/apps/scandic-web/server/routers/contentstack/destinationCityPage/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/destinationCityPage/utils.ts @@ -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( 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(req.document, req.variables, req.options) + Array.from({ length: amountOfRequests }).map((_, i) => + request( + GetCityPageUrls, + { locale: lang, skip: i * 100 }, + { key: `${lang}:city_page_urls_batch_${i}`, ttl: "max" } + ) ) ) const validatedResponse = batchedCityPageUrlsSchema.safeParse(batchedResponse) diff --git a/apps/scandic-web/server/routers/contentstack/destinationCountryPage/query.ts b/apps/scandic-web/server/routers/contentstack/destinationCountryPage/query.ts index d8ea6800f..364d5fd6f 100644 --- a/apps/scandic-web/server/routers/contentstack/destinationCountryPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/destinationCountryPage/query.ts @@ -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) { diff --git a/apps/scandic-web/server/routers/contentstack/destinationCountryPage/utils.ts b/apps/scandic-web/server/routers/contentstack/destinationCountryPage/utils.ts index 81b2d1176..ad2a70ce0 100644 --- a/apps/scandic-web/server/routers/contentstack/destinationCountryPage/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/destinationCountryPage/utils.ts @@ -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( 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", } ) diff --git a/apps/scandic-web/server/routers/contentstack/destinationOverviewPage/query.ts b/apps/scandic-web/server/routers/contentstack/destinationOverviewPage/query.ts index 6ee128ba6..11e230c55 100644 --- a/apps/scandic-web/server/routers/contentstack/destinationOverviewPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/destinationOverviewPage/query.ts @@ -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, } diff --git a/apps/scandic-web/server/routers/contentstack/hotelPage/query.ts b/apps/scandic-web/server/routers/contentstack/hotelPage/query.ts index c7795710a..18aec90f6 100644 --- a/apps/scandic-web/server/routers/contentstack/hotelPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/hotelPage/query.ts @@ -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) { diff --git a/apps/scandic-web/server/routers/contentstack/hotelPage/utils.ts b/apps/scandic-web/server/routers/contentstack/hotelPage/utils.ts index f31224b83..ea829b472 100644 --- a/apps/scandic-web/server/routers/contentstack/hotelPage/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/hotelPage/utils.ts @@ -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( 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(req.document, req.variables, req.options) + request(req.document, req.variables, { + key: req.cacheKey, + ttl: "max", + }) ) ) diff --git a/apps/scandic-web/server/routers/contentstack/languageSwitcher/utils.ts b/apps/scandic-web/server/routers/contentstack/languageSwitcher/utils.ts index f579ec54d..03e7525d6 100644 --- a/apps/scandic-web/server/routers/contentstack/languageSwitcher/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/languageSwitcher/utils.ts @@ -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, }, }, ]) diff --git a/apps/scandic-web/server/routers/contentstack/loyaltyLevel/query.ts b/apps/scandic-web/server/routers/contentstack/loyaltyLevel/query.ts index a50e563f4..248803f61 100644 --- a/apps/scandic-web/server/routers/contentstack/loyaltyLevel/query.ts +++ b/apps/scandic-web/server/routers/contentstack/loyaltyLevel/query.ts @@ -63,7 +63,7 @@ export const getAllLoyaltyLevels = cache(async (ctx: Context) => { const loyaltyLevelsConfigResponse = await request( 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 ( diff --git a/apps/scandic-web/server/routers/contentstack/loyaltyPage/query.ts b/apps/scandic-web/server/routers/contentstack/loyaltyPage/query.ts index f1f4edeec..72c589cc8 100644 --- a/apps/scandic-web/server/routers/contentstack/loyaltyPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/loyaltyPage/query.ts @@ -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", } ) diff --git a/apps/scandic-web/server/routers/contentstack/metadata/query.ts b/apps/scandic-web/server/routers/contentstack/metadata/query.ts index 9f6fa59b2..d46fc4cf8 100644 --- a/apps/scandic-web/server/routers/contentstack/metadata/query.ts +++ b/apps/scandic-web/server/routers/contentstack/metadata/query.ts @@ -64,10 +64,8 @@ const fetchMetadata = cache(async function fetchMemoizedMetadata( query, { locale: lang, uid }, { - cache: "force-cache", - next: { - tags: [generateTag(lang, uid, affix)], - }, + key: generateTag(lang, uid, affix), + ttl: "max", } ) if (!response.data) { diff --git a/apps/scandic-web/server/routers/contentstack/metadata/utils.ts b/apps/scandic-web/server/routers/contentstack/metadata/utils.ts index e4577af0d..7c4c8dd90 100644 --- a/apps/scandic-web/server/routers/contentstack/metadata/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/metadata/utils.ts @@ -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) diff --git a/apps/scandic-web/server/routers/contentstack/pageSettings/query.ts b/apps/scandic-web/server/routers/contentstack/pageSettings/query.ts index 6580b1945..94697e53e 100644 --- a/apps/scandic-web/server/routers/contentstack/pageSettings/query.ts +++ b/apps/scandic-web/server/routers/contentstack/pageSettings/query.ts @@ -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", } ) diff --git a/apps/scandic-web/server/routers/contentstack/partner/query.ts b/apps/scandic-web/server/routers/contentstack/partner/query.ts index 52424a9da..7e1cbcfde 100644 --- a/apps/scandic-web/server/routers/contentstack/partner/query.ts +++ b/apps/scandic-web/server/routers/contentstack/partner/query.ts @@ -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", } ) diff --git a/apps/scandic-web/server/routers/contentstack/reward/query.ts b/apps/scandic-web/server/routers/contentstack/reward/query.ts index 157aa755c..b4497d07d 100644 --- a/apps/scandic-web/server/routers/contentstack/reward/query.ts +++ b/apps/scandic-web/server/routers/contentstack/reward/query.ts @@ -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) diff --git a/apps/scandic-web/server/routers/contentstack/reward/utils.ts b/apps/scandic-web/server/routers/contentstack/reward/utils.ts index 63c850086..4a2c2c087 100644 --- a/apps/scandic-web/server/routers/contentstack/reward/utils.ts +++ b/apps/scandic-web/server/routers/contentstack/reward/utils.ts @@ -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( @@ -301,7 +310,7 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) { locale: lang, rewardIds, }, - { next: { tags }, cache: "force-cache" } + { key: tags, ttl: "max" } ) } diff --git a/apps/scandic-web/server/routers/contentstack/startPage/query.ts b/apps/scandic-web/server/routers/contentstack/startPage/query.ts index 2462584cc..d70624ff3 100644 --- a/apps/scandic-web/server/routers/contentstack/startPage/query.ts +++ b/apps/scandic-web/server/routers/contentstack/startPage/query.ts @@ -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", } ) diff --git a/apps/scandic-web/server/routers/hotels/output.ts b/apps/scandic-web/server/routers/hotels/output.ts index 9c4474c85..e85142318 100644 --- a/apps/scandic-web/server/routers/hotels/output.ts +++ b/apps/scandic-web/server/routers/hotels/output.ts @@ -269,6 +269,7 @@ export const countriesSchema = z.object({ }), }) +export type Cities = z.infer export const citiesSchema = z .object({ data: z.array(citySchema), diff --git a/apps/scandic-web/server/routers/hotels/query.ts b/apps/scandic-web/server/routers/hotels/query.ts index f967edb35..d9db45b16 100644 --- a/apps/scandic-web/server/routers/hotels/query.ts +++ b/apps/scandic-web/server/routers/hotels/query.ts @@ -1,6 +1,3 @@ -import { unstable_cache } from "next/cache" - -import { ApiLang } from "@/constants/languages" import { env } from "@/env/server" import * as api from "@/lib/api" import { dt } from "@/lib/dt" @@ -16,6 +13,7 @@ import { import { toApiLang } from "@/server/utils" import { generateChildrenString } from "@/components/HotelReservation/utils" +import { getCacheClient } from "@/services/dataCache" import { cache } from "@/utils/cache" import { getHotelPageUrls } from "../contentstack/hotelPage/utils" @@ -67,7 +65,6 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { HotelTypeEnum } from "@/types/enums/hotelType" import { RateTypeEnum } from "@/types/enums/rateType" -import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { HotelDataWithUrl } from "@/types/hotel" import type { HotelsAvailabilityInputSchema, @@ -78,137 +75,128 @@ import type { CityLocation } from "@/types/trpc/routers/hotel/locations" export const getHotel = cache( async (input: HotelInput, serviceToken: string) => { - const callable = unstable_cache( - async function ( - hotelId: HotelInput["hotelId"], - language: HotelInput["language"], - isCardOnlyPayment?: HotelInput["isCardOnlyPayment"] - ) { - /** - * Since API expects the params appended and not just - * a comma separated string we need to initialize the - * SearchParams with a sequence of pairs - * (include=City&include=NearbyHotels&include=Restaurants etc.) - **/ - const params = new URLSearchParams([ - ["include", "AdditionalData"], - ["include", "City"], - ["include", "NearbyHotels"], - ["include", "Restaurants"], - ["include", "RoomCategories"], - ["language", toApiLang(language)], - ]) - metrics.hotel.counter.add(1, { + const callable = async function ( + hotelId: HotelInput["hotelId"], + language: HotelInput["language"], + isCardOnlyPayment?: HotelInput["isCardOnlyPayment"] + ) { + /** + * Since API expects the params appended and not just + * a comma separated string we need to initialize the + * SearchParams with a sequence of pairs + * (include=City&include=NearbyHotels&include=Restaurants etc.) + **/ + const params = new URLSearchParams([ + ["include", "AdditionalData"], + ["include", "City"], + ["include", "NearbyHotels"], + ["include", "Restaurants"], + ["include", "RoomCategories"], + ["language", toApiLang(language)], + ]) + metrics.hotel.counter.add(1, { + hotelId, + language, + }) + console.info( + "api.hotels.hotelData start", + JSON.stringify({ query: { hotelId, params: params.toString() } }) + ) + + const apiResponse = await api.get( + api.endpoints.v1.Hotel.Hotels.hotel(hotelId), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.hotel.fail.add(1, { hotelId, language, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), }) - console.info( - "api.hotels.hotelData start", - JSON.stringify({ query: { hotelId, params: params.toString() } }) - ) - - const apiResponse = await api.get( - api.endpoints.v1.Hotel.Hotels.hotel(hotelId), - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - // needs to clear default option as only - // cache or next.revalidate is permitted - cache: undefined, - next: { - revalidate: env.CACHE_TIME_HOTELS, - tags: [`${language}:hotel:${hotelId}`], - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.hotel.fail.add(1, { - hotelId, - language, - error_type: "http_error", - error: JSON.stringify({ + console.error( + "api.hotels.hotelData error", + JSON.stringify({ + query: { hotelId, params: params.toString() }, + error: { status: apiResponse.status, statusText: apiResponse.statusText, text, - }), - }) - console.error( - "api.hotels.hotelData error", - JSON.stringify({ - query: { hotelId, params: params.toString() }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }, - }) - ) - return null - } - - const apiJson = await apiResponse.json() - const validateHotelData = hotelSchema.safeParse(apiJson) - - if (!validateHotelData.success) { - metrics.hotel.fail.add(1, { - hotelId, - language, - error_type: "validation_error", - error: JSON.stringify(validateHotelData.error), - }) - - console.error( - "api.hotels.hotelData validation error", - JSON.stringify({ - query: { hotelId, params: params.toString() }, - error: validateHotelData.error, - }) - ) - throw badRequestError() - } - - metrics.hotel.success.add(1, { - hotelId, - language, - }) - console.info( - "api.hotels.hotelData success", - JSON.stringify({ - query: { hotelId, params: params.toString() }, + }, }) ) - const hotelData = validateHotelData.data - - if (isCardOnlyPayment) { - hotelData.hotel.merchantInformationData.alternatePaymentOptions = [] - } - - const gallery = hotelData.additionalData?.gallery - if (gallery) { - const smallerImages = gallery.smallerImages - const hotelGalleryImages = - hotelData.hotel.hotelType === HotelTypeEnum.Signature - ? smallerImages.slice(0, 10) - : smallerImages.slice(0, 6) - hotelData.hotel.galleryImages = hotelGalleryImages - } - - return hotelData - }, - [`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`], - { - revalidate: env.CACHE_TIME_HOTELS, - tags: [ - `${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`, - ], + return null } - ) - return callable(input.hotelId, input.language, input.isCardOnlyPayment) + const apiJson = await apiResponse.json() + const validateHotelData = hotelSchema.safeParse(apiJson) + + if (!validateHotelData.success) { + metrics.hotel.fail.add(1, { + hotelId, + language, + error_type: "validation_error", + error: JSON.stringify(validateHotelData.error), + }) + + console.error( + "api.hotels.hotelData validation error", + JSON.stringify({ + query: { hotelId, params: params.toString() }, + error: validateHotelData.error, + }) + ) + throw badRequestError() + } + + metrics.hotel.success.add(1, { + hotelId, + language, + }) + console.info( + "api.hotels.hotelData success", + JSON.stringify({ + query: { hotelId, params: params.toString() }, + }) + ) + const hotelData = validateHotelData.data + + if (isCardOnlyPayment) { + hotelData.hotel.merchantInformationData.alternatePaymentOptions = [] + } + + const gallery = hotelData.additionalData?.gallery + if (gallery) { + const smallerImages = gallery.smallerImages + const hotelGalleryImages = + hotelData.hotel.hotelType === HotelTypeEnum.Signature + ? smallerImages.slice(0, 10) + : smallerImages.slice(0, 6) + hotelData.hotel.galleryImages = hotelGalleryImages + } + + return hotelData + } + + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + `${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`, + async () => { + return callable(input.hotelId, input.language, input.isCardOnlyPayment) + }, + "1d" + ) } ) @@ -226,115 +214,119 @@ export const getHotelsAvailabilityByCity = async ( bookingCode, redemption, } = input - - const params: Record = { - roomStayStartDate, - roomStayEndDate, - adults, - ...(children && { children }), - ...(bookingCode && { bookingCode }), - ...(redemption ? { isRedemption: "true" } : {}), - language: apiLang, - } - metrics.hotelsAvailability.counter.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - }) - console.info( - "api.hotels.hotelsAvailability start", - JSON.stringify({ query: { cityId, params } }) - ) - const apiResponse = await api.get( - api.endpoints.v1.Availability.city(cityId), - { - cache: undefined, - headers: { - Authorization: `Bearer ${token}`, - }, - next: { - revalidate: env.CACHE_TIME_CITY_SEARCH, - }, - }, - params - ) - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.hotelsAvailability.fail.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.hotels.hotelsAvailability error", - JSON.stringify({ - query: { cityId, params }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + `${cityId}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, + async () => { + const params: Record = { + roomStayStartDate, + roomStayEndDate, + adults, + ...(children && { children }), + ...(bookingCode && { bookingCode }), + ...(redemption ? { isRedemption: "true" } : {}), + language: apiLang, + } + metrics.hotelsAvailability.counter.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, + }) + console.info( + "api.hotels.hotelsAvailability start", + JSON.stringify({ query: { cityId, params } }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Availability.city(cityId), + { + headers: { + Authorization: `Bearer ${token}`, + }, }, + params + ) + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.hotelsAvailability.fail.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.hotelsAvailability error", + JSON.stringify({ + query: { cityId, params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + + throw new Error("Failed to fetch hotels availability by city") + } + + const apiJson = await apiResponse.json() + const validateAvailabilityData = + hotelsAvailabilitySchema.safeParse(apiJson) + if (!validateAvailabilityData.success) { + metrics.hotelsAvailability.fail.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, + error_type: "validation_error", + error: JSON.stringify(validateAvailabilityData.error), + }) + console.error( + "api.hotels.hotelsAvailability validation error", + JSON.stringify({ + query: { cityId, params }, + error: validateAvailabilityData.error, + }) + ) + throw badRequestError() + } + metrics.hotelsAvailability.success.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, }) - ) - return null - } - const apiJson = await apiResponse.json() - const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) - if (!validateAvailabilityData.success) { - metrics.hotelsAvailability.fail.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - error_type: "validation_error", - error: JSON.stringify(validateAvailabilityData.error), - }) - console.error( - "api.hotels.hotelsAvailability validation error", - JSON.stringify({ - query: { cityId, params }, - error: validateAvailabilityData.error, - }) - ) - throw badRequestError() - } - metrics.hotelsAvailability.success.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - }) - console.info( - "api.hotels.hotelsAvailability success", - JSON.stringify({ - query: { cityId, params: params }, - }) + console.info( + "api.hotels.hotelsAvailability success", + JSON.stringify({ + query: { cityId, params: params }, + }) + ) + return { + availability: validateAvailabilityData.data.data.flatMap( + (hotels) => hotels.attributes + ), + } + }, + "1h" ) - return { - availability: validateAvailabilityData.data.data.flatMap( - (hotels) => hotels.attributes - ), - } } export const getHotelsAvailabilityByHotelIds = async ( @@ -351,12 +343,6 @@ export const getHotelsAvailabilityByHotelIds = async ( bookingCode, } = input - /** - * Since API expects the params appended and not just - * a comma separated string we need to initialize the - * SearchParams with a sequence of pairs - * (hotelIds=810&hotelIds=879&hotelIds=222 etc.) - **/ const params = new URLSearchParams([ ["roomStayStartDate", roomStayStartDate], ["roomStayEndDate", roomStayEndDate], @@ -365,102 +351,117 @@ export const getHotelsAvailabilityByHotelIds = async ( ["bookingCode", bookingCode], ["language", apiLang], ]) - hotelIds.forEach((hotelId) => params.append("hotelIds", hotelId.toString())) - metrics.hotelsByHotelIdAvailability.counter.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - }) - console.info( - "api.hotels.hotelsByHotelIdAvailability start", - JSON.stringify({ query: { params } }) - ) - const apiResponse = await api.get( - api.endpoints.v1.Availability.hotels(), - { - cache: undefined, - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - next: { - revalidate: env.CACHE_TIME_CITY_SEARCH, - }, - }, - params - ) - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.hotelsByHotelIdAvailability.fail.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.hotels.hotelsByHotelIdAvailability error", - JSON.stringify({ - query: { params }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, + + const cacheClient = await getCacheClient() + return cacheClient.cacheOrGet( + `${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, + async () => { + /** + * Since API expects the params appended and not just + * a comma separated string we need to initialize the + * SearchParams with a sequence of pairs + * (hotelIds=810&hotelIds=879&hotelIds=222 etc.) + **/ + + hotelIds.forEach((hotelId) => + params.append("hotelIds", hotelId.toString()) + ) + metrics.hotelsByHotelIdAvailability.counter.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + }) + console.info( + "api.hotels.hotelsByHotelIdAvailability start", + JSON.stringify({ query: { params } }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Availability.hotels(), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, }, + params + ) + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.hotelsByHotelIdAvailability.fail.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.hotelsByHotelIdAvailability error", + JSON.stringify({ + query: { params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + + throw new Error("Failed to fetch hotels availability by hotelIds") + } + const apiJson = await apiResponse.json() + const validateAvailabilityData = + hotelsAvailabilitySchema.safeParse(apiJson) + if (!validateAvailabilityData.success) { + metrics.hotelsByHotelIdAvailability.fail.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "validation_error", + error: JSON.stringify(validateAvailabilityData.error), + }) + console.error( + "api.hotels.hotelsByHotelIdAvailability validation error", + JSON.stringify({ + query: { params }, + error: validateAvailabilityData.error, + }) + ) + throw badRequestError() + } + metrics.hotelsByHotelIdAvailability.success.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, }) - ) - return null - } - const apiJson = await apiResponse.json() - const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) - if (!validateAvailabilityData.success) { - metrics.hotelsByHotelIdAvailability.fail.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - error_type: "validation_error", - error: JSON.stringify(validateAvailabilityData.error), - }) - console.error( - "api.hotels.hotelsByHotelIdAvailability validation error", - JSON.stringify({ - query: { params }, - error: validateAvailabilityData.error, - }) - ) - throw badRequestError() - } - metrics.hotelsByHotelIdAvailability.success.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - }) - console.info( - "api.hotels.hotelsByHotelIdAvailability success", - JSON.stringify({ - query: { params }, - }) + console.info( + "api.hotels.hotelsByHotelIdAvailability success", + JSON.stringify({ + query: { params }, + }) + ) + return { + availability: validateAvailabilityData.data.data.flatMap( + (hotels) => hotels.attributes + ), + } + }, + env.CACHE_TIME_CITY_SEARCH ) - return { - availability: validateAvailabilityData.data.data.flatMap( - (hotels) => hotels.attributes - ), - } } export const hotelQueryRouter = router({ @@ -654,7 +655,8 @@ export const hotelQueryRouter = router({ }, }) ) - return null + + throw new Error("Failed to fetch selected room availability") } const apiJsonAvailability = await apiResponseAvailability.json() const validateAvailabilityData = @@ -913,28 +915,12 @@ export const hotelQueryRouter = router({ const { lang, serviceToken } = ctx const { country } = input - 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: ctx.serviceToken, }) - const hotelIds = await getHotelIdsByCountry( - country, - options, - hotelIdsParams - ) - return await getHotelsByHotelIds(hotelIds, lang, serviceToken) + return await getHotelsByHotelIds({ hotelIds, lang, serviceToken }) }), }), byCityIdentifier: router({ @@ -949,7 +935,7 @@ export const hotelQueryRouter = router({ serviceToken ) - return await getHotelsByHotelIds(hotelIds, lang, serviceToken) + return await getHotelsByHotelIds({ hotelIds, lang, serviceToken }) }), }), byCSFilter: router({ @@ -959,19 +945,6 @@ export const hotelQueryRouter = router({ const { locationFilter, hotelsToInclude } = input const language = ctx.lang - const apiLang = toApiLang(language) - const options: RequestOptionsWithOutBody = { - // needs to clear default option as only - // cache or next.revalidate is permitted - cache: undefined, - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - next: { - revalidate: env.CACHE_TIME_HOTELS, - }, - } - let hotelsToFetch: string[] = [] metrics.hotels.counter.add(1, { @@ -991,15 +964,11 @@ export const hotelQueryRouter = router({ if (hotelsToInclude.length) { hotelsToFetch = hotelsToInclude } else if (locationFilter?.city) { - const locationsParams = new URLSearchParams({ - language: apiLang, + const locations = await getLocations({ + lang: language, + serviceToken: ctx.serviceToken, + citiesByCountry: null, }) - const locations = await getLocations( - language, - options, - locationsParams, - null - ) if (!locations || "error" in locations) { return [] } @@ -1028,15 +997,11 @@ export const hotelQueryRouter = router({ ) return [] } - const hotelIdsParams = new URLSearchParams({ - language: apiLang, - city: cityId, - }) - const hotelIds = await getHotelIdsByCityId( + + const hotelIds = await getHotelIdsByCityId({ cityId, - options, - hotelIdsParams - ) + serviceToken: ctx.serviceToken, + }) if (!hotelIds?.length) { metrics.hotels.fail.add(1, { @@ -1062,15 +1027,10 @@ export const hotelQueryRouter = router({ hotelsToFetch = filteredHotelIds } else if (locationFilter?.country) { - const hotelIdsParams = new URLSearchParams({ - language: ApiLang.En, + const hotelIds = await getHotelIdsByCountry({ country: locationFilter.country, + serviceToken: ctx.serviceToken, }) - const hotelIds = await getHotelIdsByCountry( - locationFilter.country, - options, - hotelIdsParams - ) if (!hotelIds?.length) { metrics.hotels.fail.add(1, { @@ -1154,43 +1114,29 @@ export const hotelQueryRouter = router({ }), getAllHotels: 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) + if (!countries) { return null } + const countryNames = countries.data.map((country) => country.name) const hotelData: HotelDataWithUrl[] = ( await Promise.all( countryNames.map(async (country) => { - const countryParams = new URLSearchParams({ - country: country, - }) - const hotelIds = await getHotelIdsByCountry( + const hotelIds = await getHotelIdsByCountry({ country, - options, - countryParams - ) + serviceToken: ctx.serviceToken, + }) - const hotels = await getHotelsByHotelIds( + const hotels = await getHotelsByHotelIds({ hotelIds, - ctx.lang, - ctx.serviceToken - ) + lang: ctx.lang, + serviceToken: ctx.serviceToken, + }) return hotels }) ) @@ -1280,50 +1226,43 @@ export const hotelQueryRouter = router({ }), locations: router({ get: serviceProcedure.input(getLocationsInput).query(async function ({ - input, ctx, + input, }) { const lang = input.lang ?? ctx.lang + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + `${ctx.lang}:getLocations`, + async () => { + const countries = await getCountries({ + lang: lang, + serviceToken: ctx.serviceToken, + }) - const searchParams = new URLSearchParams() - searchParams.set("language", toApiLang(lang)) + if (!countries) { + throw new Error("Unable to fetch countries") + } + const countryNames = countries.data.map((country) => country.name) + const citiesByCountry = await getCitiesByCountry({ + countries: countryNames, + serviceToken: ctx.serviceToken, + lang, + }) - const options: RequestOptionsWithOutBody = { - // needs to clear default option as only - // cache or next.revalidate is permitted - cache: undefined, - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, + const locations = await getLocations({ + lang, + serviceToken: ctx.serviceToken, + citiesByCountry, + }) + + if (!locations || "error" in locations) { + throw new Error("Unable to fetch locations") + } + + return locations }, - next: { - revalidate: env.CACHE_TIME_HOTELS, - }, - } - - const countries = await getCountries(options, searchParams, lang) - if (!countries) { - throw new Error("Unable to fetch countries") - } - const countryNames = countries.data.map((country) => country.name) - const citiesByCountry = await getCitiesByCountry( - countryNames, - options, - searchParams, - lang + "max" ) - - const locations = await getLocations( - lang, - options, - searchParams, - citiesByCountry - ) - - if (!locations || "error" in locations) { - throw new Error("Unable to fetch locations") - } - - return locations }), }), map: router({ @@ -1380,67 +1319,71 @@ export const hotelQueryRouter = router({ JSON.stringify({ query: { hotelId, params } }) ) - const apiResponse = await api.get( - api.endpoints.v1.Hotel.Hotels.meetingRooms(input.hotelId), - { - cache: undefined, - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - next: { - revalidate: env.CACHE_TIME_HOTELS, - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.meetingRooms.fail.add(1, { - ...metricsData, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.hotels.meetingRooms error", - JSON.stringify({ - query: { params }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, + const cacheClient = await getCacheClient() + return cacheClient.cacheOrGet( + `${language}:hotels:meetingRooms:${hotelId}`, + async () => { + const apiResponse = await api.get( + api.endpoints.v1.Hotel.Hotels.meetingRooms(input.hotelId), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, }, - }) - ) - return [] - } + params + ) - const apiJson = await apiResponse.json() - const validatedMeetingRooms = meetingRoomsSchema.safeParse(apiJson) + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.meetingRooms.fail.add(1, { + ...metricsData, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.meetingRooms error", + JSON.stringify({ + query: { params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) - if (!validatedMeetingRooms.success) { - console.error( - "api.hotels.meetingRooms validation error", - JSON.stringify({ - query: { params }, - error: validatedMeetingRooms.error, + throw new Error("Failed to fetch meeting rooms") + } + + const apiJson = await apiResponse.json() + const validatedMeetingRooms = meetingRoomsSchema.safeParse(apiJson) + + if (!validatedMeetingRooms.success) { + console.error( + "api.hotels.meetingRooms validation error", + JSON.stringify({ + query: { params }, + error: validatedMeetingRooms.error, + }) + ) + throw badRequestError() + } + metrics.meetingRooms.success.add(1, { + hotelId, }) - ) - return [] - } - metrics.meetingRooms.success.add(1, { - hotelId, - }) - console.info( - "api.hotels.meetingRooms success", - JSON.stringify({ query: { params } }) + console.info( + "api.hotels.meetingRooms success", + JSON.stringify({ query: { params } }) + ) + + return validatedMeetingRooms.data.data + }, + env.CACHE_TIME_HOTELS ) - - return validatedMeetingRooms.data.data }), additionalData: safeProtectedServiceProcedure .input(getAdditionalDataInputSchema) @@ -1458,67 +1401,72 @@ export const hotelQueryRouter = router({ JSON.stringify({ query: { hotelId, params } }) ) - const apiResponse = await api.get( - api.endpoints.v1.Hotel.Hotels.additionalData(input.hotelId), - { - cache: undefined, - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - next: { - revalidate: env.CACHE_TIME_HOTELS, - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.additionalData.fail.add(1, { - ...metricsData, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.hotels.additionalData error", - JSON.stringify({ - query: { params }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, + const cacheClient = await getCacheClient() + return cacheClient.cacheOrGet( + `${language}:hotels:additionalData:${hotelId}`, + async () => { + const apiResponse = await api.get( + api.endpoints.v1.Hotel.Hotels.additionalData(input.hotelId), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, }, - }) - ) - return null - } + params + ) - const apiJson = await apiResponse.json() - const validatedAdditionalData = additionalDataSchema.safeParse(apiJson) + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.additionalData.fail.add(1, { + ...metricsData, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.additionalData error", + JSON.stringify({ + query: { params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) - if (!validatedAdditionalData.success) { - console.error( - "api.hotels.additionalData validation error", - JSON.stringify({ - query: { params }, - error: validatedAdditionalData.error, + throw new Error("Unable to fetch additional data for hotel") + } + + const apiJson = await apiResponse.json() + const validatedAdditionalData = + additionalDataSchema.safeParse(apiJson) + + if (!validatedAdditionalData.success) { + console.error( + "api.hotels.additionalData validation error", + JSON.stringify({ + query: { params }, + error: validatedAdditionalData.error, + }) + ) + throw badRequestError() + } + metrics.additionalData.success.add(1, { + hotelId, }) - ) - throw badRequestError() - } - metrics.additionalData.success.add(1, { - hotelId, - }) - console.info( - "api.hotels.additionalData success", - JSON.stringify({ query: { params } }) + console.info( + "api.hotels.additionalData success", + JSON.stringify({ query: { params } }) + ) + + return validatedAdditionalData.data + }, + env.CACHE_TIME_HOTELS ) - - return validatedAdditionalData.data }), packages: router({ get: serviceProcedure @@ -1628,69 +1576,75 @@ export const hotelQueryRouter = router({ JSON.stringify({ query: metricsData }) ) - const apiResponse = await api.get( - api.endpoints.v1.Package.Breakfast.hotel(input.hotelId), - { - cache: undefined, - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - next: { - revalidate: 60, - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.breakfastPackage.fail.add(1, { - ...metricsData, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.package.breakfast error", - JSON.stringify({ - query: metricsData, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, + const cacheClient = await getCacheClient() + const breakfastPackages = await cacheClient.cacheOrGet( + `${apiLang}:adults${input.adults}:startDate:${params.StartDate}:endDate:${params.EndDate}`, + async () => { + const apiResponse = await api.get( + api.endpoints.v1.Package.Breakfast.hotel(input.hotelId), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, }, - }) - ) - return null - } + params + ) - const apiJson = await apiResponse.json() - const breakfastPackages = breakfastPackagesSchema.safeParse(apiJson) - if (!breakfastPackages.success) { - metrics.breakfastPackage.fail.add(1, { - ...metricsData, - error_type: "validation_error", - error: JSON.stringify(breakfastPackages.error), - }) - console.error( - "api.package.breakfast validation error", - JSON.stringify({ - query: metricsData, - error: breakfastPackages.error, - }) - ) - return null - } + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.breakfastPackage.fail.add(1, { + ...metricsData, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.package.breakfast error", + JSON.stringify({ + query: metricsData, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + throw new Error("Unable to fetch breakfast packages") + } - metrics.breakfastPackage.success.add(1, metricsData) - console.info( - "api.package.breakfast success", - JSON.stringify({ - query: metricsData, - }) + const apiJson = await apiResponse.json() + const breakfastPackages = breakfastPackagesSchema.safeParse(apiJson) + if (!breakfastPackages.success) { + metrics.breakfastPackage.fail.add(1, { + ...metricsData, + error_type: "validation_error", + error: JSON.stringify(breakfastPackages.error), + }) + console.error( + "api.package.breakfast validation error", + JSON.stringify({ + query: metricsData, + error: breakfastPackages.error, + }) + ) + + throw new Error("Unable to parse breakfast packages") + } + + metrics.breakfastPackage.success.add(1, metricsData) + console.info( + "api.package.breakfast success", + JSON.stringify({ + query: metricsData, + }) + ) + + return breakfastPackages.data + }, + "1h" ) if (ctx.session?.token) { @@ -1701,7 +1655,7 @@ export const hotelQueryRouter = router({ user.membership && ["L6", "L7"].includes(user.membership.membershipLevel) ) { - const freeBreakfastPackage = breakfastPackages.data.find( + const freeBreakfastPackage = breakfastPackages.find( (pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) if (freeBreakfastPackage?.localPrice) { @@ -1711,7 +1665,7 @@ export const hotelQueryRouter = router({ } } - return breakfastPackages.data.filter( + return breakfastPackages.filter( (pkg) => pkg.code !== BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) }), @@ -1727,78 +1681,80 @@ export const hotelQueryRouter = router({ language: apiLang, } - const metricsData = { ...params, hotelId: input.hotelId } - metrics.ancillaryPackage.counter.add(1, metricsData) - console.info( - "api.package.ancillary start", - JSON.stringify({ query: metricsData }) - ) - - const apiResponse = await api.get( - api.endpoints.v1.Package.Ancillary.hotel(input.hotelId), - { - cache: undefined, - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - next: { - revalidate: 60, - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.ancillaryPackage.fail.add(1, { - ...metricsData, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.package.ancillary start error", - JSON.stringify({ - query: metricsData, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + `${apiLang}:hotel:${input.hotelId}:ancillaries:startDate:${params.StartDate}:endDate:${params.EndDate}`, + async () => { + const metricsData = { ...params, hotelId: input.hotelId } + metrics.ancillaryPackage.counter.add(1, metricsData) + console.info( + "api.package.ancillary start", + JSON.stringify({ query: metricsData }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Package.Ancillary.hotel(input.hotelId), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, }, - }) - ) - return null - } + params + ) - const apiJson = await apiResponse.json() - const ancillaryPackages = ancillaryPackagesSchema.safeParse(apiJson) - if (!ancillaryPackages.success) { - metrics.ancillaryPackage.fail.add(1, { - ...metricsData, - error_type: "validation_error", - error: JSON.stringify(ancillaryPackages.error), - }) - console.error( - "api.package.ancillary validation error", - JSON.stringify({ - query: metricsData, - error: ancillaryPackages.error, - }) - ) - return null - } + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.ancillaryPackage.fail.add(1, { + ...metricsData, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.package.ancillary start error", + JSON.stringify({ + query: metricsData, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } - metrics.ancillaryPackage.success.add(1, metricsData) - console.info( - "api.package.ancillary success", - JSON.stringify({ - query: metricsData, - }) + const apiJson = await apiResponse.json() + const ancillaryPackages = ancillaryPackagesSchema.safeParse(apiJson) + if (!ancillaryPackages.success) { + metrics.ancillaryPackage.fail.add(1, { + ...metricsData, + error_type: "validation_error", + error: JSON.stringify(ancillaryPackages.error), + }) + console.error( + "api.package.ancillary validation error", + JSON.stringify({ + query: metricsData, + error: ancillaryPackages.error, + }) + ) + return null + } + + metrics.ancillaryPackage.success.add(1, metricsData) + console.info( + "api.package.ancillary success", + JSON.stringify({ + query: metricsData, + }) + ) + return ancillaryPackages.data + }, + "1h" ) - return ancillaryPackages.data }), }), }) diff --git a/apps/scandic-web/server/routers/hotels/schemas/availability/productType.ts b/apps/scandic-web/server/routers/hotels/schemas/availability/productType.ts index 4b3950f8b..3191c260a 100644 --- a/apps/scandic-web/server/routers/hotels/schemas/availability/productType.ts +++ b/apps/scandic-web/server/routers/hotels/schemas/availability/productType.ts @@ -1,8 +1,8 @@ import { z } from "zod" import { - productTypePriceSchema, productTypePointsSchema, + productTypePriceSchema, } from "../productTypePrice" export const productTypeSchema = z diff --git a/apps/scandic-web/server/routers/hotels/utils.ts b/apps/scandic-web/server/routers/hotels/utils.ts index 947b4f2eb..ebcc7f28d 100644 --- a/apps/scandic-web/server/routers/hotels/utils.ts +++ b/apps/scandic-web/server/routers/hotels/utils.ts @@ -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 { + 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 { + 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) => { diff --git a/apps/scandic-web/server/routers/partners/jobylon/query.ts b/apps/scandic-web/server/routers/partners/jobylon/query.ts index 9613aed2f..44d3e23ec 100644 --- a/apps/scandic-web/server/routers/partners/jobylon/query.ts +++ b/apps/scandic-web/server/routers/partners/jobylon/query.ts @@ -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 }), }), }) diff --git a/apps/scandic-web/server/routers/user/query.ts b/apps/scandic-web/server/routers/user/query.ts index ea389f4de..5898dd86c 100644 --- a/apps/scandic-web/server/routers/user/query.ts +++ b/apps/scandic-web/server/routers/user/query.ts @@ -641,11 +641,9 @@ export const userQueryRouter = router({ const apiResponse = await api.get( api.endpoints.v1.Profile.Transaction.friendTransactions, { - cache: undefined, // override defaultOptions headers: { Authorization: `Bearer ${ctx.session.token.access_token}`, }, - next: { revalidate: 30 * 60 * 1000 }, } ) diff --git a/apps/scandic-web/server/tokenManager.ts b/apps/scandic-web/server/tokenManager.ts index 1416fda20..515b490f0 100644 --- a/apps/scandic-web/server/tokenManager.ts +++ b/apps/scandic-web/server/tokenManager.ts @@ -1,9 +1,8 @@ -import { metrics } from "@opentelemetry/api" -import { revalidateTag, unstable_cache } from "next/cache" +import { metrics, trace } from "@opentelemetry/api" import { env } from "@/env/server" -import { generateServiceTokenTag } from "@/utils/generateTag" +import { getCacheClient } from "@/services/dataCache" import type { ServiceTokenResponse } from "@/types/tokens" @@ -12,13 +11,49 @@ const meter = metrics.getMeter("trpc.context.serviceToken") const fetchServiceTokenCounter = meter.createCounter( "trpc.context.serviceToken.fetch-new-token" ) -const fetchTempServiceTokenCounter = meter.createCounter( - "trpc.context.serviceToken.fetch-temporary" -) + const fetchServiceTokenFailCounter = meter.createCounter( "trpc.context.serviceToken.fetch-fail" ) +export async function getServiceToken() { + const tracer = trace.getTracer("getServiceToken") + + return await tracer.startActiveSpan("getServiceToken", async () => { + let scopes: string[] = [] + if (env.ENABLE_BOOKING_FLOW) { + scopes = ["profile", "hotel", "booking", "package", "availability"] + } else { + scopes = ["profile"] + } + const cacheKey = getServiceTokenCacheKey(scopes) + + const cacheClient = await getCacheClient() + const token = + await cacheClient.get>>(cacheKey) + console.log("[DEBUG] getServiceToken", typeof token, token) + if (!token || token.expiresAt < Date.now()) { + return await tracer.startActiveSpan("fetch new token", async () => { + const newToken = await getJwt(scopes) + const relativeTime = (newToken.expiresAt - Date.now()) / 1000 + await cacheClient.set(cacheKey, newToken, relativeTime) + + return newToken.jwt + }) + } + + return token.jwt + }) +} + +async function getJwt(scopes: string[]) { + fetchServiceTokenCounter.add(1) + const jwt = await fetchServiceToken(scopes) + + const expiresAt = Date.now() + jwt.expires_in * 1000 + return { expiresAt, jwt } +} + async function fetchServiceToken(scopes: string[]) { fetchServiceTokenCounter.add(1) @@ -69,41 +104,6 @@ async function fetchServiceToken(scopes: string[]) { return response.json() as Promise } -export async function getServiceToken() { - let scopes: string[] = [] - if (env.ENABLE_BOOKING_FLOW) { - scopes = ["profile", "hotel", "booking", "package", "availability"] - } else { - scopes = ["profile"] - } - - const tag = generateServiceTokenTag(scopes) - const getCachedJwt = unstable_cache( - async (scopes) => { - const jwt = await fetchServiceToken(scopes) - - const expiresAt = Date.now() + jwt.expires_in * 1000 - return { expiresAt, jwt } - }, - [tag], - { tags: [tag] } - ) - - const cachedJwt = await getCachedJwt(scopes) - if (cachedJwt.expiresAt < Date.now()) { - console.log( - "trpc.context.serviceToken: Service token expired, revalidating tag" - ) - - revalidateTag(tag) - - console.log( - "trpc.context.serviceToken: Fetching new temporary service token." - ) - fetchTempServiceTokenCounter.add(1) - const newToken = await fetchServiceToken(scopes) - return newToken - } - - return cachedJwt.jwt +function getServiceTokenCacheKey(scopes: string[]): string { + return `serviceToken:${scopes.join(",")}` } diff --git a/apps/scandic-web/server/trpc.ts b/apps/scandic-web/server/trpc.ts index 6eb55fdd0..64f3d1f24 100644 --- a/apps/scandic-web/server/trpc.ts +++ b/apps/scandic-web/server/trpc.ts @@ -138,7 +138,9 @@ export const safeProtectedProcedure = baseProcedure.use(async function (opts) { }) export const serviceProcedure = baseProcedure.use(async (opts) => { - const { access_token } = await getServiceToken() + const token = await getServiceToken() + console.log("[DEBUG] token", typeof token, token) + const { access_token } = token if (!access_token) { throw internalServerError(`[serviceProcedure] No service token`) } diff --git a/apps/scandic-web/services/cms/fetchAndCacheEntry.ts b/apps/scandic-web/services/cms/fetchAndCacheEntry.ts index ecb8c4781..a82bf00eb 100644 --- a/apps/scandic-web/services/cms/fetchAndCacheEntry.ts +++ b/apps/scandic-web/services/cms/fetchAndCacheEntry.ts @@ -1,50 +1,23 @@ +import { getCacheClient } from "@/services/dataCache" import { resolve as resolveEntry } from "@/utils/entry" import type { Lang } from "@/constants/languages" -const entryResponseCache: Map< - string, - { - contentType: string | null - uid: string | null - expiresAt: number - } -> = new Map() - -let size: number = 0 - export const fetchAndCacheEntry = async (path: string, lang: Lang) => { - const cacheKey = `${path + lang}` - const cachedResponse = entryResponseCache.get(cacheKey) + path = path || "/" + const cacheKey = `${lang}:resolveentry:${path}` + const cache = await getCacheClient() - if (cachedResponse && cachedResponse.expiresAt > Date.now() / 1000) { - console.log("[CMS MIDDLEWARE]: CACHE HIT") - return cachedResponse - } + return cache.cacheOrGet( + cacheKey, + async () => { + const { contentType, uid } = await resolveEntry(path, lang) - if (cachedResponse && cachedResponse.expiresAt < Date.now() / 1000) { - console.log("[CMS MIDDLEWARE]: CACHE STALE") - size -= JSON.stringify(cachedResponse).length - entryResponseCache.delete(cacheKey) - } else { - console.log("[CMS MIDDLEWARE]: CACHE MISS") - } - - const { contentType, uid } = await resolveEntry(path, lang) - let expiresAt = Date.now() / 1000 - if (!contentType || !uid) { - expiresAt += 600 - } else { - expiresAt += 3600 * 12 - } - const entryCache = { contentType, uid, expiresAt } - size += JSON.stringify(entryCache).length - console.log("[CMS MIDDLEWARE] Adding to cache", entryCache) - console.log("[CMS MIDDLEWARE] Cache size (total)", size) - entryResponseCache.set(cacheKey, entryCache) - - return { - contentType, - uid, - } + return { + contentType, + uid, + } + }, + "1d" + ) } diff --git a/apps/scandic-web/services/dataCache/Cache.ts b/apps/scandic-web/services/dataCache/Cache.ts new file mode 100644 index 000000000..8f164c65e --- /dev/null +++ b/apps/scandic-web/services/dataCache/Cache.ts @@ -0,0 +1,96 @@ +const ONE_HOUR_IN_SECONDS = 3_600 as const +const ONE_DAY_IN_SECONDS = 86_400 as const + +export const namedCacheTimeMap: Record = { + "no cache": 0, + "1m": 60, + "5m": 300, + "10m": 600, + "1h": ONE_HOUR_IN_SECONDS, + "3h": ONE_HOUR_IN_SECONDS * 3, + "6h": ONE_HOUR_IN_SECONDS * 6, + "1d": ONE_DAY_IN_SECONDS, + "3d": ONE_DAY_IN_SECONDS * 3, + max: ONE_DAY_IN_SECONDS * 30, +} as const + +export const namedCacheTimes = [ + "no cache", + "1m", + "5m", + "10m", + "1h", + "3h", + "6h", + "1d", + "3d", + "max", +] as const + +export type NamedCacheTimes = (typeof namedCacheTimes)[number] + +/** + * Retrieves the cache time in seconds based on the given cache time. + * @param cacheTime - The time value to determine, either a named cache time or a number of seconds. + * @returns The cache time in seconds. + */ +export const getCacheTimeInSeconds = (cacheTime: CacheTime): number => { + if (typeof cacheTime === "number") { + if (cacheTime < 0 || !Number.isInteger(cacheTime)) { + return 0 + } + + return cacheTime + } + + return namedCacheTimeMap[cacheTime] ?? 0 +} + +export type CacheTime = NamedCacheTimes | number + +export type DataCache = { + /** + * Type of cache + */ + type: "edge" | "redis" | "in-memory" | "unstable-cache" + + /** + * Helper function that retrieves from the cache if it exists, otherwise calls the callback and caches the result. + * If the call fails, the cache is not updated. + * @param key The cache key + * @param getDataFromSource An async function that provides a value to cache + * @param ttl Time to live, either a named cache time or a number of seconds + * @returns The cached value or the result from the callback + */ + cacheOrGet: ( + key: string | string[], + getDataFromSource: () => Promise, + ttl: CacheTime + ) => Promise + + /** + * Get a value from the cache, if it exists + * @see `cacheOrGet` for a more convenient way to cache values + * @param key The cache key to retrieve the value for + * @returns The cached value or undefined if not found + */ + get: (key: string) => Promise + + /** + * Sets a value in the cache. + * @see `cacheOrGet` for a more convenient way to cache values + * @param key CacheKey to set + * @param obj Value to be cached + * @param ttl Time to live, either a named cache time or a number of seconds + * @returns A promise that resolves when the value has been cached + */ + set: (key: string, obj: T, ttl: CacheTime) => Promise + + /** + * Deletes a key from the cache + * @param key CacheKey to delete + * @param fuzzy If true, does a wildcard delete. *key* + * @returns + */ + deleteKey: (key: string, opts?: { fuzzy?: boolean }) => Promise +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/cacheOrGet.ts b/apps/scandic-web/services/dataCache/DistributedCache/cacheOrGet.ts new file mode 100644 index 000000000..d0c24e5d3 --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/cacheOrGet.ts @@ -0,0 +1,29 @@ +import { type CacheTime, type DataCache } from "@/services/dataCache/Cache" + +import { cacheLogger } from "../logger" +import { generateCacheKey } from "./generateCacheKey" +import { get } from "./get" +import { set } from "./set" + +export const cacheOrGet: DataCache["cacheOrGet"] = async ( + key: string | string[], + callback: () => Promise, + ttl: CacheTime +) => { + const cacheKey = generateCacheKey(key) + const cachedValue = await get(cacheKey) + + if (!cachedValue) { + const perf = performance.now() + const data = await callback() + + cacheLogger.debug( + `Getting data '${cacheKey}' took ${(performance.now() - perf).toFixed(2)}ms` + ) + + await set(cacheKey, data, ttl) + return data + } + + return cachedValue +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/client.ts b/apps/scandic-web/services/dataCache/DistributedCache/client.ts new file mode 100644 index 000000000..133b68b3e --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/client.ts @@ -0,0 +1,19 @@ +import { env } from "@/env/server" + +import { cacheOrGet } from "./cacheOrGet" +import { deleteKey } from "./deleteKey" +import { get } from "./get" +import { set } from "./set" + +import type { DataCache } from "@/services/dataCache/Cache" + +export const API_KEY = env.REDIS_API_KEY ?? "" +export async function createDistributedCache(): Promise { + return { + type: "redis", + get, + set, + cacheOrGet, + deleteKey, + } satisfies DataCache +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/deleteKey.ts b/apps/scandic-web/services/dataCache/DistributedCache/deleteKey.ts new file mode 100644 index 000000000..a5b0af783 --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/deleteKey.ts @@ -0,0 +1,38 @@ +import * as Sentry from "@sentry/nextjs" + +import { cacheLogger } from "../logger" +import { API_KEY } from "./client" +import { getCacheEndpoint } from "./endpoints" + +export async function deleteKey(key: string) { + const perf = performance.now() + + const response = await fetch(getCacheEndpoint(key), { + method: "DELETE", + cache: "no-cache", + headers: { + "x-api-key": API_KEY, + }, + }) + + if (!response.ok) { + if (response.status !== 404) { + Sentry.captureMessage("Unable to DELETE cachekey", { + level: "error", + extra: { + cacheKey: key, + statusCode: response?.status, + statusText: response?.statusText, + }, + }) + } + + return undefined + } + + const data = (await response.json()) as { data: T } + cacheLogger.debug( + `Delete '${key}' took ${(performance.now() - perf).toFixed(2)}ms` + ) + return data.data +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/endpoints.ts b/apps/scandic-web/services/dataCache/DistributedCache/endpoints.ts new file mode 100644 index 000000000..95c5b64d9 --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/endpoints.ts @@ -0,0 +1,24 @@ +import { env } from "@/env/server" + +import { generateCacheKey } from "./generateCacheKey" + +export function getCacheEndpoint(key: string) { + if (!env.REDIS_API_HOST) { + throw new Error("REDIS_API_HOST is not set") + } + + const url = new URL(`/api/cache`, env.REDIS_API_HOST) + url.searchParams.set("key", encodeURIComponent(generateCacheKey(key))) + + return url +} + +export function getClearCacheEndpoint() { + if (!env.REDIS_API_HOST) { + throw new Error("REDIS_API_HOST is not set") + } + + const url = new URL(`/api/cache/clear`, env.REDIS_API_HOST) + + return url +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/generateCacheKey.ts b/apps/scandic-web/services/dataCache/DistributedCache/generateCacheKey.ts new file mode 100644 index 000000000..2d858952e --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/generateCacheKey.ts @@ -0,0 +1,20 @@ +import { env } from "@/env/server" + +export function generateCacheKey(key: string | string[]): string { + const prefix = getPrefix() + key = Array.isArray(key) ? key.join("_") : key + + return `${prefix ? `${prefix}:` : ""}${key}` +} + +function getPrefix(): string { + if (process.env.NODE_ENV === "development") { + const devPrefix = process.env.USER || process.env.USERNAME || "dev" + return `${devPrefix}` + } + + const branch = env.BRANCH.trim() + const gitSha = env.GIT_SHA?.trim().substring(0, 7) + + return `${branch}:${gitSha}` +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/get.ts b/apps/scandic-web/services/dataCache/DistributedCache/get.ts new file mode 100644 index 000000000..c7802e80e --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/get.ts @@ -0,0 +1,62 @@ +import * as Sentry from "@sentry/nextjs" + +import { safeTry } from "@/utils/safeTry" + +import { cacheLogger } from "../logger" +import { API_KEY } from "./client" +import { deleteKey } from "./deleteKey" +import { getCacheEndpoint } from "./endpoints" + +export async function get(key: string) { + const perf = performance.now() + + const [response, error] = await safeTry( + fetch(getCacheEndpoint(key), { + method: "GET", + cache: "no-cache", + headers: { + "x-api-key": API_KEY, + }, + }) + ) + + if (!response || error || !response.ok) { + if (response?.status === 404) { + cacheLogger.debug( + `Miss '${key}' took ${(performance.now() - perf).toFixed(2)}ms` + ) + return undefined + } + + Sentry.captureMessage("Unable to GET cachekey", { + level: "error", + extra: { + cacheKey: key, + errorMessage: error instanceof Error ? error.message : undefined, + statusCode: response?.status, + statusText: response?.statusText, + }, + }) + return undefined + } + + const [data, jsonError] = await safeTry( + response.json() as Promise<{ data: T }> + ) + + if (jsonError) { + cacheLogger.error("Failed to parse cache response", { + key, + error: jsonError, + }) + + await deleteKey(key) + return undefined + } + + cacheLogger.debug( + `Hit '${key}' took ${(performance.now() - perf).toFixed(2)}ms` + ) + + return data?.data +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/index.ts b/apps/scandic-web/services/dataCache/DistributedCache/index.ts new file mode 100644 index 000000000..e574393a2 --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/index.ts @@ -0,0 +1 @@ +export { createDistributedCache } from "./client" diff --git a/apps/scandic-web/services/dataCache/DistributedCache/set.ts b/apps/scandic-web/services/dataCache/DistributedCache/set.ts new file mode 100644 index 000000000..8c923202f --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/set.ts @@ -0,0 +1,33 @@ +import * as Sentry from "@sentry/nextjs" + +import { safeTry } from "@/utils/safeTry" + +import { type CacheTime, getCacheTimeInSeconds } from "../Cache" +import { API_KEY } from "./client" +import { getCacheEndpoint } from "./endpoints" + +export async function set(key: string, value: T, ttl: CacheTime) { + const [response, error] = await safeTry( + fetch(getCacheEndpoint(key), { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-api-key": API_KEY, + }, + body: JSON.stringify({ data: value, ttl: getCacheTimeInSeconds(ttl) }), + cache: "no-cache", + }) + ) + + if (!response || error || !response.ok) { + Sentry.captureMessage("Unable to SET cachekey", { + level: "error", + extra: { + cacheKey: key, + errorMessage: error instanceof Error ? error.message : undefined, + statusCode: response?.status, + statusText: response?.statusText, + }, + }) + } +} diff --git a/apps/scandic-web/services/dataCache/DistributedCache/shouldHaveFullTtl.ts b/apps/scandic-web/services/dataCache/DistributedCache/shouldHaveFullTtl.ts new file mode 100644 index 000000000..dae6a5338 --- /dev/null +++ b/apps/scandic-web/services/dataCache/DistributedCache/shouldHaveFullTtl.ts @@ -0,0 +1,5 @@ +import { env } from "@/env/server" + +export function shouldHaveFullTtl() { + return env.BRANCH === "release" +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheMap.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheMap.ts new file mode 100644 index 000000000..f02eebc5a --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheMap.ts @@ -0,0 +1,9 @@ +export const cacheMap = new Map< + string, + { + /** Absolute expiration timestamp (`Date.now()`) */ + expiresAt: number + /** The cached data */ + data: unknown + } +>() diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheOrGet.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheOrGet.ts new file mode 100644 index 000000000..428080605 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/cacheOrGet.ts @@ -0,0 +1,36 @@ +import { type CacheTime, type DataCache } from "@/services/dataCache/Cache" +import { cacheLogger } from "@/services/dataCache/logger" + +import { get } from "./get" +import { set } from "./set" + +export const cacheOrGet: DataCache["cacheOrGet"] = async ( + key: string | string[], + callback: () => Promise, + ttl: CacheTime +): Promise => { + if (Array.isArray(key)) { + key = key.join("-") + } + + const cached = await get(key) + + if (cached) { + return cached as T + } + + cacheLogger.debug(`Miss for key '${key}'`) + + try { + const data = await callback() + await set(key, data, ttl) + + return data + } catch (e) { + cacheLogger.error( + `Error while fetching data for key '${key}', avoid caching`, + e + ) + throw e + } +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/deleteAll.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/deleteAll.ts new file mode 100644 index 000000000..3a6466356 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/deleteAll.ts @@ -0,0 +1,9 @@ +import { cacheLogger } from "@/services/dataCache/logger" + +import { cacheMap } from "./cacheMap" + +export async function deleteAll() { + cacheLogger.debug("Deleting all keys") + + cacheMap.clear() +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/deleteKey.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/deleteKey.ts new file mode 100644 index 000000000..2cc5ea7f5 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/deleteKey.ts @@ -0,0 +1,17 @@ +import { cacheLogger } from "@/services/dataCache/logger" + +import { cacheMap } from "./cacheMap" + +export async function deleteKey(key: string, opts?: { fuzzy?: boolean }) { + cacheLogger.debug("Deleting key", key) + if (opts?.fuzzy) { + cacheMap.forEach((_, k) => { + if (k.includes(key)) { + cacheMap.delete(k) + } + }) + return + } + + cacheMap.delete(key) +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/get.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/get.ts new file mode 100644 index 000000000..62832a416 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/get.ts @@ -0,0 +1,23 @@ +import { cacheLogger } from "@/services/dataCache/logger" + +import { cacheMap } from "./cacheMap" + +export async function get(key: string): Promise { + const cached = cacheMap.get(key) + if (!cached) { + return undefined + } + + if (cached.expiresAt < Date.now()) { + cacheLogger.debug(`Expired for key '${key}'`) + cacheMap.delete(key) + return undefined + } + if (cached.data === undefined) { + cacheLogger.debug(`Data is undefined for key '${key}'`) + cacheMap.delete(key) + return undefined + } + + return cached.data as T +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/index.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/index.ts new file mode 100644 index 000000000..13cb45e30 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/index.ts @@ -0,0 +1,10 @@ +import { cacheOrGet } from "./cacheOrGet" +import { deleteKey } from "./deleteKey" +import { get } from "./get" +import { set } from "./set" + +import type { DataCache } from "@/services/dataCache/Cache" + +export async function createInMemoryCache(): Promise { + return { type: "in-memory", cacheOrGet, deleteKey, get, set } +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/set.ts b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/set.ts new file mode 100644 index 000000000..62f5e53c7 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/InMemoryCache/set.ts @@ -0,0 +1,17 @@ +import { + type CacheTime, + getCacheTimeInSeconds, +} from "@/services/dataCache/Cache" + +import { cacheMap } from "./cacheMap" + +export async function set( + key: string, + data: T, + ttl: CacheTime +): Promise { + cacheMap.set(key, { + data: data, + expiresAt: Date.now() + getCacheTimeInSeconds(ttl) * 1000, + }) +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/cacheOrGet.ts b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/cacheOrGet.ts new file mode 100644 index 000000000..04943fe45 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/cacheOrGet.ts @@ -0,0 +1,29 @@ +import { unstable_cache } from "next/cache" + +import { + type CacheTime, + type DataCache, + getCacheTimeInSeconds, +} from "@/services/dataCache/Cache" + +import { cacheLogger } from "../../logger" + +export const cacheOrGet: DataCache["cacheOrGet"] = async ( + key: string | string[], + callback: () => Promise, + ttl: CacheTime +): Promise => { + if (!Array.isArray(key)) { + key = [key] + } + + const perf = performance.now() + + const res = await unstable_cache(callback, key, { + revalidate: getCacheTimeInSeconds(ttl), + tags: key, + })() + cacheLogger.debug(`'${key}' took ${(performance.now() - perf).toFixed(2)}ms`) + + return res +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/deleteKey.ts b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/deleteKey.ts new file mode 100644 index 000000000..1624a4c15 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/deleteKey.ts @@ -0,0 +1,5 @@ +import { revalidateTag } from "next/cache" + +export async function deleteKey(key: string) { + revalidateTag(key) +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/get.ts b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/get.ts new file mode 100644 index 000000000..f2de1e01a --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/get.ts @@ -0,0 +1,14 @@ +import type { DataCache } from "@/services/dataCache/Cache" + +/** + * This function is not implemented for unstable_cache due to underlying cache implementation. + * @see cacheOrGet + * @param _key + * @returns + */ +export const get: DataCache["get"] = async ( + _key: string +): Promise => { + console.warn("UnstableCache.get is not implemented, use cacheOrGet") + return undefined +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/index.ts b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/index.ts new file mode 100644 index 000000000..7a25e367c --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/index.ts @@ -0,0 +1,10 @@ +import { cacheOrGet } from "./cacheOrGet" +import { deleteKey } from "./deleteKey" +import { get } from "./get" +import { set } from "./set" + +import type { DataCache } from "@/services/dataCache/Cache" + +export async function createUnstableCache(): Promise { + return { type: "unstable-cache", cacheOrGet, deleteKey, get, set } +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/set.ts b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/set.ts new file mode 100644 index 000000000..7cb20f5ec --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/UnstableCache/set.ts @@ -0,0 +1,15 @@ +import { type CacheTime, type DataCache } from "@/services/dataCache/Cache" + +/** + * This function is not implemented for unstable_cache due to underlying cache implementation. + * @see cacheOrGet + * @param _key + * @returns + */ +export const set: DataCache["set"] = async ( + _key: string, + _data: T, + _ttl: CacheTime +): Promise => { + console.warn("UnstableCache.set is not implemented, use cacheOrGet") +} diff --git a/apps/scandic-web/services/dataCache/MemoryCache/createMemoryCache.ts b/apps/scandic-web/services/dataCache/MemoryCache/createMemoryCache.ts new file mode 100644 index 000000000..7612104b5 --- /dev/null +++ b/apps/scandic-web/services/dataCache/MemoryCache/createMemoryCache.ts @@ -0,0 +1,17 @@ +import { isEdge } from "@/utils/isEdge" + +import { createInMemoryCache } from "./InMemoryCache" +import { createUnstableCache } from "./UnstableCache" + +import type { DataCache } from "@/services/dataCache/Cache" + +export function createMemoryCache(): Promise { + if (isEdge) { + /** + * unstable_cache is not available on the edge runtime + */ + return createInMemoryCache() + } + + return createUnstableCache() +} diff --git a/apps/scandic-web/services/dataCache/index.ts b/apps/scandic-web/services/dataCache/index.ts new file mode 100644 index 000000000..acedfdee2 --- /dev/null +++ b/apps/scandic-web/services/dataCache/index.ts @@ -0,0 +1,28 @@ +import { env } from "@/env/server" + +import { isEdge } from "@/utils/isEdge" + +import { createMemoryCache } from "./MemoryCache/createMemoryCache" +import { createDistributedCache } from "./DistributedCache" +import { cacheLogger } from "./logger" + +import type { DataCache } from "./Cache" + +export type { CacheTime, DataCache } from "./Cache" + +export async function getCacheClient(): Promise { + if (global.cacheClient) { + return global.cacheClient + } + + global.cacheClient = env.REDIS_API_HOST + ? createDistributedCache() + : createMemoryCache() + + const cacheClient = await global.cacheClient + cacheLogger.debug( + `Creating ${cacheClient.type} cache on ${isEdge ? "edge" : "server"} runtime` + ) + + return global.cacheClient +} diff --git a/apps/scandic-web/services/dataCache/logger.ts b/apps/scandic-web/services/dataCache/logger.ts new file mode 100644 index 000000000..303eef1aa --- /dev/null +++ b/apps/scandic-web/services/dataCache/logger.ts @@ -0,0 +1,44 @@ +export const cacheLogger = { + async debug(message: string, ...args: unknown[]): Promise { + console.debug(`${await loggerPrefix()} ${message}`, ...args) + }, + async warn(message: string, ...args: unknown[]): Promise { + console.warn(`${await loggerPrefix()} Warning - ${message}`, ...args) + }, + async error(message: string, ...args: unknown[]): Promise { + console.error(`${await loggerPrefix()} Error - ${message}`, ...args) + }, +} + +async function loggerPrefix() { + const instancePrefix = await getCachePrefix() + return `[Cache] ${instancePrefix ?? ""}`.trim() +} + +async function getCachePrefix() { + const cacheCreated = await isPromiseResolved(global.cacheClient) + + if (!cacheCreated.resolved) { + return null + } + + const instanceType = cacheCreated.value?.type + if (!instanceType) { + return null + } + + return `[${instanceType}]` +} + +function isPromiseResolved(promise: Promise | undefined) { + if (!promise) { + return { resolved: false, value: undefined } + } + + const check = Promise.race([promise, Promise.resolve("__PENDING__")]) + + return check.then((result) => ({ + resolved: result !== "__PENDING__", + value: result !== "__PENDING__" ? (result as Awaited) : undefined, + })) +} diff --git a/apps/scandic-web/stores/enter-details/index.ts b/apps/scandic-web/stores/enter-details/index.ts index c83089967..0e7e695ba 100644 --- a/apps/scandic-web/stores/enter-details/index.ts +++ b/apps/scandic-web/stores/enter-details/index.ts @@ -258,7 +258,8 @@ export function createDetailsStore( } const stateTotalLocalPrice = state.totalPrice.local.price - const stateTotalLocalRegularPrice = state.totalPrice.local.regularPrice + const stateTotalLocalRegularPrice = + state.totalPrice.local.regularPrice const addToTotalPrice = (currentRoom.room.breakfast === undefined || @@ -292,9 +293,9 @@ export function createDetailsStore( currency: breakfast.localPrice.currency, price: stateTotalLocalPrice + breakfastTotalPrice, regularPrice: stateTotalLocalRegularPrice - ? stateTotalLocalRegularPrice + breakfastTotalPrice - : undefined, - }, + ? stateTotalLocalRegularPrice + breakfastTotalPrice + : undefined, + }, } } @@ -326,9 +327,9 @@ export function createDetailsStore( if (localPrice < 0) { localPrice = 0 } - let regularPrice = stateTotalLocalRegularPrice - ? stateTotalLocalRegularPrice - currentBreakfastTotalPrice - : undefined + let regularPrice = stateTotalLocalRegularPrice + ? stateTotalLocalRegularPrice - currentBreakfastTotalPrice + : undefined state.totalPrice = { requested: state.totalPrice.requested && { diff --git a/apps/scandic-web/stores/select-rate/index.ts b/apps/scandic-web/stores/select-rate/index.ts index 1ef70d082..cefedd3d5 100644 --- a/apps/scandic-web/stores/select-rate/index.ts +++ b/apps/scandic-web/stores/select-rate/index.ts @@ -274,18 +274,18 @@ export function createRatesStore({ bookingRoom: room, rooms: selectedPackage ? allRooms.filter((r) => - r.features.find((f) => f.code === selectedPackage) - ) + r.features.find((f) => f.code === selectedPackage) + ) : allRooms, selectedPackage, selectedRate: selectedRate && product ? { - features: selectedRate.features, - product, - roomType: selectedRate.roomType, - roomTypeCode: selectedRate.roomTypeCode, - } + features: selectedRate.features, + product, + roomType: selectedRate.roomType, + roomTypeCode: selectedRate.roomTypeCode, + } : null, } }), diff --git a/apps/scandic-web/types/components/blocks/textCols.ts b/apps/scandic-web/types/components/blocks/textCols.ts index 55c7ab939..a4ec3ff6c 100644 --- a/apps/scandic-web/types/components/blocks/textCols.ts +++ b/apps/scandic-web/types/components/blocks/textCols.ts @@ -1,3 +1,3 @@ import type { TextCols } from "@/types/trpc/routers/contentstack/blocks" -export interface TextColProps extends Pick { } \ No newline at end of file +export interface TextColProps extends Pick {} diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts index 4e179b7a6..67d33273b 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/rooms.ts @@ -5,7 +5,7 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmat import type { linkedReservationSchema } from "@/server/routers/booking/output" export interface LinkedReservationSchema - extends z.output { } + extends z.output {} export interface BookingConfirmationRoomsProps extends Pick { diff --git a/apps/scandic-web/types/global.d.ts b/apps/scandic-web/types/global.d.ts new file mode 100644 index 000000000..ee2a717df --- /dev/null +++ b/apps/scandic-web/types/global.d.ts @@ -0,0 +1,5 @@ +import type { DataCache } from "@/services/dataCache" + +declare global { + var cacheClient: Promise | undefined +} diff --git a/apps/scandic-web/types/requests/blocks/list.ts b/apps/scandic-web/types/requests/blocks/list.ts index 0c102e64b..64a57032c 100644 --- a/apps/scandic-web/types/requests/blocks/list.ts +++ b/apps/scandic-web/types/requests/blocks/list.ts @@ -37,9 +37,7 @@ type RegularListItem = Typename< BlockListItemsEnum.CurrentBlocksPageBlocksListBlockListItemsListItem > -export type ListItem = - | ExternalLinkListItem - | RegularListItem +export type ListItem = ExternalLinkListItem | RegularListItem export type List = { list: { diff --git a/apps/scandic-web/utils/generateTag.ts b/apps/scandic-web/utils/generateTag.ts index df73aab53..a0055d421 100644 --- a/apps/scandic-web/utils/generateTag.ts +++ b/apps/scandic-web/utils/generateTag.ts @@ -49,7 +49,7 @@ export function generateRefTag( * as it is the same entity as the actual page tag otherwise * @returns string */ -export function generateTag(lang: Lang, uid: string, affix?: string) { +export function generateTag(lang: Lang, uid: string, affix?: string | null) { if (affix) { return `${lang}:${uid}:${affix}` } diff --git a/apps/scandic-web/utils/isEdge.ts b/apps/scandic-web/utils/isEdge.ts new file mode 100644 index 000000000..7d58b0b4e --- /dev/null +++ b/apps/scandic-web/utils/isEdge.ts @@ -0,0 +1,3 @@ +export const isEdge = + typeof (global as any).EdgeRuntime !== "undefined" || + typeof (global as any).Deno !== "undefined" diff --git a/apps/scandic-web/utils/tracking/payment.ts b/apps/scandic-web/utils/tracking/payment.ts index ecee63891..ffa64e5a9 100644 --- a/apps/scandic-web/utils/tracking/payment.ts +++ b/apps/scandic-web/utils/tracking/payment.ts @@ -1,6 +1,9 @@ import { trackEvent } from "./base" -import type { PaymentEvent, PaymentFailEvent } from "@/types/components/tracking" +import type { + PaymentEvent, + PaymentFailEvent, +} from "@/types/components/tracking" function isPaymentFailEvent(event: PaymentEvent): event is PaymentFailEvent { return "errorMessage" in event diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 000000000..6a7bd8900 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,16 @@ +{ + "linter": { + "enabled": false, + "rules": { + "recommended": true, + }, + }, + "overrides": [ + { + "include": ["apps/redis-api/**"], + "linter": { + "enabled": true, + }, + }, + ], +} diff --git a/packages/design-system/.prettierignore b/packages/design-system/.prettierignore index 0af3a9b28..7885bd40e 100644 --- a/packages/design-system/.prettierignore +++ b/packages/design-system/.prettierignore @@ -4,6 +4,11 @@ out/ dist/ .vscode/* .husky/* +.gitignore +.npmignore +*.ttf +*.patch +.gitkeep # Files: !.vscode/launch.json diff --git a/yarn.lock b/yarn.lock index 85d45f9de..0842f44cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1649,6 +1649,97 @@ __metadata: languageName: node linkType: hard +"@biomejs/biome@npm:^1.9.4": + version: 1.9.4 + resolution: "@biomejs/biome@npm:1.9.4" + dependencies: + "@biomejs/cli-darwin-arm64": "npm:1.9.4" + "@biomejs/cli-darwin-x64": "npm:1.9.4" + "@biomejs/cli-linux-arm64": "npm:1.9.4" + "@biomejs/cli-linux-arm64-musl": "npm:1.9.4" + "@biomejs/cli-linux-x64": "npm:1.9.4" + "@biomejs/cli-linux-x64-musl": "npm:1.9.4" + "@biomejs/cli-win32-arm64": "npm:1.9.4" + "@biomejs/cli-win32-x64": "npm:1.9.4" + dependenciesMeta: + "@biomejs/cli-darwin-arm64": + optional: true + "@biomejs/cli-darwin-x64": + optional: true + "@biomejs/cli-linux-arm64": + optional: true + "@biomejs/cli-linux-arm64-musl": + optional: true + "@biomejs/cli-linux-x64": + optional: true + "@biomejs/cli-linux-x64-musl": + optional: true + "@biomejs/cli-win32-arm64": + optional: true + "@biomejs/cli-win32-x64": + optional: true + bin: + biome: bin/biome + checksum: 10c0/b5655c5aed9a6fffe24f7d04f15ba4444389d0e891c9ed9106fab7388ac9b4be63185852cc2a937b22940dac3e550b71032a4afd306925cfea436c33e5646b3e + languageName: node + linkType: hard + +"@biomejs/cli-darwin-arm64@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-darwin-arm64@npm:1.9.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-darwin-x64@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-darwin-x64@npm:1.9.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64-musl@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-linux-arm64-musl@npm:1.9.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-linux-arm64@npm:1.9.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64-musl@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-linux-x64-musl@npm:1.9.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-linux-x64@npm:1.9.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-win32-arm64@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-win32-arm64@npm:1.9.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-win32-x64@npm:1.9.4": + version: 1.9.4 + resolution: "@biomejs/cli-win32-x64@npm:1.9.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -1736,6 +1827,29 @@ __metadata: languageName: node linkType: hard +"@elysiajs/server-timing@npm:1.2.1": + version: 1.2.1 + resolution: "@elysiajs/server-timing@npm:1.2.1" + peerDependencies: + elysia: ">= 1.2.0" + checksum: 10c0/ebf1e9991b45bb76b2c83c9709863d31c3ede803c7db5abc08b870cfd736d083f8d36c5e4f6b895b5e4cdfb70c5544a2ae85fd12041f36594e77ddb2e4076514 + languageName: node + linkType: hard + +"@elysiajs/swagger@npm:1.2.2": + version: 1.2.2 + resolution: "@elysiajs/swagger@npm:1.2.2" + dependencies: + "@scalar/themes": "npm:^0.9.52" + "@scalar/types": "npm:^0.0.12" + openapi-types: "npm:^12.1.3" + pathe: "npm:^1.1.2" + peerDependencies: + elysia: ">= 1.2.0" + checksum: 10c0/2ca2341dc36294f11934423ad2c0cb586b08edd41b74cafae7df62c7a22643c313e3f2d763892dc3cbff1afdf51420169c62f3e2317eab18c9c3f6fb711a55d2 + languageName: node + linkType: hard + "@emnapi/runtime@npm:^1.2.0": version: 1.3.1 resolution: "@emnapi/runtime@npm:1.3.1" @@ -2394,6 +2508,13 @@ __metadata: languageName: node linkType: hard +"@ioredis/commands@npm:^1.1.1": + version: 1.2.0 + resolution: "@ioredis/commands@npm:1.2.0" + checksum: 10c0/a5d3c29dd84d8a28b7c67a441ac1715cbd7337a7b88649c0f17c345d89aa218578d2b360760017c48149ef8a70f44b051af9ac0921a0622c2b479614c4f65b36 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -2857,10 +2978,10 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:15.1.7": - version: 15.1.7 - resolution: "@next/env@npm:15.1.7" - checksum: 10c0/ad7761078552d8c88fe3c87224a3761d1bca82a15c747f417f561f92a4521898f227e3e7d2e8e65227a5ac8364ea8a2351c1febec5b5aa2ac1dcf016dd065edd +"@next/env@npm:15.2.1": + version: 15.2.1 + resolution: "@next/env@npm:15.2.1" + checksum: 10c0/776f118c18862950cde7f02885a43879879ea15e0c4ae82da5fbc498a304560373a3c07645ad255524c14b93f33590003a4915b15b39d51c8f99e804d823c583 languageName: node linkType: hard @@ -2880,9 +3001,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-darwin-arm64@npm:15.1.7" +"@next/swc-darwin-arm64@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-darwin-arm64@npm:15.2.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -2894,9 +3015,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-x64@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-darwin-x64@npm:15.1.7" +"@next/swc-darwin-x64@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-darwin-x64@npm:15.2.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -2908,9 +3029,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-linux-arm64-gnu@npm:15.1.7" +"@next/swc-linux-arm64-gnu@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-linux-arm64-gnu@npm:15.2.1" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -2922,9 +3043,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-linux-arm64-musl@npm:15.1.7" +"@next/swc-linux-arm64-musl@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-linux-arm64-musl@npm:15.2.1" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -2936,9 +3057,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-linux-x64-gnu@npm:15.1.7" +"@next/swc-linux-x64-gnu@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-linux-x64-gnu@npm:15.2.1" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -2950,9 +3071,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-linux-x64-musl@npm:15.1.7" +"@next/swc-linux-x64-musl@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-linux-x64-musl@npm:15.2.1" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -2964,9 +3085,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-win32-arm64-msvc@npm:15.1.7" +"@next/swc-win32-arm64-msvc@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-win32-arm64-msvc@npm:15.2.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -2985,9 +3106,9 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:15.1.7": - version: 15.1.7 - resolution: "@next/swc-win32-x64-msvc@npm:15.1.7" +"@next/swc-win32-x64-msvc@npm:15.2.1": + version: 15.2.1 + resolution: "@next/swc-win32-x64-msvc@npm:15.2.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -5999,6 +6120,50 @@ __metadata: languageName: node linkType: hard +"@scalar/openapi-types@npm:0.1.1": + version: 0.1.1 + resolution: "@scalar/openapi-types@npm:0.1.1" + checksum: 10c0/ef7108a7621b936694a153875ab85bf720fbf8369d4125778653dfb88a41da3308cdf55167da39052c382035766eae95c89fc2ab50f7346b87309f6fb65bd5bf + languageName: node + linkType: hard + +"@scalar/openapi-types@npm:0.1.9": + version: 0.1.9 + resolution: "@scalar/openapi-types@npm:0.1.9" + checksum: 10c0/8c815fadd4a52b496b9851484c537417c04c6408fa5c84f72ad1284c49944ffa74f002a3caa8ab07b3a2fb6434d3c20331da00ffd3f1aa6fb8511658d0c9ae7d + languageName: node + linkType: hard + +"@scalar/themes@npm:^0.9.52": + version: 0.9.75 + resolution: "@scalar/themes@npm:0.9.75" + dependencies: + "@scalar/types": "npm:0.0.40" + checksum: 10c0/1079e53abc6bef6c3b97d2e2104601b05a4b9b75443fb10fb6a0d1c076bcb197c893e47127ff3c86a31b46458fb5539d3e1907111caa944a25c3f23efcb8b7fc + languageName: node + linkType: hard + +"@scalar/types@npm:0.0.40": + version: 0.0.40 + resolution: "@scalar/types@npm:0.0.40" + dependencies: + "@scalar/openapi-types": "npm:0.1.9" + "@unhead/schema": "npm:^1.11.11" + zod: "npm:^3.23.8" + checksum: 10c0/723ca05d275972e613d68a795bec7e84db4cc98373fe075d8e2a136ccfbc123e2bc739ff6acb53f33c463d319f1db5c80e5f528e0f7959866169562e9d5b86e0 + languageName: node + linkType: hard + +"@scalar/types@npm:^0.0.12": + version: 0.0.12 + resolution: "@scalar/types@npm:0.0.12" + dependencies: + "@scalar/openapi-types": "npm:0.1.1" + "@unhead/schema": "npm:^1.9.5" + checksum: 10c0/5776818cc732b4784b2406c1e81ce9916de6149c0f3452907372a6fd97fdcc2e7982acf7aee2832e1a7e08c47d3fff3e965009d612194870929747a0c21b4b5c + languageName: node + linkType: hard + "@scandic-hotels/design-system@workspace:*, @scandic-hotels/design-system@workspace:packages/design-system": version: 0.0.0-use.local resolution: "@scandic-hotels/design-system@workspace:packages/design-system" @@ -6126,6 +6291,7 @@ __metadata: ics: "npm:^3.8.1" immer: "npm:10.1.1" input-otp: "npm:^1.4.2" + ioredis: "npm:^5.5.0" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" jiti: "npm:^1.21.0" @@ -6481,6 +6647,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.34.27": + version: 0.34.29 + resolution: "@sinclair/typebox@npm:0.34.29" + checksum: 10c0/90f41e78e1963abd91eaa4c8be792401b1482790650c7440752312bbf0f199196660defd8e30e2d10854289851baafe47799d0ce72aa43c273cd39a3e6f61204 + languageName: node + linkType: hard + "@sindresorhus/is@npm:^2.0.0": version: 2.1.1 resolution: "@sindresorhus/is@npm:2.1.1" @@ -7100,6 +7273,24 @@ __metadata: languageName: node linkType: hard +"@t3-oss/env-core@npm:0.12.0": + version: 0.12.0 + resolution: "@t3-oss/env-core@npm:0.12.0" + peerDependencies: + typescript: ">=5.0.0" + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 + peerDependenciesMeta: + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + checksum: 10c0/b79d2c291b8996b008ef77fa9b3afb8fd7c4c59e271b65039a52a504a9cdb7492ec88b414e6b5691dc4c2c93077abfac85a5cea00dcfdd083ad94912efc5464b + languageName: node + linkType: hard + "@t3-oss/env-core@npm:0.9.2": version: 0.9.2 resolution: "@t3-oss/env-core@npm:0.9.2" @@ -7661,6 +7852,15 @@ __metadata: languageName: node linkType: hard +"@types/bun@npm:latest": + version: 1.2.5 + resolution: "@types/bun@npm:1.2.5" + dependencies: + bun-types: "npm:1.2.5" + checksum: 10c0/228fbaee32c91353696740361e7ab4b3650906d85e10d3d8ea0c8b2669e529b756e67f444609ca98ee400a5774c3cedfa611ca2b51d7d8db37f5c1db42d654cd + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -8029,6 +8229,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:~8.5.10": + version: 8.5.14 + resolution: "@types/ws@npm:8.5.14" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/be88a0b6252f939cb83340bd1b4d450287f752c19271195cd97564fd94047259a9bb8c31c585a61b69d8a1b069a99df9dd804db0132d3359c54d3890c501416a + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -8285,6 +8494,16 @@ __metadata: languageName: node linkType: hard +"@unhead/schema@npm:^1.11.11, @unhead/schema@npm:^1.9.5": + version: 1.11.20 + resolution: "@unhead/schema@npm:1.11.20" + dependencies: + hookable: "npm:^5.5.3" + zhead: "npm:^2.2.4" + checksum: 10c0/f2f968639bbd18f90ddfb83b77c9256bc4c0379ab75efa24dc759f3f597aae707d4dde97df690823f8902eab31d73a5faa8bdd8daf18c6ac8e4503a78b42be74 + languageName: node + linkType: hard + "@vercel/otel@npm:^1.9.1": version: 1.10.1 resolution: "@vercel/otel@npm:1.10.1" @@ -9126,6 +9345,13 @@ __metadata: languageName: node linkType: hard +"atomic-sleep@npm:^1.0.0": + version: 1.0.0 + resolution: "atomic-sleep@npm:1.0.0" + checksum: 10c0/e329a6665512736a9bbb073e1761b4ec102f7926cce35037753146a9db9c8104f5044c1662e4a863576ce544fb8be27cd2be6bc8c1a40147d03f31eb1cfb6e8a + languageName: node + linkType: hard + "atomically@npm:^2.0.3": version: 2.0.3 resolution: "atomically@npm:2.0.3" @@ -9484,6 +9710,16 @@ __metadata: languageName: node linkType: hard +"bun-types@npm:1.2.5": + version: 1.2.5 + resolution: "bun-types@npm:1.2.5" + dependencies: + "@types/node": "npm:*" + "@types/ws": "npm:~8.5.10" + checksum: 10c0/ef3c3d673def40b2461b8eba6b4f7f5bcd7430590ab576d45748279642945bd4298497893ff26efd040def1739e658f67908a7257f6af5368bdd4ca2331b30b9 + languageName: node + linkType: hard + "busboy@npm:1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -9911,6 +10147,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:^1.1.0": + version: 1.1.2 + resolution: "cluster-key-slot@npm:1.1.2" + checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 + languageName: node + linkType: hard + "co-body@npm:^6.0.0": version: 6.2.0 resolution: "co-body@npm:6.2.0" @@ -10006,7 +10249,7 @@ __metadata: languageName: node linkType: hard -"colorette@npm:^2.0.16, colorette@npm:^2.0.20": +"colorette@npm:^2.0.16, colorette@npm:^2.0.20, colorette@npm:^2.0.7": version: 2.0.20 resolution: "colorette@npm:2.0.20" checksum: 10c0/e94116ff33b0ff56f3b83b9ace895e5bf87c2a7a47b3401b8c3f3226e050d5ef76cf4072fb3325f9dc24d1698f9b730baf4e05eeaf861d74a1883073f4c98a40 @@ -10199,6 +10442,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^1.0.2": + version: 1.0.2 + resolution: "cookie@npm:1.0.2" + checksum: 10c0/fd25fe79e8fbcfcaf6aa61cd081c55d144eeeba755206c058682257cb38c4bd6795c6620de3f064c740695bb65b7949ebb1db7a95e4636efb8357a335ad3f54b + languageName: node + linkType: hard + "cookies@npm:~0.9.0": version: 0.9.1 resolution: "cookies@npm:0.9.1" @@ -10565,6 +10815,13 @@ __metadata: languageName: node linkType: hard +"dateformat@npm:^4.6.3": + version: 4.6.3 + resolution: "dateformat@npm:4.6.3" + checksum: 10c0/e2023b905e8cfe2eb8444fb558562b524807a51cdfe712570f360f873271600b5c94aebffaf11efb285e2c072264a7cf243eadb68f3eba0f8cc85fb86cd25df6 + languageName: node + linkType: hard + "dayjs@npm:^1.10.4, dayjs@npm:^1.11.10": version: 1.11.13 resolution: "dayjs@npm:1.11.13" @@ -10771,6 +11028,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:^2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363 + languageName: node + linkType: hard + "depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -11040,6 +11304,27 @@ __metadata: languageName: node linkType: hard +"elysia@npm:1.2.25": + version: 1.2.25 + resolution: "elysia@npm:1.2.25" + dependencies: + "@sinclair/typebox": "npm:^0.34.27" + cookie: "npm:^1.0.2" + memoirist: "npm:^0.3.0" + openapi-types: "npm:^12.1.3" + peerDependencies: + "@sinclair/typebox": ">= 0.34.0" + openapi-types: ">= 12.0.0" + typescript: ">= 5.0.0" + peerDependenciesMeta: + openapi-types: + optional: true + typescript: + optional: true + checksum: 10c0/7fa89dd468417a393f032ab53aee01c7a74c719b415878df8034721bb7f18c475212c69ca717554b15c9e6a39adefd4bc2ca5e3304599b05ffb29483b7e292de + languageName: node + linkType: hard + "embla-carousel-react@npm:^8.5.2": version: 8.5.2 resolution: "embla-carousel-react@npm:8.5.2" @@ -12017,6 +12302,13 @@ __metadata: languageName: node linkType: hard +"fast-copy@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-copy@npm:3.0.2" + checksum: 10c0/02e8b9fd03c8c024d2987760ce126456a0e17470850b51e11a1c3254eed6832e4733ded2d93316c82bc0b36aeb991ad1ff48d1ba95effe7add7c3ab8d8eb554a + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -12051,6 +12343,20 @@ __metadata: languageName: node linkType: hard +"fast-redact@npm:^3.1.1": + version: 3.5.0 + resolution: "fast-redact@npm:3.5.0" + checksum: 10c0/7e2ce4aad6e7535e0775bf12bd3e4f2e53d8051d8b630e0fa9e67f68cb0b0e6070d2f7a94b1d0522ef07e32f7c7cda5755e2b677a6538f1e9070ca053c42343a + languageName: node + linkType: hard + +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: 10c0/d90ec1c963394919828872f21edaa3ad6f1dddd288d2bd4e977027afff09f5db40f94e39536d4646f7e01761d704d72d51dce5af1b93717f3489ef808f5f4e4d + languageName: node + linkType: hard + "fast-uri@npm:^3.0.1": version: 3.0.6 resolution: "fast-uri@npm:3.0.6" @@ -12856,6 +13162,13 @@ __metadata: languageName: node linkType: hard +"help-me@npm:^5.0.0": + version: 5.0.0 + resolution: "help-me@npm:5.0.0" + checksum: 10c0/054c0e2e9ae2231c85ab5e04f75109b9d068ffcc54e58fb22079822a5ace8ff3d02c66fd45379c902ad5ab825e5d2e1451fcc2f7eab1eb49e7d488133ba4cacb + languageName: node + linkType: hard + "hoist-non-react-statics@npm:3, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" @@ -12865,6 +13178,13 @@ __metadata: languageName: node linkType: hard +"hookable@npm:^5.5.3": + version: 5.5.3 + resolution: "hookable@npm:5.5.3" + checksum: 10c0/275f4cc84d27f8d48c5a5cd5685b6c0fea9291be9deea5bff0cfa72856ed566abde1dcd8cb1da0f9a70b4da3d7ec0d60dc3554c4edbba647058cc38816eced3d + languageName: node + linkType: hard + "html-dom-parser@npm:5.0.13": version: 5.0.13 resolution: "html-dom-parser@npm:5.0.13" @@ -13310,6 +13630,40 @@ __metadata: languageName: node linkType: hard +"ioredis@npm:5.6.0": + version: 5.6.0 + resolution: "ioredis@npm:5.6.0" + dependencies: + "@ioredis/commands": "npm:^1.1.1" + cluster-key-slot: "npm:^1.1.0" + debug: "npm:^4.3.4" + denque: "npm:^2.1.0" + lodash.defaults: "npm:^4.2.0" + lodash.isarguments: "npm:^3.1.0" + redis-errors: "npm:^1.2.0" + redis-parser: "npm:^3.0.0" + standard-as-callback: "npm:^2.1.0" + checksum: 10c0/a885e5146640fc448706871290ef424ffa39af561f7ee3cf1590085209a509f85e99082bdaaf3cd32fa66758aea3fc2055d1109648ddca96fac4944bf2092c30 + languageName: node + linkType: hard + +"ioredis@npm:^5.5.0": + version: 5.5.0 + resolution: "ioredis@npm:5.5.0" + dependencies: + "@ioredis/commands": "npm:^1.1.1" + cluster-key-slot: "npm:^1.1.0" + debug: "npm:^4.3.4" + denque: "npm:^2.1.0" + lodash.defaults: "npm:^4.2.0" + lodash.isarguments: "npm:^3.1.0" + redis-errors: "npm:^1.2.0" + redis-parser: "npm:^3.0.0" + standard-as-callback: "npm:^2.1.0" + checksum: 10c0/ba64502fc92d9e05465793fafcd0568cb668af6e2350462b61daadfd499e3a48239d9a723d3ce08b08c93f3f745d05dda91136cdc597d4d485604e6730305305 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -14420,6 +14774,13 @@ __metadata: languageName: node linkType: hard +"joycon@npm:^3.1.1": + version: 3.1.1 + resolution: "joycon@npm:3.1.1" + checksum: 10c0/131fb1e98c9065d067fd49b6e685487ac4ad4d254191d7aa2c9e3b90f4e9ca70430c43cad001602bdbdabcf58717d3b5c5b7461c1bd8e39478c8de706b3fe6ae + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -15131,6 +15492,20 @@ __metadata: languageName: node linkType: hard +"lodash.defaults@npm:^4.2.0": + version: 4.2.0 + resolution: "lodash.defaults@npm:4.2.0" + checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707 + languageName: node + linkType: hard + +"lodash.isarguments@npm:^3.1.0": + version: 3.1.0 + resolution: "lodash.isarguments@npm:3.1.0" + checksum: 10c0/5e8f95ba10975900a3920fb039a3f89a5a79359a1b5565e4e5b4310ed6ebe64011e31d402e34f577eca983a1fc01ff86c926e3cbe602e1ddfc858fdd353e62d8 + languageName: node + linkType: hard + "lodash.isempty@npm:^4.4.0": version: 4.4.0 resolution: "lodash.isempty@npm:4.4.0" @@ -15580,6 +15955,13 @@ __metadata: languageName: node linkType: hard +"memoirist@npm:^0.3.0": + version: 0.3.0 + resolution: "memoirist@npm:0.3.0" + checksum: 10c0/a8f3aff57b51eb2bfcdee2fb4f3c565d04dbcfcad5d7d5d9c22ca63c76fdd3f7b3f5c3679b738b417ff1f120756bd5622bb7cf5685b954b16eac90a52dff58ed + languageName: node + linkType: hard + "memoizerific@npm:^1.11.3": version: 1.11.3 resolution: "memoizerific@npm:1.11.3" @@ -16037,18 +16419,18 @@ __metadata: linkType: hard "next@npm:*": - version: 15.1.7 - resolution: "next@npm:15.1.7" + version: 15.2.1 + resolution: "next@npm:15.2.1" dependencies: - "@next/env": "npm:15.1.7" - "@next/swc-darwin-arm64": "npm:15.1.7" - "@next/swc-darwin-x64": "npm:15.1.7" - "@next/swc-linux-arm64-gnu": "npm:15.1.7" - "@next/swc-linux-arm64-musl": "npm:15.1.7" - "@next/swc-linux-x64-gnu": "npm:15.1.7" - "@next/swc-linux-x64-musl": "npm:15.1.7" - "@next/swc-win32-arm64-msvc": "npm:15.1.7" - "@next/swc-win32-x64-msvc": "npm:15.1.7" + "@next/env": "npm:15.2.1" + "@next/swc-darwin-arm64": "npm:15.2.1" + "@next/swc-darwin-x64": "npm:15.2.1" + "@next/swc-linux-arm64-gnu": "npm:15.2.1" + "@next/swc-linux-arm64-musl": "npm:15.2.1" + "@next/swc-linux-x64-gnu": "npm:15.2.1" + "@next/swc-linux-x64-musl": "npm:15.2.1" + "@next/swc-win32-arm64-msvc": "npm:15.2.1" + "@next/swc-win32-x64-msvc": "npm:15.2.1" "@swc/counter": "npm:0.1.3" "@swc/helpers": "npm:0.5.15" busboy: "npm:1.6.0" @@ -16093,7 +16475,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10c0/9d0f26c3742fb4339b931124607f267558357f2a9cd1cde4ea7d5755cea56a2f751b5898e1babd686ae97ee1f6043c94177f1dcc9c69db50b61d27e441970dfe + checksum: 10c0/9e46b78b3ed15e65f2dd0b4c697ae5ed811825fda8694df97b14bdeb39663906fb7ddf13be3ed74911a2098bf9a4648d88abb7d2c44b25a6a5bf0bd6c39a395d languageName: node linkType: hard @@ -16399,6 +16781,13 @@ __metadata: languageName: node linkType: hard +"on-exit-leak-free@npm:^2.1.0": + version: 2.1.2 + resolution: "on-exit-leak-free@npm:2.1.2" + checksum: 10c0/faea2e1c9d696ecee919026c32be8d6a633a7ac1240b3b87e944a380e8a11dc9c95c4a1f8fb0568de7ab8db3823e790f12bda45296b1d111e341aad3922a0570 + languageName: node + linkType: hard + "on-finished@npm:^2.3.0": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -16488,6 +16877,13 @@ __metadata: languageName: node linkType: hard +"openapi-types@npm:^12.1.3": + version: 12.1.3 + resolution: "openapi-types@npm:12.1.3" + checksum: 10c0/4ad4eb91ea834c237edfa6ab31394e87e00c888fc2918009763389c00d02342345195d6f302d61c3fd807f17723cd48df29b47b538b68375b3827b3758cd520f + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -16782,6 +17178,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.2": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 + languageName: node + linkType: hard + "pathe@npm:^2.0.1, pathe@npm:^2.0.3": version: 2.0.3 resolution: "pathe@npm:2.0.3" @@ -16928,6 +17331,66 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^2.0.0": + version: 2.0.0 + resolution: "pino-abstract-transport@npm:2.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10c0/02c05b8f2ffce0d7c774c8e588f61e8b77de8ccb5f8125afd4a7325c9ea0e6af7fb78168999657712ae843e4462bb70ac550dfd6284f930ee57f17f486f25a9f + languageName: node + linkType: hard + +"pino-pretty@npm:^13.0.0": + version: 13.0.0 + resolution: "pino-pretty@npm:13.0.0" + dependencies: + colorette: "npm:^2.0.7" + dateformat: "npm:^4.6.3" + fast-copy: "npm:^3.0.2" + fast-safe-stringify: "npm:^2.1.1" + help-me: "npm:^5.0.0" + joycon: "npm:^3.1.1" + minimist: "npm:^1.2.6" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^2.0.0" + pump: "npm:^3.0.0" + secure-json-parse: "npm:^2.4.0" + sonic-boom: "npm:^4.0.1" + strip-json-comments: "npm:^3.1.1" + bin: + pino-pretty: bin.js + checksum: 10c0/015dac25006c1b9820b9e01fccb8a392a019e12b30e6bfc3f3f61ecca8dbabcd000a8f3f64410b620b7f5d08579ba85e6ef137f7fbeaad70d46397a97a5f75ea + languageName: node + linkType: hard + +"pino-std-serializers@npm:^7.0.0": + version: 7.0.0 + resolution: "pino-std-serializers@npm:7.0.0" + checksum: 10c0/73e694d542e8de94445a03a98396cf383306de41fd75ecc07085d57ed7a57896198508a0dec6eefad8d701044af21eb27253ccc352586a03cf0d4a0bd25b4133 + languageName: node + linkType: hard + +"pino@npm:9.6.0": + version: 9.6.0 + resolution: "pino@npm:9.6.0" + dependencies: + atomic-sleep: "npm:^1.0.0" + fast-redact: "npm:^3.1.1" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^2.0.0" + pino-std-serializers: "npm:^7.0.0" + process-warning: "npm:^4.0.0" + quick-format-unescaped: "npm:^4.0.3" + real-require: "npm:^0.2.0" + safe-stable-stringify: "npm:^2.3.1" + sonic-boom: "npm:^4.0.1" + thread-stream: "npm:^3.0.0" + bin: + pino: bin.js + checksum: 10c0/bcd1e9d9b301bea13b95689ca9ad7105ae9451928fb6c0b67b3e58c5fe37cea1d40665f3d6641e3da00be0bbc17b89031e67abbc8ea6aac6164f399309fd78e7 + languageName: node + linkType: hard + "pirates@npm:^4.0.4": version: 4.0.6 resolution: "pirates@npm:4.0.6" @@ -17219,6 +17682,13 @@ __metadata: languageName: node linkType: hard +"process-warning@npm:^4.0.0": + version: 4.0.1 + resolution: "process-warning@npm:4.0.1" + checksum: 10c0/577a268b9fd5c3d9f6dbb4348220099391d830905642845d591e7ee8b1e45043d98b7b9826a3c1379bdd1686cdfe0f6cf349cb812affc5853b662e6a9896579e + languageName: node + linkType: hard + "process@npm:^0.11.10": version: 0.11.10 resolution: "process@npm:0.11.10" @@ -17419,6 +17889,13 @@ __metadata: languageName: node linkType: hard +"quick-format-unescaped@npm:^4.0.3": + version: 4.0.4 + resolution: "quick-format-unescaped@npm:4.0.4" + checksum: 10c0/fe5acc6f775b172ca5b4373df26f7e4fd347975578199e7d74b2ae4077f0af05baa27d231de1e80e8f72d88275ccc6028568a7a8c9ee5e7368ace0e18eff93a4 + languageName: node + linkType: hard + "ramda@npm:0.27.1": version: 0.27.1 resolution: "ramda@npm:0.27.1" @@ -17858,6 +18335,13 @@ __metadata: languageName: node linkType: hard +"real-require@npm:^0.2.0": + version: 0.2.0 + resolution: "real-require@npm:0.2.0" + checksum: 10c0/23eea5623642f0477412ef8b91acd3969015a1501ed34992ada0e3af521d3c865bb2fe4cdbfec5fe4b505f6d1ef6a03e5c3652520837a8c3b53decff7e74b6a0 + languageName: node + linkType: hard + "recast@npm:^0.23.5": version: 0.23.9 resolution: "recast@npm:0.23.9" @@ -17881,6 +18365,39 @@ __metadata: languageName: node linkType: hard +"redis-api@workspace:apps/redis-api": + version: 0.0.0-use.local + resolution: "redis-api@workspace:apps/redis-api" + dependencies: + "@biomejs/biome": "npm:^1.9.4" + "@elysiajs/server-timing": "npm:1.2.1" + "@elysiajs/swagger": "npm:1.2.2" + "@t3-oss/env-core": "npm:0.12.0" + "@types/bun": "npm:latest" + elysia: "npm:1.2.25" + ioredis: "npm:5.6.0" + pino: "npm:9.6.0" + pino-pretty: "npm:^13.0.0" + typescript: "npm:^5.7.2" + languageName: unknown + linkType: soft + +"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": + version: 1.2.0 + resolution: "redis-errors@npm:1.2.0" + checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 + languageName: node + linkType: hard + +"redis-parser@npm:^3.0.0": + version: 3.0.0 + resolution: "redis-parser@npm:3.0.0" + dependencies: + redis-errors: "npm:^1.0.0" + checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f + languageName: node + linkType: hard + "reduce-flatten@npm:^2.0.0": version: 2.0.0 resolution: "reduce-flatten@npm:2.0.0" @@ -18429,6 +18946,13 @@ __metadata: languageName: node linkType: hard +"safe-stable-stringify@npm:^2.3.1": + version: 2.5.0 + resolution: "safe-stable-stringify@npm:2.5.0" + checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.0.2, safer-buffer@npm:^2.1.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -18514,6 +19038,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:^2.4.0": + version: 2.7.0 + resolution: "secure-json-parse@npm:2.7.0" + checksum: 10c0/f57eb6a44a38a3eeaf3548228585d769d788f59007454214fab9ed7f01fbf2e0f1929111da6db28cf0bcc1a2e89db5219a59e83eeaec3a54e413a0197ce879e4 + languageName: node + linkType: hard + "secure-json-parse@npm:^4.0.0": version: 4.0.0 resolution: "secure-json-parse@npm:4.0.0" @@ -18910,6 +19441,15 @@ __metadata: languageName: node linkType: hard +"sonic-boom@npm:^4.0.1": + version: 4.2.0 + resolution: "sonic-boom@npm:4.2.0" + dependencies: + atomic-sleep: "npm:^1.0.0" + checksum: 10c0/ae897e6c2cd6d3cb7cdcf608bc182393b19c61c9413a85ce33ffd25891485589f39bece0db1de24381d0a38fc03d08c9862ded0c60f184f1b852f51f97af9684 + languageName: node + linkType: hard + "sonner@npm:^1.7.0": version: 1.7.4 resolution: "sonner@npm:1.7.4" @@ -18976,6 +19516,13 @@ __metadata: languageName: node linkType: hard +"split2@npm:^4.0.0": + version: 4.2.0 + resolution: "split2@npm:4.2.0" + checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534 + languageName: node + linkType: hard + "split@npm:0.3": version: 0.3.3 resolution: "split@npm:0.3.3" @@ -19061,6 +19608,13 @@ __metadata: languageName: node linkType: hard +"standard-as-callback@npm:^2.1.0": + version: 2.1.0 + resolution: "standard-as-callback@npm:2.1.0" + checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f + languageName: node + linkType: hard + "start-server-and-test@npm:^2.0.3": version: 2.0.10 resolution: "start-server-and-test@npm:2.0.10" @@ -19659,6 +20213,15 @@ __metadata: languageName: node linkType: hard +"thread-stream@npm:^3.0.0": + version: 3.1.0 + resolution: "thread-stream@npm:3.1.0" + dependencies: + real-require: "npm:^0.2.0" + checksum: 10c0/c36118379940b77a6ef3e6f4d5dd31e97b8210c3f7b9a54eb8fe6358ab173f6d0acfaf69b9c3db024b948c0c5fd2a7df93e2e49151af02076b35ada3205ec9a6 + languageName: node + linkType: hard + "throttleit@npm:^1.0.0": version: 1.0.1 resolution: "throttleit@npm:1.0.1" @@ -20206,7 +20769,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.7.3": +"typescript@npm:^5.7.2, typescript@npm:^5.7.3": version: 5.8.2 resolution: "typescript@npm:5.8.2" bin: @@ -20236,7 +20799,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": +"typescript@patch:typescript@npm%3A^5.7.2#optional!builtin, typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": version: 5.8.2 resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=5786d5" bin: @@ -21237,7 +21800,14 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4": +"zhead@npm:^2.2.4": + version: 2.2.4 + resolution: "zhead@npm:2.2.4" + checksum: 10c0/3d166fb661f1b7fdf8a0ef2222d9e574ab241e72141f2f1fda62a9250ca73aabf2eaf0d66046a3984cd24d1dd9bac231338c6271684d6b8caa6b66af7c45f275 + languageName: node + linkType: hard + +"zod@npm:^3.22.4, zod@npm:^3.23.8": version: 3.24.2 resolution: "zod@npm:3.24.2" checksum: 10c0/c638c7220150847f13ad90635b3e7d0321b36cce36f3fc6050ed960689594c949c326dfe2c6fa87c14b126ee5d370ccdebd6efb304f41ef5557a4aaca2824565