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 getProfileCounter = meter.createCounter("trpc.user.profile") 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 (error) { 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) { 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 }), tracking: 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 = { 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 = { 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) }), })