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