Distributed cache * cache deleteKey now uses an options object instead of a lonely argument variable fuzzy * merge * remove debug logs and cleanup * cleanup * add fault handling * add fault handling * add pid when logging redis client creation * add identifier when logging redis client creation * cleanup * feat: add redis-api as it's own app * feature: use http wrapper for redis * feat: add the possibility to fallback to unstable_cache * Add error handling if redis cache is unresponsive * add logging for unstable_cache * merge * don't cache errors * fix: metadatabase on branchdeploys * Handle when /en/destinations throws add ErrorBoundary * Add sentry-logging when ErrorBoundary catches exception * Fix error handling for distributed cache * cleanup code * Added Application Insights back * Update generateApiKeys script and remove duplicate * Merge branch 'feature/redis' of bitbucket.org:scandic-swap/web into feature/redis * merge Approved-by: Linus Flood
542 lines
16 KiB
TypeScript
542 lines
16 KiB
TypeScript
import { env } from "@/env/server"
|
|
import * as api from "@/lib/api"
|
|
import { dt } from "@/lib/dt"
|
|
import { notFound } from "@/server/errors/trpc"
|
|
import {
|
|
contentStackBaseWithProtectedProcedure,
|
|
contentStackBaseWithServiceProcedure,
|
|
protectedProcedure,
|
|
router,
|
|
} from "@/server/trpc"
|
|
import { langInput } from "@/server/utils"
|
|
|
|
import { getCacheClient } from "@/services/dataCache"
|
|
|
|
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
|
|
import {
|
|
rewardsAllInput,
|
|
rewardsByLevelInput,
|
|
rewardsRedeemInput,
|
|
rewardsUpdateInput,
|
|
} from "./input"
|
|
import {
|
|
type Reward,
|
|
type Surprise,
|
|
validateApiRewardSchema,
|
|
validateCategorizedRewardsSchema,
|
|
} from "./output"
|
|
import {
|
|
getAllCachedApiRewards,
|
|
getAllRewardCounter,
|
|
getAllRewardFailCounter,
|
|
getAllRewardSuccessCounter,
|
|
getByLevelRewardCounter,
|
|
getByLevelRewardFailCounter,
|
|
getByLevelRewardSuccessCounter,
|
|
getCachedAllTierRewards,
|
|
getCmsRewards,
|
|
getCurrentRewardCounter,
|
|
getCurrentRewardFailCounter,
|
|
getCurrentRewardSuccessCounter,
|
|
getNonRedeemedRewardIds,
|
|
getRedeemCounter,
|
|
getRedeemFailCounter,
|
|
getRedeemSuccessCounter,
|
|
getUniqueRewardIds,
|
|
getUnwrapSurpriseCounter,
|
|
getUnwrapSurpriseFailCounter,
|
|
getUnwrapSurpriseSuccessCounter,
|
|
} from "./utils"
|
|
|
|
export const rewardQueryRouter = router({
|
|
all: contentStackBaseWithServiceProcedure
|
|
.input(rewardsAllInput)
|
|
.query(async function ({ input, ctx }) {
|
|
getAllRewardCounter.add(1)
|
|
|
|
const allApiRewards = env.USE_NEW_REWARDS_ENDPOINT
|
|
? await getCachedAllTierRewards(ctx.serviceToken)
|
|
: await getAllCachedApiRewards(ctx.serviceToken)
|
|
|
|
if (!allApiRewards) {
|
|
return []
|
|
}
|
|
|
|
const rewardIds = Object.values(allApiRewards)
|
|
.flatMap((level) => level.map((reward) => reward?.rewardId))
|
|
.filter((id): id is string => Boolean(id))
|
|
|
|
const contentStackRewards = await getCmsRewards(
|
|
ctx.lang,
|
|
getUniqueRewardIds(rewardIds)
|
|
)
|
|
|
|
if (!contentStackRewards) {
|
|
return []
|
|
}
|
|
|
|
const loyaltyLevelsConfig = await getAllLoyaltyLevels(ctx)
|
|
const levelsWithRewards = Object.entries(allApiRewards).map(
|
|
([level, rewards]) => {
|
|
const combinedRewards = rewards
|
|
.filter((r) => (input.unique ? r?.rewardTierLevel === level : true))
|
|
.map((reward) => {
|
|
const contentStackReward = contentStackRewards.find((r) => {
|
|
return r.reward_id === reward?.rewardId
|
|
})
|
|
|
|
if (contentStackReward) {
|
|
return contentStackReward
|
|
} else {
|
|
console.error("No contentStackReward found", reward?.rewardId)
|
|
}
|
|
})
|
|
.filter((reward): reward is Reward => Boolean(reward))
|
|
|
|
const levelConfig = loyaltyLevelsConfig.find(
|
|
(l) => l.level_id === level
|
|
)
|
|
|
|
if (!levelConfig) {
|
|
getAllRewardFailCounter.add(1)
|
|
|
|
console.error("contentstack.loyaltyLevels level not found")
|
|
throw notFound()
|
|
}
|
|
return { ...levelConfig, rewards: combinedRewards }
|
|
}
|
|
)
|
|
|
|
getAllRewardSuccessCounter.add(1)
|
|
return levelsWithRewards
|
|
}),
|
|
byLevel: contentStackBaseWithServiceProcedure
|
|
.input(rewardsByLevelInput)
|
|
.query(async function ({ input, ctx }) {
|
|
getByLevelRewardCounter.add(1)
|
|
const { level_id } = input
|
|
|
|
const allUpcomingApiRewards = env.USE_NEW_REWARDS_ENDPOINT
|
|
? await getCachedAllTierRewards(ctx.serviceToken)
|
|
: await getAllCachedApiRewards(ctx.serviceToken)
|
|
|
|
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
|
|
getByLevelRewardFailCounter.add(1)
|
|
|
|
return null
|
|
}
|
|
|
|
let apiRewards = allUpcomingApiRewards[level_id]!
|
|
|
|
if (input.unique) {
|
|
apiRewards = allUpcomingApiRewards[level_id]!.filter(
|
|
(reward) => reward?.rewardTierLevel === level_id
|
|
)
|
|
}
|
|
|
|
const rewardIds = apiRewards
|
|
.map((reward) => reward?.rewardId)
|
|
.filter((id): id is string => Boolean(id))
|
|
|
|
const [contentStackRewards, loyaltyLevelsConfig] = await Promise.all([
|
|
getCmsRewards(ctx.lang, rewardIds),
|
|
getLoyaltyLevel(ctx, input.level_id),
|
|
])
|
|
|
|
if (!contentStackRewards) {
|
|
return null
|
|
}
|
|
|
|
const levelsWithRewards = apiRewards
|
|
.map((reward) => {
|
|
const contentStackReward = contentStackRewards.find((r) => {
|
|
return r.reward_id === reward?.rewardId
|
|
})
|
|
|
|
if (contentStackReward) {
|
|
return contentStackReward
|
|
} else {
|
|
console.info("No contentStackReward found", reward?.rewardId)
|
|
}
|
|
})
|
|
.filter((reward): reward is Reward => Boolean(reward))
|
|
|
|
getByLevelRewardSuccessCounter.add(1)
|
|
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
|
|
}),
|
|
current: contentStackBaseWithProtectedProcedure
|
|
.input(langInput.optional()) // lang is required for client, but not for server
|
|
.query(async function ({ ctx }) {
|
|
getCurrentRewardCounter.add(1)
|
|
|
|
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
|
|
const endpoint = isNewEndpoint
|
|
? api.endpoints.v1.Profile.Reward.reward
|
|
: api.endpoints.v1.Profile.reward
|
|
|
|
const cacheClient = await getCacheClient()
|
|
|
|
return cacheClient.cacheOrGet(
|
|
endpoint,
|
|
async () => {
|
|
const apiResponse = await api.get(endpoint, {
|
|
headers: {
|
|
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
|
},
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
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 || []
|
|
: [],
|
|
}
|
|
})
|
|
|
|
getCurrentRewardSuccessCounter.add(1)
|
|
|
|
return { rewards }
|
|
},
|
|
"1h"
|
|
)
|
|
}),
|
|
surprises: contentStackBaseWithProtectedProcedure
|
|
.input(langInput.optional()) // lang is required for client, but not for server
|
|
.query(async ({ ctx }) => {
|
|
getCurrentRewardCounter.add(1)
|
|
|
|
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
|
|
const endpoint = isNewEndpoint
|
|
? api.endpoints.v1.Profile.Reward.reward
|
|
: api.endpoints.v1.Profile.reward
|
|
|
|
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}`,
|
|
},
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
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) {
|
|
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)
|
|
.mutation(async ({ input, ctx }) => {
|
|
getUnwrapSurpriseCounter.add(1)
|
|
|
|
const promises = input.map(({ rewardId, couponCode }) => {
|
|
return api.post(api.endpoints.v1.Profile.Reward.unwrap, {
|
|
body: {
|
|
rewardId,
|
|
couponCode,
|
|
},
|
|
headers: {
|
|
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
|
},
|
|
})
|
|
})
|
|
|
|
const responses = await Promise.all(promises)
|
|
|
|
const errors = await Promise.all(
|
|
responses.map(async (apiResponse) => {
|
|
if (!apiResponse.ok) {
|
|
const text = await apiResponse.text()
|
|
|
|
getUnwrapSurpriseFailCounter.add(1, {
|
|
error_type: "http_error",
|
|
error: JSON.stringify({
|
|
status: apiResponse.status,
|
|
statusText: apiResponse.statusText,
|
|
text,
|
|
}),
|
|
})
|
|
|
|
console.error(
|
|
"contentstack.unwrap API error",
|
|
JSON.stringify({
|
|
error: {
|
|
status: apiResponse.status,
|
|
statusText: apiResponse.statusText,
|
|
text,
|
|
},
|
|
query: {},
|
|
})
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
)
|
|
|
|
if (errors.filter((ok) => !ok).length > 0) {
|
|
return null
|
|
}
|
|
|
|
getUnwrapSurpriseSuccessCounter.add(1)
|
|
|
|
return true
|
|
}),
|
|
redeem: protectedProcedure
|
|
.input(rewardsRedeemInput)
|
|
.mutation(async ({ input, ctx }) => {
|
|
getRedeemCounter.add(1)
|
|
|
|
const { rewardId, couponCode } = input
|
|
|
|
const apiResponse = await api.post(
|
|
api.endpoints.v1.Profile.Reward.redeem,
|
|
{
|
|
body: {
|
|
rewardId,
|
|
couponCode,
|
|
},
|
|
headers: {
|
|
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
|
},
|
|
}
|
|
)
|
|
|
|
if (!apiResponse.ok) {
|
|
const text = await apiResponse.text()
|
|
getRedeemFailCounter.add(1, {
|
|
error_type: "http_error",
|
|
error: JSON.stringify({
|
|
status: apiResponse.status,
|
|
statusText: apiResponse.statusText,
|
|
text,
|
|
}),
|
|
})
|
|
console.error(
|
|
"api.redeem error ",
|
|
JSON.stringify({
|
|
error: {
|
|
status: apiResponse.status,
|
|
statusText: apiResponse.statusText,
|
|
text,
|
|
},
|
|
})
|
|
)
|
|
return null
|
|
}
|
|
|
|
getRedeemSuccessCounter.add(1)
|
|
|
|
return true
|
|
}),
|
|
})
|