Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
810 lines
24 KiB
TypeScript
810 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 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<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)
|
|
}),
|
|
})
|