feat(SW-353): dynamic rewards

This commit is contained in:
Christel Westerberg
2024-09-25 15:59:16 +02:00
parent 6a85cfd19c
commit 56cd02f90b
78 changed files with 1568 additions and 4587 deletions

View File

@@ -7,9 +7,11 @@ import { breadcrumbsRouter } from "./breadcrumbs"
import { contentPageRouter } from "./contentPage"
import { hotelPageRouter } from "./hotelPage"
import { languageSwitcherRouter } from "./languageSwitcher"
import { loyaltyLevelRouter } from "./loyaltyLevel"
import { loyaltyPageRouter } from "./loyaltyPage"
import { metaDataRouter } from "./metadata"
import { myPagesRouter } from "./myPages"
import { rewardRouter } from "./reward"
export const contentstackRouter = router({
accountPage: accountPageRouter,
@@ -22,4 +24,6 @@ export const contentstackRouter = router({
contentPage: contentPageRouter,
myPages: myPagesRouter,
metaData: metaDataRouter,
rewards: rewardRouter,
loyaltyLevels: loyaltyLevelRouter,
})

View File

@@ -0,0 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { loyaltyLevelQueryRouter } from "./query"
export const loyaltyLevelRouter = mergeRouters(loyaltyLevelQueryRouter)

View File

@@ -0,0 +1,7 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const loyaltyLevelInput = z.object({
level: z.nativeEnum(MembershipLevelEnum),
})

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const validateLoyaltyLevelsSchema = z
.object({
all_loyalty_level: z.object({
items: z.array(
z.object({
level_id: z.nativeEnum(MembershipLevelEnum),
name: z.string(),
user_facing_tag: z.string().optional(),
description: z.string().optional(),
required_nights: z.number().optional().nullable(),
required_points: z.number(),
})
),
}),
})
.transform((data) => data.all_loyalty_level.items)
export type LoyaltyLevelsResponse = z.input<typeof validateLoyaltyLevelsSchema>
export type LoyaltyLevel = z.output<typeof validateLoyaltyLevelsSchema>[0]

View File

@@ -0,0 +1,147 @@
import { metrics } from "@opentelemetry/api"
import {
MembershipLevel,
MembershipLevelEnum,
} from "@/constants/membershipLevels"
import {
GetAllLoyaltyLevels,
GetLoyaltyLevel,
} from "@/lib/graphql/Query/LoyaltyLevels.graphql"
import { request } from "@/lib/graphql/request"
import { Context } from "@/server/context"
import { notFound } from "@/server/errors/trpc"
import { contentstackBaseProcedure, router } from "@/server/trpc"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import { loyaltyLevelInput } from "./input"
import { LoyaltyLevelsResponse, validateLoyaltyLevelsSchema } from "./output"
const meter = metrics.getMeter("trpc.loyaltyLevel")
// OpenTelemetry metrics: Loyalty Level
const getAllLoyaltyLevelCounter = meter.createCounter(
"trpc.contentstack.loyaltyLevel.all"
)
const getAllLoyaltyLevelSuccessCounter = meter.createCounter(
"trpc.contentstack.loyaltyLevel.all-success"
)
const getAllLoyaltyLevelFailCounter = meter.createCounter(
"trpc.contentstack.loyaltyLevel.all-fail"
)
export async function getAllLoyaltyLevels(ctx: Context) {
getAllLoyaltyLevelCounter.add(1)
// Ideally we should fetch all available tiers from API, but since they
// are static, we can just use the enum values. We want to know which
// levels we are fetching so that we can use tags to cache them
const allLevelIds = Object.values(MembershipLevelEnum)
const tags = allLevelIds.map((levelId) =>
generateLoyaltyConfigTag(ctx.lang, "loyalty_level", levelId)
)
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
GetAllLoyaltyLevels,
{ lang: ctx.lang, level_ids: allLevelIds },
{ next: { tags }, cache: "force-cache" }
)
if (!loyaltyLevelsConfigResponse.data) {
getAllLoyaltyLevelFailCounter.add(1)
const notFoundError = notFound(loyaltyLevelsConfigResponse)
console.error(
"contentstack.loyaltyLevels not found error",
JSON.stringify({
query: {
lang: ctx.lang,
},
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
loyaltyLevelsConfigResponse.data
)
if (!validatedLoyaltyLevels.success) {
getAllLoyaltyLevelFailCounter.add(1)
console.error(validatedLoyaltyLevels.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: {
lang: ctx.lang,
},
error: validatedLoyaltyLevels.error,
})
)
return []
}
getAllLoyaltyLevelSuccessCounter.add(1)
return validatedLoyaltyLevels.data
}
export async function getLoyaltyLevel(ctx: Context, level_id: MembershipLevel) {
getAllLoyaltyLevelCounter.add(1)
const loyaltyLevelsConfigResponse = await request<LoyaltyLevelsResponse>(
GetLoyaltyLevel,
{ lang: ctx.lang, level_id },
{
next: {
tags: [generateLoyaltyConfigTag(ctx.lang, "loyalty_level", level_id)],
},
cache: "force-cache",
}
)
if (
!loyaltyLevelsConfigResponse.data ||
!loyaltyLevelsConfigResponse.data.all_loyalty_level.items.length
) {
getAllLoyaltyLevelFailCounter.add(1)
const notFoundError = notFound(loyaltyLevelsConfigResponse)
console.error(
"contentstack.loyaltyLevels not found error",
JSON.stringify({
query: { lang: ctx.lang, level_id },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedLoyaltyLevels = validateLoyaltyLevelsSchema.safeParse(
loyaltyLevelsConfigResponse.data
)
if (!validatedLoyaltyLevels.success) {
getAllLoyaltyLevelFailCounter.add(1)
console.error(validatedLoyaltyLevels.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { lang: ctx.lang, level_id },
error: validatedLoyaltyLevels.error,
})
)
return null
}
getAllLoyaltyLevelSuccessCounter.add(1)
return validatedLoyaltyLevels.data[0]
}
export const loyaltyLevelQueryRouter = router({
byLevel: contentstackBaseProcedure
.input(loyaltyLevelInput)
.query(async function ({ ctx, input }) {
return getLoyaltyLevel(ctx, input.level)
}),
all: contentstackBaseProcedure.query(async function ({ ctx }) {
return getAllLoyaltyLevels(ctx)
}),
})

View File

@@ -0,0 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { rewardQueryRouter } from "./query"
export const rewardRouter = mergeRouters(rewardQueryRouter)

View File

@@ -0,0 +1,19 @@
import { z } from "zod"
import { Lang } from "@/constants/languages"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const rewardsByLevelInput = z.object({
level_id: z.nativeEnum(MembershipLevelEnum),
unique: z.boolean().default(false),
})
export const rewardsAllInput = z
.object({ unique: z.boolean() })
.default({ unique: false })
export const rewardsCurrentInput = z.object({
limit: z.number().min(0).default(3),
cursor: z.number().optional().default(0),
lang: z.nativeEnum(Lang).optional(),
})

View File

@@ -0,0 +1,82 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const validateApiRewardSchema = z.object({
data: z.array(
z
.object({
title: z.string().optional(),
id: z.string().optional(),
type: z.string().optional(),
status: z.string().optional(),
rewardId: z.string().optional(),
redeemLocation: z.string().optional(),
autoApplyReward: z.boolean().default(false),
rewardType: z.string().optional(),
rewardTierLevel: z.string().optional(),
})
.optional()
),
})
enum TierKey {
tier1 = MembershipLevelEnum.L1,
tier2 = MembershipLevelEnum.L2,
tier3 = MembershipLevelEnum.L3,
tier4 = MembershipLevelEnum.L4,
tier5 = MembershipLevelEnum.L5,
tier6 = MembershipLevelEnum.L6,
tier7 = MembershipLevelEnum.L7,
}
type Key = keyof typeof TierKey
export const validateApiTierRewardsSchema = z.record(
z.nativeEnum(TierKey).transform((data) => {
return TierKey[data as unknown as Key]
}),
z.array(
z
.object({
title: z.string().optional(),
id: z.string().optional(),
type: z.string().optional(),
status: z.string().optional(),
rewardId: z.string().optional(),
redeemLocation: z.string().optional(),
autoApplyReward: z.boolean().default(false),
rewardType: z.string().optional(),
rewardTierLevel: z.string().optional(),
})
.optional()
)
)
export const validateCmsRewardsSchema = z
.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
taxonomies: z.array(
z.object({
term_uid: z.string().optional(),
})
),
label: z.string().optional(),
reward_id: z.string(),
grouped_label: z.string().optional(),
description: z.string().optional(),
grouped_description: z.string().optional(),
value: z.string().optional(),
})
),
}),
}),
})
.transform((data) => data.data.all_reward.items)
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
export type Reward = z.output<typeof validateCmsRewardsSchema>[0]

View File

@@ -0,0 +1,371 @@
import { metrics } from "@opentelemetry/api"
import { Lang } from "@/constants/languages"
import * as api from "@/lib/api"
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
import { request } from "@/lib/graphql/request"
import { Context } from "@/server/context"
import { notFound } from "@/server/errors/trpc"
import {
contentStackBaseWithProfileServiceProcedure,
contentStackBaseWithProtectedProcedure,
router,
} from "@/server/trpc"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import {
rewardsAllInput,
rewardsByLevelInput,
rewardsCurrentInput,
} from "./input"
import {
CmsRewardsResponse,
Reward,
validateApiRewardSchema,
validateApiTierRewardsSchema,
validateCmsRewardsSchema,
} from "./output"
const meter = metrics.getMeter("trpc.reward")
// OpenTelemetry metrics: Reward
const getCurrentRewardCounter = meter.createCounter(
"trpc.contentstack.reward.current"
)
const getCurrentRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.current-success"
)
const getCurrentRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.current-fail"
)
const getByLevelRewardCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel"
)
const getByLevelRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-success"
)
const getByLevelRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-fail"
)
const getAllRewardCounter = meter.createCounter("trpc.contentstack.reward.all")
const getAllRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.all-success"
)
const getAllRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.all-fail"
)
function getUniqueRewardIds(rewardIds: string[]) {
const uniqueRewardIds = new Set(rewardIds)
return Array.from(uniqueRewardIds)
}
async function getAllApiRewards(ctx: Context & { serviceToken: string }) {
const apiResponse = await api.get(api.endpoints.v1.tierRewards, {
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
// One hour. Since the service token is refreshed every hour, this is the longest cache we can have.
next: { revalidate: 60 * 60 },
})
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.rewards.tierRewards error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
}
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,
})
)
return null
}
return validatedApiTierRewards.data
}
async function getCmsRewards(locale: Lang, rewardIds: string[]) {
const tags = rewardIds.map((id) =>
generateLoyaltyConfigTag(locale, "reward", id)
)
const cmsRewardsResponse = await request<CmsRewardsResponse>(
GetRewards,
{
locale: locale,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
)
if (!cmsRewardsResponse.data) {
getAllRewardFailCounter.add(1, {
lang: locale,
error_type: "validation_error",
error: JSON.stringify(cmsRewardsResponse.data),
})
const notFoundError = notFound(cmsRewardsResponse)
console.error(
"contentstack.rewards not found error",
JSON.stringify({
query: {
locale,
rewardIds,
},
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedCmsRewards =
validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
if (!validatedCmsRewards.success) {
getAllRewardFailCounter.add(1, {
locale,
rewardIds,
error_type: "validation_error",
error: JSON.stringify(validatedCmsRewards.error),
})
console.error(validatedCmsRewards.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { locale, rewardIds },
error: validatedCmsRewards.error,
})
)
return null
}
return validatedCmsRewards.data
}
export const rewardQueryRouter = router({
current: contentStackBaseWithProtectedProcedure
.input(rewardsCurrentInput)
.query(async function ({ input, ctx }) {
getCurrentRewardCounter.add(1)
const { limit, cursor } = input
const apiResponse = await api.get(api.endpoints.v1.rewards, {
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: 60 * 60 },
})
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 = 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 = validatedApiRewards.data.data
.map((reward) => reward?.rewardId)
.filter(Boolean)
.sort() as string[]
const slicedData = rewardIds.slice(cursor, limit + cursor)
const cmsRewards = await getCmsRewards(ctx.lang, slicedData)
if (!cmsRewards) {
return null
}
const nextCursor =
limit + cursor < rewardIds.length ? limit + cursor : undefined
getCurrentRewardSuccessCounter.add(1)
return {
rewards: cmsRewards,
nextCursor,
}
}),
byLevel: contentStackBaseWithProfileServiceProcedure
.input(rewardsByLevelInput)
.query(async function ({ input, ctx }) {
getByLevelRewardCounter.add(1)
const { level_id } = input
const allUpcomingApiRewards = await getAllApiRewards(ctx)
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 = await getCmsRewards(ctx.lang, rewardIds)
if (!contentStackRewards) {
return null
}
const loyaltyLevelsConfig = await getLoyaltyLevel(ctx, input.level_id)
const levelsWithRewards = apiRewards
.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))
getByLevelRewardSuccessCounter.add(1)
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
}),
all: contentStackBaseWithProfileServiceProcedure
.input(rewardsAllInput)
.query(async function ({ input, ctx }) {
getAllRewardCounter.add(1)
const allApiRewards = await getAllApiRewards(ctx)
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
}),
})