Merge remote-tracking branch 'origin' into feature/tracking

This commit is contained in:
Linus Flood
2024-12-13 09:02:37 +01:00
329 changed files with 4494 additions and 1910 deletions

View File

@@ -1,6 +1,5 @@
import { z } from "zod"
import { Lang } from "@/constants/languages"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const rewardsByLevelInput = z.object({
@@ -12,15 +11,14 @@ export const rewardsAllInput = z
.object({ unique: z.boolean() })
.default({ unique: false })
export const rewardsCurrentInput = z.object({
limit: z.number().min(1).default(3),
cursor: z.number().optional().default(0),
lang: z.nativeEnum(Lang).optional(),
})
export const rewardsUpdateInput = z.array(
z.object({
rewardId: z.string(),
couponCode: z.string(),
})
)
export const rewardsRedeemInput = z.object({
rewardId: z.string(),
couponCode: z.string().optional(),
})

View File

@@ -115,13 +115,57 @@ export const validateCmsRewardsSchema = z
})
.transform((data) => data.data.all_reward.items)
export const validateCmsRewardsWithRedeemSchema = 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(),
redeem_description: z
.string()
.nullable()
.transform((val) => val || ""),
grouped_description: z.string().optional(),
value: z.string().optional(),
})
),
}),
}),
})
.transform((data) => data.data.all_reward.items)
export type ApiReward = z.output<typeof validateApiRewardSchema>[0]
export type SurpriseReward = z.output<typeof SurpriseReward>
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
export type Reward = z.output<typeof validateCmsRewardsSchema>[0]
export type CmsRewardsWithRedeemResponse = z.input<
typeof validateCmsRewardsWithRedeemSchema
>
export type CMSReward = z.output<typeof validateCmsRewardsSchema>[0]
export type CMSRewardWithRedeem = z.output<
typeof validateCmsRewardsWithRedeemSchema
>[0]
export type Reward = CMSReward & {
id: string | undefined
}
export type RewardWithRedeem = CMSRewardWithRedeem & {
id: string | undefined
}
// New endpoint related types and schemas.

View File

@@ -12,14 +12,13 @@ import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import {
rewardsAllInput,
rewardsByLevelInput,
rewardsCurrentInput,
rewardsRedeemInput,
rewardsUpdateInput,
} from "./input"
import {
Reward,
validateApiRewardSchema,
validateCategorizedRewardsSchema,
} from "./output"
type
Reward, validateApiRewardSchema,
validateCategorizedRewardsSchema} from "./output"
import {
getAllCachedApiRewards,
getAllRewardCounter,
@@ -33,12 +32,16 @@ import {
getCurrentRewardCounter,
getCurrentRewardFailCounter,
getCurrentRewardSuccessCounter,
getRedeemCounter,
getRedeemFailCounter,
getRedeemSuccessCounter,
getUniqueRewardIds,
getUnwrapSurpriseCounter,
getUnwrapSurpriseFailCounter,
getUnwrapSurpriseSuccessCounter,
} from "./utils"
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({
@@ -157,108 +160,105 @@ export const rewardQueryRouter = router({
getByLevelRewardSuccessCounter.add(1)
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
}),
current: contentStackBaseWithProtectedProcedure
.input(rewardsCurrentInput)
.query(async function ({ input, ctx }) {
getCurrentRewardCounter.add(1)
current: contentStackBaseWithProtectedProcedure.query(async function ({
ctx,
}) {
getCurrentRewardCounter.add(1)
const { limit, cursor } = input
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
const endpoint = isNewEndpoint
? api.endpoints.v1.Profile.Reward.reward
: api.endpoints.v1.Profile.reward
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
const endpoint = isNewEndpoint
? 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 apiResponse = await api.get(endpoint, {
cache: undefined, // override defaultOptions
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,
}),
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCurrentRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
console.error(
"api.reward error ",
JSON.stringify({
error: {
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 = validatedApiRewards.data
.map((reward) => reward?.rewardId)
.filter((rewardId): rewardId is string => !!rewardId)
.sort()
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
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(
(reward) => !wrappedSurprisesIds.includes(reward.reward_id)
)
return null
}
getCurrentRewardSuccessCounter.add(1)
const data = await apiResponse.json()
const validatedApiRewards = isNewEndpoint
? validateCategorizedRewardsSchema.safeParse(data)
: validateApiRewardSchema.safeParse(data)
return {
rewards,
nextCursor,
}
}),
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
.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
.filter(
(reward) =>
reward.type === "coupon" &&
reward.rewardType === "Surprise" &&
"coupon" in reward &&
reward.coupon?.some(({ unwrapped }) => !unwrapped)
)
.map(({ rewardId }) => rewardId)
const rewards = cmsRewards
.filter((reward) => !wrappedSurprisesIds.includes(reward.reward_id))
.map((reward) => {
return {
...reward,
id: validatedApiRewards.data.find(
({ rewardId }) => rewardId === reward.reward_id
)?.id,
}
})
getCurrentRewardSuccessCounter.add(1)
return { rewards }
}),
surprises: contentStackBaseWithProtectedProcedure.query(async ({ ctx }) => {
getCurrentRewardCounter.add(1)
@@ -427,6 +427,53 @@ export const rewardQueryRouter = router({
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
}),
})

View File

@@ -1,20 +1,24 @@
import { metrics } from "@opentelemetry/api"
import { unstable_cache } from "next/cache"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
import { GetRewards as GetRewardsWithReedem } from "@/lib/graphql/Query/RewardsWithRedeem.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { generateLoyaltyConfigTag } from "@/utils/generateTag"
import {
CmsRewardsResponse,
validateApiAllTiersSchema,
type
CmsRewardsResponse,type
CmsRewardsWithRedeemResponse, validateApiAllTiersSchema,
validateApiTierRewardsSchema,
validateCmsRewardsSchema,
} from "./output"
validateCmsRewardsWithRedeemSchema} from "./output"
import type { Lang } from "@/constants/languages"
const meter = metrics.getMeter("trpc.reward")
export const getAllRewardCounter = meter.createCounter(
@@ -53,6 +57,15 @@ export const getUnwrapSurpriseFailCounter = meter.createCounter(
export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-success"
)
export const getRedeemCounter = meter.createCounter(
"trpc.contentstack.reward.redeem"
)
export const getRedeemFailCounter = meter.createCounter(
"trpc.contentstack.reward.redeem-fail"
)
export const getRedeemSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.redeem-success"
)
const ONE_HOUR = 60 * 60
@@ -187,14 +200,24 @@ export 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" }
)
const cmsRewardsResponse = env.USE_NEW_REWARD_MODEL
? await request<CmsRewardsWithRedeemResponse>(
GetRewardsWithReedem,
{
locale: locale,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
)
: await request<CmsRewardsResponse>(
GetRewards,
{
locale: locale,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
)
if (!cmsRewardsResponse.data) {
getAllRewardFailCounter.add(1, {
@@ -216,8 +239,9 @@ export async function getCmsRewards(locale: Lang, rewardIds: string[]) {
throw notFoundError
}
const validatedCmsRewards =
validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
const validatedCmsRewards = env.USE_NEW_REWARD_MODEL
? validateCmsRewardsWithRedeemSchema.safeParse(cmsRewardsResponse)
: validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
if (!validatedCmsRewards.success) {
getAllRewardFailCounter.add(1, {

View File

@@ -6,6 +6,7 @@ export const buttonSchema = z
.object({
cta_text: z.string().optional().default(""),
open_in_new_tab: z.boolean().default(false),
is_contentstack_link: z.boolean().default(false),
external_link: z
.object({
href: z.string().optional().default(""),
@@ -34,7 +35,7 @@ export const buttonSchema = z
}),
})
.transform((data) => {
if (data.linkConnection?.edges?.length) {
if (data.linkConnection?.edges?.length && data.is_contentstack_link) {
const link = data.linkConnection.edges[0].node
return {
href: link.url,

View File

@@ -1,6 +1,6 @@
import { z } from "zod"
import { ChildBedTypeEnum, PaymentMethodEnum } from "@/constants/booking"
import { ChildBedTypeEnum ,type PaymentMethodEnum } from "@/constants/booking"
import { dt } from "@/lib/dt"
import { toLang } from "@/server/utils"
@@ -514,7 +514,7 @@ const linksSchema = z.object({
export const priceSchema = z.object({
pricePerNight: z.coerce.number(),
pricePerStay: z.coerce.number(),
currency: z.string(),
currency: z.nativeEnum(CurrencyEnum),
})
export const productTypePriceSchema = z.object({
@@ -530,7 +530,7 @@ const productSchema = z.object({
rateCode: "",
rateType: "",
localPrice: {
currency: "SEK",
currency: CurrencyEnum.SEK,
pricePerNight: 0,
pricePerStay: 0,
},

View File

@@ -547,7 +547,7 @@ export const hotelQueryRouter = router({
const hotelData = await getHotelData(
{
hotelId,
language: ctx.lang,
language: toApiLang(ctx.lang),
},
ctx.serviceToken
)
@@ -607,14 +607,18 @@ export const hotelQueryRouter = router({
const bedTypes = availableRoomsInCategory
.map((availRoom) => {
const matchingRoom = hotelData?.included
?.find((room) => room.name === availRoom.roomType)
?.find((room) =>
room.roomTypes
.map((roomType) => roomType.code)
.includes(availRoom.roomTypeCode)
)
?.roomTypes.find(
(roomType) => roomType.code === availRoom.roomTypeCode
)
if (matchingRoom) {
return {
description: matchingRoom.mainBed.description,
description: matchingRoom.description,
size: matchingRoom.mainBed.widthRange,
value: matchingRoom.code,
}

View File

@@ -22,13 +22,16 @@ export const getUserSchema = z
.object({
data: z.object({
attributes: z.object({
address: z.object({
city: z.string().optional(),
country: z.string().optional(),
countryCode: z.nativeEnum(countriesMap).optional(),
streetAddress: z.string().optional(),
zipCode: z.string().optional(),
}),
address: z
.object({
city: z.string().optional(),
country: z.string().optional(),
countryCode: z.nativeEnum(countriesMap).optional(),
streetAddress: z.string().optional(),
zipCode: z.string().optional(),
})
.optional()
.nullable(),
dateOfBirth: z.string().optional().default("1900-01-01"),
email: z.string().email(),
firstName: z.string(),

View File

@@ -34,7 +34,7 @@ import type {
TrackingSDKUserData,
} from "@/types/components/tracking"
import { Transactions } from "@/types/enums/transactions"
import { User } from "@/types/user"
import type { User } from "@/types/user"
import type { MembershipLevel } from "@/constants/membershipLevels"
// OpenTelemetry metrics: User
@@ -170,15 +170,15 @@ export const getVerifiedUser = cache(
)
export function parsedUser(data: User, isMFA: boolean) {
const country = countries.find((c) => c.code === data.address.countryCode)
const country = countries.find((c) => c.code === data.address?.countryCode)
const user = {
address: {
city: data.address.city,
city: data.address?.city,
country: country?.name ?? "",
countryCode: data.address.countryCode,
streetAddress: data.address.streetAddress,
zipCode: data.address.zipCode,
countryCode: data.address?.countryCode,
streetAddress: data.address?.streetAddress,
zipCode: data.address?.zipCode,
},
dateOfBirth: data.dateOfBirth,
email: data.email,

View File

@@ -5,7 +5,7 @@ import { env } from "@/env/server"
import { generateServiceTokenTag } from "@/utils/generateTag"
import { ServiceTokenResponse } from "@/types/tokens"
import type { ServiceTokenResponse } from "@/types/tokens"
// OpenTelemetry metrics: Service token
const meter = metrics.getMeter("trpc.context.serviceToken")
@@ -72,7 +72,7 @@ async function fetchServiceToken(scopes: string[]) {
export async function getServiceToken() {
let scopes: string[] = []
if (env.ENABLE_BOOKING_FLOW) {
scopes = ["profile", "hotel", "booking", "package"]
scopes = ["profile", "hotel", "booking", "package", "availability"]
} else {
scopes = ["profile"]
}