refactor(LOY-175): rewrite reward types according to new api endpoints
This commit is contained in:
@@ -10,7 +10,24 @@ import {
|
||||
} from "../schemas/pageLinks"
|
||||
import { systemSchema } from "../schemas/system"
|
||||
|
||||
import type { RewardCategory } from "@/types/components/myPages/rewards"
|
||||
export {
|
||||
type ApiReward,
|
||||
type CMSReward,
|
||||
type CMSRewardsResponse,
|
||||
type CMSRewardsWithRedeemResponse,
|
||||
type CMSRewardWithRedeem,
|
||||
type Coupon,
|
||||
type GetRewardWithRedeemRefsSchema,
|
||||
type RedeemableCoupon,
|
||||
type RedeemLocation,
|
||||
rewardWithRedeemRefsSchema,
|
||||
type Surprise,
|
||||
type SurpriseReward,
|
||||
validateApiAllTiersSchema,
|
||||
validateCategorizedRewardsSchema,
|
||||
validateCmsRewardsSchema,
|
||||
validateCmsRewardsWithRedeemSchema,
|
||||
}
|
||||
|
||||
enum TierKey {
|
||||
tier1 = MembershipLevelEnum.L1,
|
||||
@@ -24,7 +41,10 @@ enum TierKey {
|
||||
|
||||
type Key = keyof typeof TierKey
|
||||
|
||||
export const validateCmsRewardsSchema = z
|
||||
/*
|
||||
* TODO: Remove this once we start using the new CMS model with redeem entirely
|
||||
*/
|
||||
const validateCmsRewardsSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
@@ -48,7 +68,10 @@ export const validateCmsRewardsSchema = z
|
||||
})
|
||||
.transform((data) => data.data.all_reward.items)
|
||||
|
||||
export const validateCmsRewardsWithRedeemSchema = z
|
||||
type CMSRewardsResponse = z.input<typeof validateCmsRewardsSchema>
|
||||
type CMSReward = z.output<typeof validateCmsRewardsSchema>[number]
|
||||
|
||||
const validateCmsRewardsWithRedeemSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
@@ -88,13 +111,14 @@ export const validateCmsRewardsWithRedeemSchema = z
|
||||
})
|
||||
.transform((data) => data.data.all_reward.items)
|
||||
|
||||
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
|
||||
|
||||
export type CmsRewardsWithRedeemResponse = z.input<
|
||||
type CMSRewardsWithRedeemResponse = z.input<
|
||||
typeof validateCmsRewardsWithRedeemSchema
|
||||
>
|
||||
type CMSRewardWithRedeem = z.output<
|
||||
typeof validateCmsRewardsWithRedeemSchema
|
||||
>[number]
|
||||
|
||||
export const rewardWithRedeemRefsSchema = z.object({
|
||||
const rewardWithRedeemRefsSchema = z.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
items: z.array(
|
||||
@@ -115,70 +139,36 @@ export const rewardWithRedeemRefsSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export interface GetRewardWithRedeemRefsSchema
|
||||
extends z.input<typeof rewardWithRedeemRefsSchema> {}
|
||||
type GetRewardWithRedeemRefsSchema = z.input<typeof rewardWithRedeemRefsSchema>
|
||||
|
||||
export type CMSReward = z.output<typeof validateCmsRewardsSchema>[0]
|
||||
const REDEEM_LOCATIONS = ["Non-redeemable", "On-site", "Online"] as const
|
||||
type RedeemLocation = (typeof REDEEM_LOCATIONS)[number]
|
||||
|
||||
export type CMSRewardWithRedeem = z.output<
|
||||
typeof validateCmsRewardsWithRedeemSchema
|
||||
>[0]
|
||||
|
||||
export type Reward = CMSReward & {
|
||||
id: string | undefined
|
||||
rewardType: string | undefined
|
||||
redeemLocation: string | undefined
|
||||
rewardTierLevel: string | undefined
|
||||
operaRewardId: string
|
||||
categories: RewardCategory[]
|
||||
couponCode: string | undefined
|
||||
coupons: Coupon[]
|
||||
}
|
||||
|
||||
export type RewardWithRedeem = CMSRewardWithRedeem & {
|
||||
id: string | undefined
|
||||
rewardType: string | undefined
|
||||
redeemLocation: string | undefined
|
||||
rewardTierLevel: string | undefined
|
||||
operaRewardId: string
|
||||
categories: RewardCategory[]
|
||||
couponCode: string | undefined
|
||||
coupons: Coupon[]
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
couponCode?: string
|
||||
expiresAt?: string
|
||||
unwrapped: boolean
|
||||
state: "claimed" | "redeemed" | "viewed"
|
||||
}
|
||||
|
||||
export interface Surprise extends Omit<Reward, "operaRewardId" | "couponCode"> {
|
||||
coupons: Coupon[]
|
||||
}
|
||||
|
||||
// New endpoint related types and schemas.
|
||||
const BaseReward = z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
id: z.string(),
|
||||
rewardId: z.string(),
|
||||
redeemLocation: z.enum(REDEEM_LOCATIONS),
|
||||
status: z.enum(["active", "expired"]),
|
||||
})
|
||||
|
||||
const BenefitReward = BaseReward.merge(
|
||||
z.object({
|
||||
rewardType: z.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility
|
||||
rewardType: z.enum(["Tier"]),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
const CouponData = z.object({
|
||||
couponCode: z.string().optional(),
|
||||
couponCode: z.string(),
|
||||
unwrapped: z.boolean().default(false),
|
||||
state: z.enum(["claimed", "redeemed", "viewed"]),
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
})
|
||||
type Coupon = z.output<typeof CouponData>
|
||||
type RedeemableCoupon = Coupon & {
|
||||
state: Exclude<Coupon["state"], "redeemed">
|
||||
}
|
||||
|
||||
const CouponReward = BaseReward.merge(
|
||||
z.object({
|
||||
@@ -192,38 +182,26 @@ const CouponReward = BaseReward.merge(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Schema for the new /profile/v1/Reward endpoint.
|
||||
*
|
||||
* TODO: Once we fully migrate to the new endpoint:
|
||||
* 1. Remove the data transform and use the categorized structure directly.
|
||||
* 2. Simplify surprise filtering in the query.
|
||||
*/
|
||||
export const validateCategorizedRewardsSchema = z
|
||||
type SurpriseReward = z.output<typeof CouponReward> & {
|
||||
rewardType: "Surprise"
|
||||
}
|
||||
|
||||
interface Surprise extends CMSReward {
|
||||
data: SurpriseReward
|
||||
}
|
||||
|
||||
const validateCategorizedRewardsSchema = z
|
||||
.object({
|
||||
benefits: z.array(BenefitReward),
|
||||
coupons: z.array(CouponReward),
|
||||
})
|
||||
.transform((data) => [
|
||||
...data.benefits.map((benefit) => ({
|
||||
...benefit,
|
||||
type: "custom" as const, // Added for legacy compatibility.
|
||||
})),
|
||||
...data.coupons.map((coupon) => ({
|
||||
...coupon,
|
||||
type: "coupon" as const, // Added for legacy compatibility.
|
||||
})),
|
||||
])
|
||||
.transform((data) => [...data.benefits, ...data.coupons])
|
||||
|
||||
export type CategorizedApiReward = z.output<
|
||||
typeof validateCategorizedRewardsSchema
|
||||
>[number]
|
||||
type ApiReward = z.output<typeof validateCategorizedRewardsSchema>[number]
|
||||
|
||||
export const validateApiAllTiersSchema = z.record(
|
||||
const validateApiAllTiersSchema = z.record(
|
||||
z.nativeEnum(TierKey).transform((data) => {
|
||||
return TierKey[data as unknown as Key]
|
||||
}),
|
||||
z.array(BenefitReward)
|
||||
)
|
||||
|
||||
export type RedeemLocation = "Non-redeemable" | "On-site" | "Online"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as api from "@/lib/api"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import {
|
||||
contentStackBaseWithProtectedProcedure,
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
} from "@/server/trpc"
|
||||
import { langInput } from "@/server/utils"
|
||||
|
||||
import { getReedemableCoupons } from "@/utils/rewards"
|
||||
|
||||
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
|
||||
import {
|
||||
rewardsAllInput,
|
||||
@@ -16,11 +17,7 @@ import {
|
||||
rewardsRedeemInput,
|
||||
rewardsUpdateInput,
|
||||
} from "./input"
|
||||
import {
|
||||
type Reward,
|
||||
type Surprise,
|
||||
validateCategorizedRewardsSchema,
|
||||
} from "./output"
|
||||
import { type Surprise, validateCategorizedRewardsSchema } from "./output"
|
||||
import {
|
||||
getAllRewardCounter,
|
||||
getAllRewardFailCounter,
|
||||
@@ -33,7 +30,6 @@ import {
|
||||
getCurrentRewardCounter,
|
||||
getCurrentRewardFailCounter,
|
||||
getCurrentRewardSuccessCounter,
|
||||
getNonRedeemedRewardIds,
|
||||
getRedeemCounter,
|
||||
getRedeemFailCounter,
|
||||
getRedeemSuccessCounter,
|
||||
@@ -41,8 +37,16 @@ import {
|
||||
getUnwrapSurpriseCounter,
|
||||
getUnwrapSurpriseFailCounter,
|
||||
getUnwrapSurpriseSuccessCounter,
|
||||
isSurpriseReward,
|
||||
} from "./utils"
|
||||
|
||||
import type {
|
||||
Reward,
|
||||
RewardWithRedeem,
|
||||
} from "@/types/components/myPages/rewards"
|
||||
|
||||
const ONE_HOUR = 60 * 60
|
||||
|
||||
export const rewardQueryRouter = router({
|
||||
all: contentStackBaseWithServiceProcedure
|
||||
.input(rewardsAllInput)
|
||||
@@ -215,71 +219,37 @@ export const rewardQueryRouter = router({
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardIds = getNonRedeemedRewardIds(validatedApiRewards.data)
|
||||
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
|
||||
}
|
||||
|
||||
const wrappedSurprisesIds = validatedApiRewards.data
|
||||
const rewards: Array<Reward | RewardWithRedeem> = cmsRewards
|
||||
.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)
|
||||
(cmsReward) =>
|
||||
// filters out any rewards tied to wrapped surprises
|
||||
!validatedApiRewards.data
|
||||
.filter(isSurpriseReward)
|
||||
.filter((reward) =>
|
||||
reward.coupon.some(({ unwrapped }) => !unwrapped)
|
||||
)
|
||||
.map(({ rewardId }) => rewardId)
|
||||
.includes(cmsReward.reward_id)
|
||||
)
|
||||
.map((cmsReward) => {
|
||||
// Non-null assertion is used here because we know our reward exist
|
||||
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 || [] : [],
|
||||
data: apiReward,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -346,12 +316,11 @@ export const rewardQueryRouter = router({
|
||||
}
|
||||
|
||||
const rewardIds = validatedApiRewards.data
|
||||
.map((reward) => reward?.rewardId)
|
||||
.filter((reward) => getReedemableCoupons(reward).length)
|
||||
.map((reward) => reward.rewardId)
|
||||
.filter((rewardId): rewardId is string => !!rewardId)
|
||||
.sort()
|
||||
|
||||
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
|
||||
|
||||
if (!cmsRewards) {
|
||||
return null
|
||||
}
|
||||
@@ -359,42 +328,25 @@ export const rewardQueryRouter = router({
|
||||
getCurrentRewardSuccessCounter.add(1)
|
||||
|
||||
const surprises: Surprise[] = validatedApiRewards.data
|
||||
// TODO: Add predicates once legacy endpoints are removed
|
||||
.filter(isSurpriseReward)
|
||||
.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
|
||||
return unwrappedCoupons.length
|
||||
})
|
||||
.map((surprise) => {
|
||||
const reward = cmsRewards.find(
|
||||
const cmsReward = cmsRewards.find(
|
||||
({ reward_id }) => surprise.rewardId === reward_id
|
||||
)
|
||||
|
||||
if (!reward) {
|
||||
if (!cmsReward) {
|
||||
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 || [] : [],
|
||||
...cmsReward,
|
||||
data: surprise,
|
||||
}
|
||||
})
|
||||
.flatMap((surprises) => (surprises ? [surprises] : []))
|
||||
|
||||
@@ -17,11 +17,12 @@ import {
|
||||
} from "@/utils/generateTag"
|
||||
|
||||
import {
|
||||
type CategorizedApiReward,
|
||||
type CmsRewardsResponse,
|
||||
type CmsRewardsWithRedeemResponse,
|
||||
type ApiReward,
|
||||
type CMSRewardsResponse,
|
||||
type CMSRewardsWithRedeemResponse,
|
||||
type GetRewardWithRedeemRefsSchema,
|
||||
rewardWithRedeemRefsSchema,
|
||||
type SurpriseReward,
|
||||
validateApiAllTiersSchema,
|
||||
validateCmsRewardsSchema,
|
||||
validateCmsRewardsWithRedeemSchema,
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
export { isSurpriseReward }
|
||||
|
||||
const meter = metrics.getMeter("trpc.reward")
|
||||
export const getAllRewardCounter = meter.createCounter(
|
||||
"trpc.contentstack.reward.all"
|
||||
@@ -230,7 +233,7 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
|
||||
})
|
||||
)
|
||||
|
||||
cmsRewardsResponse = await request<CmsRewardsWithRedeemResponse>(
|
||||
cmsRewardsResponse = await request<CMSRewardsWithRedeemResponse>(
|
||||
GetRewardsWithReedem,
|
||||
{
|
||||
locale: lang,
|
||||
@@ -242,7 +245,7 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
cmsRewardsResponse = await request<CmsRewardsResponse>(
|
||||
cmsRewardsResponse = await request<CMSRewardsResponse>(
|
||||
GetRewards,
|
||||
{
|
||||
locale: lang,
|
||||
@@ -297,17 +300,6 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
|
||||
return validatedCmsRewards.data
|
||||
}
|
||||
|
||||
export function getNonRedeemedRewardIds(rewards: Array<CategorizedApiReward>) {
|
||||
return rewards
|
||||
.filter((reward) => {
|
||||
if ("coupon" in reward && reward.coupon.length > 0) {
|
||||
if (reward.coupon.every((coupon) => coupon.state === "redeemed")) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((reward) => reward?.rewardId)
|
||||
.filter((rewardId): rewardId is string => !!rewardId)
|
||||
.sort()
|
||||
function isSurpriseReward(reward: ApiReward): reward is SurpriseReward {
|
||||
return reward.rewardType === "Surprise"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user