Files
web/apps/scandic-web/server/routers/user/query.ts
Linus Flood 0c498d82ca Merged in feat/trackingsdk-client (pull request #1420)
feat: trackingsdk as client component

* feat: trackingsdk as client component

* Cleanup

* Merge changes from feat/trackingsdk-client

* revert yarn.lock

* Added lcpTime and wait with tracking until we have the values


Approved-by: Joakim Jäderberg
2025-02-27 07:22:58 +00:00

809 lines
24 KiB
TypeScript

import { metrics } from "@opentelemetry/api"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import {
protectedProcedure,
router,
safeProtectedProcedure,
} from "@/server/trpc"
import { countries } from "@/components/TempDesignSystem/Form/Country/countries"
import { cache } from "@/utils/cache"
import * as maskValue from "@/utils/maskValue"
import { getMembership, getMembershipCards } from "@/utils/user"
import {
friendTransactionsInput,
getSavedPaymentCardsInput,
staysInput,
} from "./input"
import {
creditCardsSchema,
getFriendTransactionsSchema,
getMembershipCardsSchema,
getStaysSchema,
getUserSchema,
} from "./output"
import { updateStaysBookingUrl } from "./utils"
import type { Session } from "next-auth"
import type {
LoginType,
TrackingSDKUserData,
} from "@/types/components/tracking"
import { Transactions } from "@/types/enums/transactions"
import type { User } from "@/types/user"
import type { MembershipLevel } from "@/constants/membershipLevels"
// 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")
const getProfileSuccessCounter = meter.createCounter(
"trpc.user.profile-success"
)
const getProfileFailCounter = meter.createCounter("trpc.user.profile-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 }: { session: Session }) => {
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.v1.Profile.profile, {
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
})
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,
membership: getMembership(data.memberships),
memberships: data.memberships,
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
.use(async function (opts) {
return opts.next({
ctx: {
...opts.ctx,
isMFA:
opts.ctx.session.token.mfa_scope &&
opts.ctx.session.token.mfa_expires_at > Date.now(),
},
})
})
.query(async function getUser({ ctx }) {
const data = await getVerifiedUser({ session: ctx.session })
if (!data) {
return null
}
if ("error" in data && data.error) {
return data
}
return parsedUser(data.data, ctx.isMFA)
}),
getSafely: safeProtectedProcedure.query(async function getUser({ ctx }) {
if (!ctx.session) {
return null
}
const data = await getVerifiedUser({ session: ctx.session })
if (!data || "error" in data) {
return null
}
return parsedUser(data.data, true)
}),
name: safeProtectedProcedure.query(async function ({ ctx }) {
if (!ctx.session) {
return null
}
const verifiedData = await getVerifiedUser({ session: ctx.session })
if (!verifiedData || "error" in verifiedData) {
return null
}
return {
firstName: verifiedData.data.firstName,
lastName: verifiedData.data.lastName,
}
}),
membershipLevel: protectedProcedure.query(async function ({ ctx }) {
const verifiedData = await getVerifiedUser({ session: ctx.session })
if (!verifiedData || "error" in verifiedData) {
return null
}
const membershipLevel = getMembership(verifiedData.data.memberships)
return membershipLevel
}),
safeMembershipLevel: safeProtectedProcedure.query(async function ({ ctx }) {
if (!ctx.session) {
return null
}
const verifiedData = await getVerifiedUser({ session: ctx.session })
if (!verifiedData || "error" in verifiedData) {
return null
}
const membershipLevel = getMembership(verifiedData.data.memberships)
return membershipLevel
}),
userTrackingInfo: safeProtectedProcedure.query(async function ({ ctx }) {
const notLoggedInUserTrackingData: TrackingSDKUserData = {
loginStatus: "Non-logged in",
}
if (!ctx.session) {
return notLoggedInUserTrackingData
}
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,
}),
})
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),
})
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 = getMembership(verifiedUserData.data.memberships)
const loggedInUserTrackingData: TrackingSDKUserData = {
loginStatus: "logged in",
loginType: ctx.session.token.loginType as LoginType,
memberId: verifiedUserData.data.profileId,
membershipNumber: membership?.membershipNumber,
memberLevel: membership?.membershipLevel as MembershipLevel,
noOfNightsStayed: verifiedPreviousStaysData.data.links?.totalCount ?? 0,
totalPointsAvailableToSpend: membership?.currentPoints,
loginAction: "login success",
}
return loggedInUserTrackingData
}),
stays: router({
previous: protectedProcedure
.input(staysInput)
.query(async ({ ctx, input }) => {
const { limit, cursor } = input
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,
ctx.session.token.access_token,
ctx.lang
)
return {
data: updatedData,
nextCursor,
}
}),
upcoming: protectedProcedure
.input(staysInput)
.query(async ({ ctx, input }) => {
const { limit, cursor } = input
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,
ctx.session.token.access_token,
ctx.lang
)
return {
data: updatedData,
nextCursor,
}
}),
}),
transaction: router({
friendTransactions: protectedProcedure
.input(friendTransactionsInput)
.query(async ({ ctx, input }) => {
const { limit, page } = input
getFriendTransactionsCounter.add(1)
console.info(
"api.transaction.friendTransactions start",
JSON.stringify({})
)
const apiResponse = await api.get(
api.endpoints.v1.Profile.Transaction.friendTransactions,
{
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: 30 * 60 * 1000 },
}
)
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,
},
})
)
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 })
)
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.lang
)
const pageData = updatedData
.filter((t) => t.type !== Transactions.rewardType.expired)
.sort((a, b) => {
// 'BALFWD' are transactions from Opera migration that happended in May 2021
if (a.attributes.confirmationNumber === "BALFWD") return 1
if (b.attributes.confirmationNumber === "BALFWD") return -1
const dateA = new Date(
a.attributes.checkinDate
? a.attributes.checkinDate
: a.attributes.transactionDate
)
const dateB = new Date(
b.attributes.checkinDate
? b.attributes.checkinDate
: b.attributes.transactionDate
)
return dateA > dateB ? -1 : 1
})
const slicedData = pageData.slice(limit * (page - 1), limit * page)
return {
data: {
transactions: slicedData.map(({ type, attributes }) => {
return {
type,
awardPoints: attributes.awardPoints,
checkinDate: attributes.checkinDate,
checkoutDate: attributes.checkoutDate,
city: attributes.hotelInformation?.city,
confirmationNumber: attributes.confirmationNumber,
hotelName: attributes.hotelInformation?.name,
nights: attributes.nights,
pointsCalculated: attributes.pointsCalculated,
hotelId: attributes.hotelOperaId,
transactionDate: attributes.transactionDate,
bookingUrl: attributes.bookingUrl,
}
}),
},
meta: {
totalPages: Math.ceil(pageData.length / limit),
},
}
}),
}),
creditCards: protectedProcedure.query(async function ({ ctx }) {
return await getCreditCards({ session: ctx.session })
}),
safePaymentCards: safeProtectedProcedure
.input(getSavedPaymentCardsInput)
.query(async function ({ ctx, input }) {
if (!ctx.session) {
return null
}
const savedCards = await getCreditCards({
session: ctx.session,
onlyNonExpired: true,
})
if (!savedCards) {
return null
}
return savedCards.filter((card) =>
input.supportedCards.includes(card.type)
)
}),
membershipCards: protectedProcedure.query(async function ({ ctx }) {
const userData = await getVerifiedUser({ session: ctx.session })
if (!userData || "error" in userData) {
return null
}
const verifiedData = getMembershipCardsSchema.safeParse(
userData.data.memberships
)
if (!verifiedData.success) {
getProfileFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(verifiedData),
})
console.error(
"api.profile validation error",
JSON.stringify({ error: verifiedData })
)
return null
}
getProfileSuccessCounter.add(1)
return getMembershipCards(verifiedData.data)
}),
})