feat: harmonize log and metrics

This commit is contained in:
Michael Zetterberg
2025-04-17 07:16:11 +02:00
parent 858a81b16f
commit 5323a8e46e
58 changed files with 2324 additions and 4726 deletions

View File

@@ -11,11 +11,7 @@ export const staysInput = z
.number()
.optional()
.transform((num) => (num ? String(num) : undefined)),
limit: z
.number()
.min(0)
.default(6)
.transform((num) => String(num)),
limit: z.number().min(0).default(6),
lang: z.nativeEnum(Lang).optional(),
})
.default({})

View File

@@ -1,5 +1,3 @@
import { metrics } from "@opentelemetry/api"
import { signupVerify } from "@/constants/routes/signup"
import { env } from "@/env/server"
import * as api from "@/lib/api"
@@ -8,6 +6,7 @@ import {
initiateSaveCardSchema,
subscriberIdSchema,
} from "@/server/routers/user/output"
import { createCounter } from "@/server/telemetry"
import { protectedProcedure, router, serviceProcedure } from "@/server/trpc"
import {
@@ -17,20 +16,6 @@ import {
signupInput,
} from "./input"
const meter = metrics.getMeter("trpc.user")
const generatePreferencesLinkCounter = meter.createCounter(
"trpc.user.generatePreferencesLink"
)
const generatePreferencesLinkSuccessCounter = meter.createCounter(
"trpc.user.generatePreferencesLink-success"
)
const generatePreferencesLinkFailCounter = meter.createCounter(
"trpc.user.generatePreferencesLink-fail"
)
const signupCounter = meter.createCounter("trpc.user.signup")
const signupSuccessCounter = meter.createCounter("trpc.user.signup-success")
const signupFailCounter = meter.createCounter("trpc.user.signup-fail")
export const userMutationRouter = router({
creditCard: router({
add: protectedProcedure.input(addCreditCardInput).mutation(async function ({
@@ -159,7 +144,15 @@ export const userMutationRouter = router({
generatePreferencesLink: protectedProcedure.mutation(async function ({
ctx,
}) {
generatePreferencesLinkCounter.add(1)
const generatePreferencesLinkCounter = createCounter(
"trpc.user",
"generatePreferencesLink"
)
const metricsGeneratePreferencesLink = generatePreferencesLinkCounter.init()
metricsGeneratePreferencesLink.start()
const apiResponse = await api.get(api.endpoints.v1.Profile.subscriberId, {
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
@@ -167,25 +160,7 @@ export const userMutationRouter = router({
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
generatePreferencesLinkFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.user.subscriberId error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
await metricsGeneratePreferencesLink.httpError(apiResponse)
return null
}
@@ -194,31 +169,23 @@ export const userMutationRouter = router({
const validatedData = subscriberIdSchema.safeParse(data)
if (!validatedData.success) {
generatePreferencesLinkSuccessCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedData.error),
})
console.error(
"api.user.generatePreferencesLink validation error",
JSON.stringify({
error: validatedData.error,
})
)
console.error(validatedData.error.format())
metricsGeneratePreferencesLink.validationError(validatedData.error)
return null
}
const preferencesLink = new URL(env.SALESFORCE_PREFERENCE_BASE_URL)
preferencesLink.searchParams.set("subKey", validatedData.data.subscriberId)
generatePreferencesLinkSuccessCounter.add(1)
metricsGeneratePreferencesLink.success()
return preferencesLink.toString()
}),
signup: serviceProcedure.input(signupInput).mutation(async function ({
ctx,
input,
}) {
signupCounter.add(1)
const signupCounter = createCounter("trpc.user", "signup")
const metricsSignup = signupCounter.init()
const apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
body: input,
@@ -228,29 +195,13 @@ export const userMutationRouter = router({
})
if (!apiResponse.ok) {
await metricsSignup.httpError(apiResponse)
const text = await apiResponse.text()
signupFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
}),
})
console.error(
"api.user.signup api error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
throw serverErrorByStatus(apiResponse.status, text)
}
signupSuccessCounter.add(1)
console.info("api.user.signup success")
metricsSignup.success()
return {
success: true,
redirectUrl: signupVerify[input.language],

View File

@@ -1,8 +1,5 @@
import { metrics } from "@opentelemetry/api"
import { countries } from "@/constants/countries"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { createCounter } from "@/server/telemetry"
import {
languageProtectedProcedure,
protectedProcedure,
@@ -10,8 +7,6 @@ import {
safeProtectedProcedure,
} from "@/server/trpc"
import { cache } from "@/utils/cache"
import * as maskValue from "@/utils/maskValue"
import { isValidSession } from "@/utils/session"
import { getFriendsMembership, getMembershipCards } from "@/utils/user"
@@ -20,277 +15,21 @@ import {
getSavedPaymentCardsInput,
staysInput,
} from "./input"
import { getFriendTransactionsSchema } from "./output"
import {
creditCardsSchema,
getFriendTransactionsSchema,
getStaysSchema,
getUserSchema,
} from "./output"
import { updateStaysBookingUrl } from "./utils"
import type { Session } from "next-auth"
getCreditCards,
getPreviousStays,
getUpcomingStays,
getVerifiedUser,
parsedUser,
updateStaysBookingUrl,
} from "./utils"
import type {
LoginType,
TrackingSDKUserData,
} from "@/types/components/tracking"
import { Transactions } from "@/types/enums/transactions"
import type { User } from "@/types/user"
// OpenTelemetry metrics: User
const meter = metrics.getMeter("trpc.user")
const getVerifiedUserCounter = meter.createCounter("trpc.user.get")
const getVerifiedUserSuccessCounter = meter.createCounter(
"trpc.user.get-success"
)
const getVerifiedUserFailCounter = meter.createCounter("trpc.user.get-fail")
// OpenTelemetry metrics: Stays
const getPreviousStaysCounter = meter.createCounter("trpc.user.stays.previous")
const getPreviousStaysSuccessCounter = meter.createCounter(
"trpc.user.stays.previous-success"
)
const getPreviousStaysFailCounter = meter.createCounter(
"trpc.user.stays.previous-fail"
)
const getUpcomingStaysCounter = meter.createCounter("trpc.user.stays.upcoming")
const getUpcomingStaysSuccessCounter = meter.createCounter(
"trpc.user.stays.upcoming-success"
)
const getUpcomingStaysFailCounter = meter.createCounter(
"trpc.user.stays.upcoming-fail"
)
// OpenTelemetry metrics: Transactions
const getFriendTransactionsCounter = meter.createCounter(
"trpc.user.transactions.friendTransactions"
)
const getFriendTransactionsSuccessCounter = meter.createCounter(
"trpc.user.transactions.friendTransactions-success"
)
const getFriendTransactionsFailCounter = meter.createCounter(
"trpc.user.transactions.friendTransactions-fail"
)
// OpenTelemetry metrics: Credit Cards
const getCreditCardsCounter = meter.createCounter("trpc.user.creditCards")
const getCreditCardsSuccessCounter = meter.createCounter(
"trpc.user.creditCards-success"
)
const getCreditCardsFailCounter = meter.createCounter(
"trpc.user.creditCards-fail"
)
export const getVerifiedUser = cache(
async ({
session,
includeExtendedPartnerData,
}: {
session: Session
includeExtendedPartnerData?: boolean
}) => {
const now = Date.now()
if (session.token.expires_at && session.token.expires_at < now) {
return { error: true, cause: "token_expired" } as const
}
getVerifiedUserCounter.add(1)
console.info("api.user.profile getVerifiedUser start", JSON.stringify({}))
const apiResponse = await api.get(
api.endpoints.v2.Profile.profile,
{
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
},
includeExtendedPartnerData
? { includes: "extendedPartnerInformation" }
: {}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
getVerifiedUserFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.user.profile getVerifiedUser error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const
} else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const
} else if (apiResponse.status === 404) {
return { error: true, cause: "notfound" } as const
}
return {
error: true,
cause: "unknown",
status: apiResponse.status,
} as const
}
const apiJson = await apiResponse.json()
if (!apiJson.data?.attributes) {
getVerifiedUserFailCounter.add(1, {
error_type: "data_error",
})
console.error(
"api.user.profile getVerifiedUser data error",
JSON.stringify({
apiResponse: apiJson,
})
)
return null
}
const verifiedData = getUserSchema.safeParse(apiJson)
if (!verifiedData.success) {
getVerifiedUserFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(verifiedData.error),
})
console.error(
"api.user.profile validation error",
JSON.stringify({
errors: verifiedData.error,
apiResponse: apiJson,
})
)
return null
}
getVerifiedUserSuccessCounter.add(1)
console.info("api.user.profile getVerifiedUser success", JSON.stringify({}))
return verifiedData
}
)
export function parsedUser(data: User, isMFA: boolean) {
const country = countries.find((c) => c.code === data.address?.countryCode)
const user = {
address: {
city: data.address?.city,
country: country?.name ?? "",
countryCode: data.address?.countryCode,
streetAddress: data.address?.streetAddress,
zipCode: data.address?.zipCode,
},
dateOfBirth: data.dateOfBirth,
email: data.email,
firstName: data.firstName,
language: data.language,
lastName: data.lastName,
membershipNumber: data.membershipNumber,
membership: getFriendsMembership(data.loyalty),
loyalty: data.loyalty,
name: `${data.firstName} ${data.lastName}`,
phoneNumber: data.phoneNumber,
profileId: data.profileId,
}
if (!isMFA) {
if (user.address.city) {
user.address.city = maskValue.text(user.address.city)
}
if (user.address.streetAddress) {
user.address.streetAddress = maskValue.text(user.address.streetAddress)
}
user.address.zipCode = data.address?.zipCode
? maskValue.text(data.address.zipCode)
: ""
user.dateOfBirth = maskValue.all(user.dateOfBirth)
user.email = maskValue.email(user.email)
user.phoneNumber = user.phoneNumber ? maskValue.phone(user.phoneNumber) : ""
}
return user
}
const getCreditCards = cache(
async ({
session,
onlyNonExpired,
}: {
session: Session
onlyNonExpired?: boolean
}) => {
getCreditCardsCounter.add(1)
console.info("api.profile.creditCards start", JSON.stringify({}))
const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, {
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
getCreditCardsFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.profile.creditCards error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = creditCardsSchema.safeParse(apiJson)
if (!verifiedData.success) {
getCreditCardsFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(verifiedData.error),
})
console.error(
"api.profile.creditCards validation error ",
JSON.stringify({ error: verifiedData.error })
)
return null
}
getCreditCardsSuccessCounter.add(1)
console.info("api.profile.creditCards success", JSON.stringify({}))
return verifiedData.data.data.filter((card) => {
if (onlyNonExpired) {
try {
const expirationDate = dt(card.expirationDate).startOf("day")
const currentDate = dt().startOf("day")
return expirationDate > currentDate
} catch (_) {
return false
}
}
return true
})
}
)
export const userQueryRouter = router({
get: protectedProcedure
@@ -385,11 +124,20 @@ export const userQueryRouter = router({
return membershipLevel
}),
userTrackingInfo: safeProtectedProcedure.query(async function ({ ctx }) {
const userTrackingInfoCounter = createCounter("user", "userTrackingInfo")
const metricsUserTrackingInfo = userTrackingInfoCounter.init()
metricsUserTrackingInfo.start()
const notLoggedInUserTrackingData: TrackingSDKUserData = {
loginStatus: "Non-logged in",
}
if (!isValidSession(ctx.session)) {
metricsUserTrackingInfo.success({
reason: "invalid session",
data: notLoggedInUserTrackingData,
})
return notLoggedInUserTrackingData
}
@@ -397,62 +145,24 @@ export const userQueryRouter = router({
const verifiedUserData = await getVerifiedUser({ session: ctx.session })
if (!verifiedUserData || "error" in verifiedUserData) {
return notLoggedInUserTrackingData
}
const params = new URLSearchParams()
params.set("limit", "1")
getPreviousStaysCounter.add(1, { query: JSON.stringify({ params }) })
console.info(
"api.booking.stays.past start",
JSON.stringify({ query: { params } })
)
const previousStaysResponse = await api.get(
api.endpoints.v1.Booking.Stays.past,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
},
params
)
if (!previousStaysResponse.ok) {
getPreviousStaysFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: previousStaysResponse.status,
statusText: previousStaysResponse.statusText,
}),
metricsUserTrackingInfo.success({
reason: "invalid user data",
data: notLoggedInUserTrackingData,
})
console.error(
"api.booking.stays.past error",
JSON.stringify({
error: {
status: previousStaysResponse.status,
statusText: previousStaysResponse.statusText,
},
})
)
return notLoggedInUserTrackingData
}
const previousStaysApiJson = await previousStaysResponse.json()
const verifiedPreviousStaysData =
getStaysSchema.safeParse(previousStaysApiJson)
if (!verifiedPreviousStaysData.success) {
getPreviousStaysFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(verifiedPreviousStaysData.error),
const previousStaysData = await getPreviousStays(
ctx.session.token.access_token,
1
)
if (!previousStaysData) {
metricsUserTrackingInfo.success({
reason: "no previous stays data",
data: notLoggedInUserTrackingData,
})
console.error(
"api.booking.stays.past validation error, ",
JSON.stringify({ error: verifiedPreviousStaysData.error })
)
return notLoggedInUserTrackingData
}
getPreviousStaysSuccessCounter.add(1)
console.info("api.booking.stays.past success", JSON.stringify({}))
const membership = getFriendsMembership(verifiedUserData.data.loyalty)
@@ -462,13 +172,19 @@ export const userQueryRouter = router({
memberId: verifiedUserData.data.profileId,
membershipNumber: membership?.membershipNumber,
memberLevel: membership?.membershipLevel,
noOfNightsStayed: verifiedPreviousStaysData.data.links?.totalCount ?? 0,
noOfNightsStayed: previousStaysData.links?.totalCount ?? 0,
totalPointsAvailableToSpend: membership?.currentPoints,
loginAction: "login success",
}
metricsUserTrackingInfo.success({
reason: "valid logged in",
data: loggedInUserTrackingData,
})
return loggedInUserTrackingData
} catch (error) {
console.error("Error in userTrackingInfo:", error)
metricsUserTrackingInfo.fail(error)
return notLoggedInUserTrackingData
}
}),
@@ -479,93 +195,31 @@ export const userQueryRouter = router({
.query(async ({ ctx, input }) => {
const { limit, cursor, lang } = input
const language = lang || ctx.lang
const params: Record<string, string> = { limit }
if (cursor) {
params.offset = cursor
}
getPreviousStaysCounter.add(1, { query: JSON.stringify({ params }) })
console.info(
"api.booking.stays.past start",
JSON.stringify({ query: { params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.past,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
getPreviousStaysFailCounter.add(1, {
query: JSON.stringify({ params }),
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.booking.stays.past error ",
JSON.stringify({
query: { params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
getPreviousStaysFailCounter.add(1, {
query: JSON.stringify({ params }),
error_type: "validation_error",
error: JSON.stringify(verifiedData.error),
})
console.error(
"api.booking.stays.past validation error ",
JSON.stringify({
query: { params },
error: verifiedData.error,
})
)
return null
}
getPreviousStaysSuccessCounter.add(1, {
query: JSON.stringify({ params }),
})
console.info(
"api.booking.stays.past success",
JSON.stringify({ query: { params } })
)
const nextCursor =
verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset
: undefined
const updatedData = await updateStaysBookingUrl(
verifiedData.data.data,
const data = await getPreviousStays(
ctx.session.token.access_token,
language
limit,
cursor
)
return {
data: updatedData,
nextCursor,
if (data) {
const nextCursor =
data.links && data.links.offset < data.links.totalCount
? data.links.offset
: undefined
const updatedData = await updateStaysBookingUrl(
data.data,
ctx.session,
language
)
return {
data: updatedData,
nextCursor,
}
}
return null
}),
upcoming: languageProtectedProcedure
@@ -573,92 +227,31 @@ export const userQueryRouter = router({
.query(async ({ ctx, input }) => {
const { limit, cursor, lang } = input
const language = lang || ctx.lang
const params: Record<string, string> = { limit }
if (cursor) {
params.offset = cursor
}
getUpcomingStaysCounter.add(1, {
query: JSON.stringify({ params }),
})
console.info(
"api.booking.stays.future start",
JSON.stringify({ query: { params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.future,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
getUpcomingStaysFailCounter.add(1, {
query: JSON.stringify({ params }),
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.booking.stays.future error ",
JSON.stringify({
query: { params },
error_type: "http_error",
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
getUpcomingStaysFailCounter.add(1, {
query: JSON.stringify({ params }),
error_type: "validation_error",
error: JSON.stringify(verifiedData.error),
})
console.error(
"api.booking.stays.future validation error ",
JSON.stringify({
query: { params },
error: verifiedData.error,
})
)
return null
}
getUpcomingStaysSuccessCounter.add(1, {
query: JSON.stringify({ params }),
})
console.info("api.booking.stays.future success", {
query: JSON.stringify({ params }),
})
const nextCursor =
verifiedData.data.links &&
verifiedData.data.links.offset < verifiedData.data.links.totalCount
? verifiedData.data.links.offset
: undefined
const updatedData = await updateStaysBookingUrl(
verifiedData.data.data,
const data = await getUpcomingStays(
ctx.session.token.access_token,
language
limit,
cursor
)
return {
data: updatedData,
nextCursor,
if (data) {
const nextCursor =
data.links && data.links.offset < data.links.totalCount
? data.links.offset
: undefined
const updatedData = await updateStaysBookingUrl(
data.data,
ctx.session,
language
)
return {
data: updatedData,
nextCursor,
}
}
return null
}),
}),
transaction: router({
@@ -667,11 +260,18 @@ export const userQueryRouter = router({
.query(async ({ ctx, input }) => {
const { limit, page } = input
getFriendTransactionsCounter.add(1)
console.info(
"api.transaction.friendTransactions start",
JSON.stringify({})
const friendTransactionsCounter = createCounter(
"trpc.user.transactions",
"friendTransactions"
)
const metricsFriendTransactions = friendTransactionsCounter.init({
limit,
page,
})
metricsFriendTransactions.start()
const apiResponse = await api.get(
api.endpoints.v1.Profile.Transaction.friendTransactions,
{
@@ -682,61 +282,20 @@ export const userQueryRouter = router({
)
if (!apiResponse.ok) {
// switch (apiResponse.status) {
// case 400:
// throw badRequestError()
// case 401:
// throw unauthorizedError()
// case 403:
// throw forbiddenError()
// default:
// throw internalServerError()
// }
const text = await apiResponse.text()
getFriendTransactionsFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.transaction.friendTransactions error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
await metricsFriendTransactions.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
if (!verifiedData.success) {
getFriendTransactionsFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(verifiedData.error),
})
console.error(
"api.transaction.friendTransactions validation error ",
JSON.stringify({ error: verifiedData.error })
)
metricsFriendTransactions.validationError(verifiedData.error)
return null
}
getFriendTransactionsSuccessCounter.add(1)
console.info(
"api.transaction.friendTransactions success",
JSON.stringify({})
)
const updatedData = await updateStaysBookingUrl(
verifiedData.data.data,
ctx.session.token.access_token,
ctx.session,
ctx.lang
)
@@ -763,7 +322,7 @@ export const userQueryRouter = router({
const slicedData = pageData.slice(limit * (page - 1), limit * page)
return {
const result = {
data: {
transactions: slicedData.map(({ type, attributes }) => {
return {
@@ -786,6 +345,10 @@ export const userQueryRouter = router({
totalPages: Math.ceil(pageData.length / limit),
},
}
metricsFriendTransactions.success()
return result
}),
}),

View File

@@ -1,108 +1,363 @@
import { metrics } from "@opentelemetry/api"
import { countries } from "@/constants/countries"
import { myBookingPath } from "@/constants/myBooking"
import { myStay } from "@/constants/routes/myStay"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { encrypt } from "@/server/routers/utils/encryption"
import { createCounter } from "@/server/telemetry"
import { cache } from "@/utils/cache"
import * as maskValue from "@/utils/maskValue"
import { isValidSession } from "@/utils/session"
import { getCurrentWebUrl } from "@/utils/url"
import { getFriendsMembership } from "@/utils/user"
import { encrypt } from "../utils/encryption"
import {
creditCardsSchema,
type FriendTransaction,
getStaysSchema,
getUserSchema,
type Stay,
} from "./output"
import type { Session } from "next-auth"
import type { User } from "@/types/user"
import type { Lang } from "@/constants/languages"
import type { FriendTransaction, Stay } from "./output"
const meter = metrics.getMeter("trpc.user")
const getProfileCounter = meter.createCounter("trpc.user.profile")
const getProfileSuccessCounter = meter.createCounter(
"trpc.user.profile-success"
export const getVerifiedUser = cache(
async ({
session,
includeExtendedPartnerData,
}: {
session: Session
includeExtendedPartnerData?: boolean
}) => {
const getVerifiedUserCounter = createCounter("user", "getVerifiedUser")
const metricsGetVerifiedUser = getVerifiedUserCounter.init()
metricsGetVerifiedUser.start()
const now = Date.now()
if (session.token.expires_at && session.token.expires_at < now) {
metricsGetVerifiedUser.dataError(`Token expired`)
return { error: true, cause: "token_expired" } as const
}
const apiResponse = await api.get(
api.endpoints.v2.Profile.profile,
{
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
},
includeExtendedPartnerData
? { includes: "extendedPartnerInformation" }
: {}
)
if (!apiResponse.ok) {
await metricsGetVerifiedUser.httpError(apiResponse)
if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const
} else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const
} else if (apiResponse.status === 404) {
return { error: true, cause: "notfound" } as const
}
return {
error: true,
cause: "unknown",
status: apiResponse.status,
} as const
}
const apiJson = await apiResponse.json()
if (!apiJson.data?.attributes) {
metricsGetVerifiedUser.dataError(
`Missing data attributes in API response`,
{
data: apiJson,
}
)
return null
}
const verifiedData = getUserSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetVerifiedUser.validationError(verifiedData.error)
return null
}
metricsGetVerifiedUser.success()
return verifiedData
}
)
const getProfileFailCounter = meter.createCounter("trpc.user.profile-fail")
async function updateStaysBookingUrl(
data: Stay[],
token: string,
lang: Lang
): Promise<Stay[]>
export async function getMembershipNumber(
session: Session | null
): Promise<string | undefined> {
if (!isValidSession(session)) return undefined
async function updateStaysBookingUrl(
data: FriendTransaction[],
token: string,
lang: Lang
): Promise<FriendTransaction[]>
const verifiedUser = await getVerifiedUser({ session })
if (!verifiedUser || "error" in verifiedUser) {
return undefined
}
async function updateStaysBookingUrl(
data: Stay[] | FriendTransaction[],
token: string,
lang: Lang
return verifiedUser.data.membershipNumber
}
export async function getPreviousStays(
accessToken: string,
limit: number = 10,
cursor?: string
) {
// Temporary API call needed till we have user name in ctx session data
getProfileCounter.add(1)
console.info("api.user.profile updatebookingurl start", JSON.stringify({}))
const apiResponse = await api.get(api.endpoints.v1.Profile.profile, {
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
},
const getPreviousStaysCounter = createCounter("user", "getPreviousStays")
const metricsGetPreviousStays = getPreviousStaysCounter.init({
limit,
cursor,
})
metricsGetPreviousStays.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.past,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
{
limit,
cursor,
}
)
if (!apiResponse.ok) {
getProfileFailCounter.add(1, { error: JSON.stringify(apiResponse) })
console.info(
"api.user.profile updatebookingurl error",
JSON.stringify({ error: apiResponse })
)
return data
await metricsGetPreviousStays.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
if (!apiJson.data?.attributes) {
return data
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetPreviousStays.validationError(verifiedData.error)
return null
}
getProfileSuccessCounter.add(1)
console.info("api.user.profile updatebookingurl success", JSON.stringify({}))
metricsGetPreviousStays.success()
return data.map((d) => {
const originalString =
d.attributes.confirmationNumber.toString() +
"," +
apiJson.data.attributes.lastName
const encryptedBookingValue = encrypt(originalString)
// Get base URL with fallback for ephemeral environments (like deploy previews).
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
// Construct Booking URL.
const bookingUrl = env.HIDE_FOR_NEXT_RELEASE
? new URL(
getCurrentWebUrl({
path: myBookingPath[lang],
lang,
baseUrl,
})
)
: new URL(myStay[lang], baseUrl)
// Add search parameters.
if (encryptedBookingValue) {
bookingUrl.searchParams.set("RefId", encryptedBookingValue)
} else {
bookingUrl.searchParams.set("lastName", apiJson.data.attributes.lastName)
bookingUrl.searchParams.set(
"bookingId",
d.attributes.confirmationNumber.toString()
)
}
return {
...d,
attributes: {
...d.attributes,
bookingUrl: bookingUrl.toString(),
},
}
})
return verifiedData.data
}
export { updateStaysBookingUrl }
export async function getUpcomingStays(
accessToken: string,
limit: number = 10,
cursor?: string
) {
const getUpcomingStaysCounter = createCounter("user", "getUpcomingStays")
const metricsGetUpcomingStays = getUpcomingStaysCounter.init({
limit,
cursor,
})
metricsGetUpcomingStays.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.future,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
{
limit,
cursor,
}
)
if (!apiResponse.ok) {
await metricsGetUpcomingStays.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetUpcomingStays.validationError(verifiedData.error)
return null
}
metricsGetUpcomingStays.success()
return verifiedData.data
}
export function parsedUser(data: User, isMFA: boolean) {
const country = countries.find((c) => c.code === data.address?.countryCode)
const user = {
address: {
city: data.address?.city,
country: country?.name ?? "",
countryCode: data.address?.countryCode,
streetAddress: data.address?.streetAddress,
zipCode: data.address?.zipCode,
},
dateOfBirth: data.dateOfBirth,
email: data.email,
firstName: data.firstName,
language: data.language,
lastName: data.lastName,
membershipNumber: data.membershipNumber,
membership: getFriendsMembership(data.loyalty),
loyalty: data.loyalty,
name: `${data.firstName} ${data.lastName}`,
phoneNumber: data.phoneNumber,
profileId: data.profileId,
}
if (!isMFA) {
if (user.address.city) {
user.address.city = maskValue.text(user.address.city)
}
if (user.address.streetAddress) {
user.address.streetAddress = maskValue.text(user.address.streetAddress)
}
user.address.zipCode = data.address?.zipCode
? maskValue.text(data.address.zipCode)
: ""
user.dateOfBirth = maskValue.all(user.dateOfBirth)
user.email = maskValue.email(user.email)
user.phoneNumber = user.phoneNumber ? maskValue.phone(user.phoneNumber) : ""
}
return user
}
export const getCreditCards = cache(
async ({
session,
onlyNonExpired,
}: {
session: Session
onlyNonExpired?: boolean
}) => {
const getCreditCardsCounter = createCounter("user", "getCreditCards")
const metricsGetCreditCards = getCreditCardsCounter.init({
onlyNonExpired,
})
metricsGetCreditCards.start()
const apiResponse = await api.get(api.endpoints.v1.Profile.creditCards, {
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
})
if (!apiResponse.ok) {
await metricsGetCreditCards.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = creditCardsSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetCreditCards.validationError(verifiedData.error)
return null
}
const result = verifiedData.data.data.filter((card) => {
if (onlyNonExpired) {
try {
const expirationDate = dt(card.expirationDate).startOf("day")
const currentDate = dt().startOf("day")
return expirationDate > currentDate
} catch (_) {
return false
}
}
return true
})
metricsGetCreditCards.success()
return result
}
)
export async function updateStaysBookingUrl(
data: Stay[],
session: Session,
lang: Lang
): Promise<Stay[]>
export async function updateStaysBookingUrl(
data: FriendTransaction[],
session: Session,
lang: Lang
): Promise<FriendTransaction[]>
export async function updateStaysBookingUrl(
data: Stay[] | FriendTransaction[],
session: Session,
lang: Lang
) {
const user = await getVerifiedUser({
session,
})
if (user && !("error" in user)) {
return data.map((d) => {
const originalString =
d.attributes.confirmationNumber.toString() + "," + user.data.lastName
const encryptedBookingValue = encrypt(originalString)
// Get base URL with fallback for ephemeral environments (like deploy previews).
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
// Construct Booking URL.
const bookingUrl = env.HIDE_FOR_NEXT_RELEASE
? new URL(
getCurrentWebUrl({
path: myBookingPath[lang],
lang,
baseUrl,
})
)
: new URL(myStay[lang], baseUrl)
// Add search parameters.
if (encryptedBookingValue) {
bookingUrl.searchParams.set("RefId", encryptedBookingValue)
} else {
bookingUrl.searchParams.set("lastName", user.data.lastName)
bookingUrl.searchParams.set(
"bookingId",
d.attributes.confirmationNumber.toString()
)
}
return {
...d,
attributes: {
...d.attributes,
bookingUrl: bookingUrl.toString(),
},
}
})
}
return data
}