Merged in feat/sw-1975-get-profile-v2 (pull request #1651)

Use get Profile V2 endpoint

Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-04-08 06:26:00 +00:00
parent d282437a3d
commit c56a0b8ce9
18 changed files with 208 additions and 168 deletions

View File

@@ -10,7 +10,6 @@ import { protectedServerActionProcedure } from "@/server/trpc"
import { editProfileSchema } from "@/components/Forms/Edit/Profile/schema"
import { getIntl } from "@/i18n"
import { getFriendsMembership } from "@/utils/user"
import { phoneValidator } from "@/utils/zod/phoneValidator"
import { Status } from "@/types/components/myPages/myProfile/edit"
@@ -142,9 +141,8 @@ export const editProfile = protectedServerActionProcedure
status: Status.success,
}
} else {
const membership = getFriendsMembership(profile.memberships)
console.log(
`[edit profile: ${membership?.membershipNumber}] body keys: ${JSON.stringify(Object.keys(body))}`
`[edit profile: ${profile.membershipNumber}] body keys: ${JSON.stringify(Object.keys(body))}`
)
}

View File

@@ -17,7 +17,7 @@ export default async function SASxScandicLinkPage({
if (!profile) return null
const eurobonusMembership = getEurobonusMembership(profile.memberships)
const eurobonusMembership = getEurobonusMembership(profile.loyalty)
if (eurobonusMembership) {
redirect(`/${params.lang}/sas-x-scandic/error?errorCode=alreadyLinked`)

View File

@@ -10,7 +10,7 @@ import type { UserProps } from "@/types/components/myPages/user"
export default async function ExpiringPoints({ user }: UserProps) {
const intl = await getIntl()
const membership = getFriendsMembership(user.memberships)
const membership = getFriendsMembership(user.loyalty)
if (!membership || !membership.pointsToExpire) {
// TODO: handle this case?

View File

@@ -12,7 +12,7 @@ import type { UserProps } from "@/types/components/myPages/user"
export default async function Points({ user }: UserProps) {
const intl = await getIntl()
const membership = getFriendsMembership(user.memberships)
const membership = getFriendsMembership(user.loyalty)
const nextLevel =
membership?.nextLevel && MembershipLevelEnum[membership.nextLevel]

View File

@@ -5,7 +5,10 @@ import DiamondAddIcon from "@scandic-hotels/design-system/Icons/DiamondAddIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
import {
SAS_EUROBONUS_TIER_TO_NAME_MAP,
TIER_TO_FRIEND_MAP,
} from "@/constants/membershipLevels"
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
@@ -14,17 +17,13 @@ import SectionHeader from "@/components/Section/Header"
import SectionLink from "@/components/Section/Link"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import { getIntl } from "@/i18n"
import {
getEurobonusMembership,
getFriendsMembership,
scandicMemberships,
} from "@/utils/user"
import { getEurobonusMembership } from "@/utils/user"
import { UnlinkSAS } from "./UnlinkSAS"
import styles from "./linkedAccounts.module.css"
import type { Membership } from "@/types/user"
import type { UserLoyalty } from "@/types/user"
type Props = {
title?: string
@@ -78,20 +77,20 @@ async function MatchedAccountInfo() {
const intl = await getIntl()
const eurobonusMembership = getEurobonusMembership(user.memberships)
const eurobonusMembership = getEurobonusMembership(user.loyalty)
const friendsMembership = user.membership
if (!eurobonusMembership || !friendsMembership) {
return null
}
const sasLevelName = eurobonusMembership.membershipLevel || "-"
const sasLevelName = SAS_EUROBONUS_TIER_TO_NAME_MAP[eurobonusMembership.tier]
const sasMembershipNumber = eurobonusMembership.membershipNumber
const sasTierExpirationDate = eurobonusMembership.tierExpirationDate
const sasTierExpirationDate = eurobonusMembership.tierExpires
const scandicLevelName = TIER_TO_FRIEND_MAP[friendsMembership.membershipLevel]
const scandicExpirationDate = friendsMembership.tierExpirationDate
const matchState = calculateMatchState(user.memberships)
const matchState = calculateMatchState(user.loyalty)
return (
<section className={styles.matchedAccountSection}>
@@ -278,30 +277,12 @@ function Label({ children }: { children: ReactNode }) {
}
type MatchState = "boostedBySAS" | "boostedByScandic" | "noBoost"
function calculateMatchState(memberships: Membership[]): MatchState {
const eurobonusMembership = getEurobonusMembership(memberships)
const friendsMembership = getFriendsMembership(memberships)
const nativeMembership = memberships.find(
(x) => x.membershipType === scandicMemberships.scandic_native_tiers
)
function calculateMatchState(loyalty: UserLoyalty): MatchState {
if (!loyalty.tierBoostedBy) return "noBoost"
if (loyalty.tierBoostedBy === "SAS_EB") return "boostedBySAS"
if (!eurobonusMembership || !friendsMembership || !nativeMembership) {
return "noBoost"
}
const nativeLevel = nativeMembership.membershipLevel
const friendsLevel = friendsMembership.membershipLevel
if (nativeLevel !== friendsLevel) {
return "boostedBySAS"
}
// TODO check if SAS have been boosted by Scandic when API is available
const isBoostedByScandic = false
if (isBoostedByScandic) {
return "boostedByScandic"
}
// const eurobonusMembership = getEurobonusMembership(loyalty)
// if (eurobonusMembership.boostedByScandic) return "boostedByScandic"
return "noBoost"
}

View File

@@ -11,7 +11,7 @@ import SkeletonShimmer from "@/components/SkeletonShimmer"
import Body from "@/components/TempDesignSystem/Text/Body"
import useClickOutside from "@/hooks/useClickOutside"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import { type FriendsMembership, getInitials } from "@/utils/user"
import { getInitials } from "@/utils/user"
import Avatar from "../Avatar"
import MainMenuButton from "../MainMenuButton"
@@ -20,7 +20,7 @@ import MyPagesMenuContent, { useMyPagesNavigation } from "../MyPagesMenuContent"
import styles from "./myPagesMenu.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { User } from "@/types/user"
import type { FriendsMembership,User } from "@/types/user"
import type { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output"
export type MyPagesMenuProps = {

View File

@@ -127,8 +127,19 @@ const user: SafeUser = {
firstName: "",
language: undefined,
lastName: "",
membershipNumber: "",
membership: undefined,
memberships: [],
loyalty: {
memberships: [],
pointExpirations: [],
points: {
earned: 0,
spent: 0,
spendable: 0,
},
tier: "L1",
tierExpires: "",
},
name: "",
phoneNumber: undefined,
profileId: "",

View File

@@ -16,7 +16,7 @@ export default async function SidebarMyPages() {
const intl = await getIntl()
const profile = await getProfileSafely()
const eurobonusMembership = profile
? getEurobonusMembership(profile.memberships)
? getEurobonusMembership(profile.loyalty)
: null
return (

View File

@@ -1,3 +1,5 @@
import type { EurobonusTier } from "@/types/user"
export enum membershipLevels {
L1 = 1,
L2 = 2,
@@ -32,3 +34,11 @@ export const TIER_TO_FRIEND_MAP: Record<MembershipLevelEnum, string> = {
}
export type MembershipLevel = keyof typeof MembershipLevelEnum
export const SAS_EUROBONUS_TIER_TO_NAME_MAP: Record<EurobonusTier, string> = {
EBB: "Basic",
EBS: "Silver",
EBG: "Gold",
EBD: "Diamond",
EBP: "Pandion",
}

View File

@@ -207,6 +207,18 @@ export namespace endpoints {
}
}
}
export namespace v2 {
const version = "v2"
/**
* profile (Swagger)
* https://tstapi.scandichotels.com/profile/swagger/v2/index.html
*/
export namespace Profile {
export const profile = `${base.path.profile}/${version}/${base.enitity.Profile}`
}
}
}
export type Endpoint = string

View File

@@ -6,7 +6,6 @@ import { getVerifiedUser } from "@/server/routers/user/query"
import { router, safeProtectedServiceProcedure } from "@/server/trpc"
import { isValidSession } from "@/utils/session"
import { getFriendsMembership } from "@/utils/user"
import {
addPackageInput,
@@ -85,8 +84,7 @@ async function getMembershipNumber(
return undefined
}
const membership = getFriendsMembership(verifiedUser.data.memberships)
return membership?.membershipNumber
return verifiedUser.data.membershipNumber
}
export const bookingMutationRouter = router({

View File

@@ -7,20 +7,20 @@ import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { getEurobonusMembership } from "@/utils/user"
import type { Membership } from "@/types/user"
import type { UserLoyalty } from "@/types/user"
import type { Lang } from "@/constants/languages"
import type { MyPagesLink } from "./MyPagesLink"
export const getPrimaryLinks = cache(
async ({
lang,
memberships,
userLoyalty,
}: {
lang: Lang
memberships: Membership[]
userLoyalty: UserLoyalty
}): Promise<MyPagesLink[]> => {
const intl = await getIntl()
const showSASLink = isScandicXSASActive(memberships)
const showSASLink = isScandicXSASActive(userLoyalty)
const [showTeamMemberLink] = await safeTry(showTeamMemberCard())
const menuItems: MyPagesLink[] = [
@@ -66,8 +66,8 @@ export const getPrimaryLinks = cache(
}
)
const isScandicXSASActive = (memberships: Membership[]) => {
const eurobonusMembership = getEurobonusMembership(memberships)
const isScandicXSASActive = (loyalty: UserLoyalty) => {
const eurobonusMembership = getEurobonusMembership(loyalty)
const isLinked = Boolean(eurobonusMembership)
return env.SAS_ENABLED && isLinked

View File

@@ -45,7 +45,7 @@ export const myPagesNavigation = safeProtectedProcedure
}
const [primaryLinks, secondaryLinks] = await Promise.all([
getPrimaryLinks({ lang, memberships: user.data.memberships }),
getPrimaryLinks({ lang, userLoyalty: user.data.loyalty }),
getSecondaryLinks({ lang }),
])

View File

@@ -2,53 +2,85 @@ import { z } from "zod"
import { countriesMap } from "@/constants/countries"
import { getFriendsMembership, scandicMemberships } from "@/utils/user"
import { getFriendsMembership } from "@/utils/user"
import { imageSchema } from "../hotels/schemas/image"
const scandicFriendsTier = z.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"])
const sasEurobonusTier = z.enum(["EBB", "EBS", "EBG", "EBD", "EBP"])
const commonMembershipSchema = z.object({
currentPoints: z.number().optional(),
expirationDate: z.string().optional(),
membershipLevel: z.string().optional(),
nextLevel: z.string().optional(),
nightsToTopTier: z.number().optional(),
pointsExpiryDate: z.string().optional(),
pointsRequiredToNextlevel: z.number().optional(),
pointsToExpire: z.number().optional(),
tierExpirationDate: z.string().optional(),
membershipNumber: z.string(),
tierExpires: z.string(),
memberSince: z.string().nullish(),
})
const toLowerCaseString = z.string().transform((s) => s.toLowerCase())
const membershipType = (membershipType: scandicMemberships) =>
toLowerCaseString
// The memberships enum is in lower case so this makes sure it will match regardless of casing in the API response.
.pipe(z.literal(membershipType))
const friendsMembershipSchema = z
// This prevents validation errors if the API returns an unhandled membership type
const otherMembershipSchema = z
.object({
membershipType: membershipType(scandicMemberships.guestpr),
membershipNumber: z.string(),
memberSince: z.string(),
// This ensures that `type` won't widen into "string", losing the literal types, when used in a union
type: z.string().refine((val): val is string & {} => true),
})
.merge(commonMembershipSchema)
const otherMembershipSchema = z
export const sasMembershipSchema = z
.object({
membershipType: toLowerCaseString,
membershipNumber: z.string().optional(),
memberSince: z.string().optional(),
type: z.literal("SAS_EB"),
tier: sasEurobonusTier,
nextTier: sasEurobonusTier.nullish(),
})
.merge(commonMembershipSchema)
export const friendsMembershipSchema = z
.object({
type: z.literal("SCANDIC_NATIVE"),
tier: scandicFriendsTier,
nextTier: scandicFriendsTier.nullish(),
pointsToNextTier: z.number().nullish(),
nightsToTopTier: z.number().nullish(),
})
.merge(commonMembershipSchema)
export const membershipSchema = z.union([
friendsMembershipSchema,
sasMembershipSchema,
otherMembershipSchema,
])
const pointExpirationSchema = z.object({
points: z.number().int(),
expires: z.string(),
})
export const userLoyaltySchema = z.object({
memberships: z.array(membershipSchema),
points: z.object({
spendable: z.number().int(),
earned: z.number().int(),
spent: z.number().int(),
}),
tier: scandicFriendsTier,
tierExpires: z.string(),
tierBoostedBy: z.string().nullish(),
pointExpirations: z.array(pointExpirationSchema),
})
export const getUserSchema = z
.object({
data: z.object({
attributes: z.object({
dateOfBirth: z.string().optional().default("1900-01-01"),
email: z.string().email(),
firstName: z.string(),
language: z
.string()
// Preserve Profile v1 formatting for now so it matches ApiLang enum
.transform((s) => s.charAt(0).toUpperCase() + s.slice(1))
.optional(),
lastName: z.string(),
phoneNumber: z.string().optional(),
profileId: z.string(),
membershipNumber: z.string(),
address: z
.object({
city: z.string().optional(),
@@ -59,14 +91,7 @@ export const getUserSchema = z
})
.optional()
.nullable(),
dateOfBirth: z.string().optional().default("1900-01-01"),
email: z.string().email(),
firstName: z.string(),
language: z.string().optional(),
lastName: z.string(),
memberships: z.array(membershipSchema),
phoneNumber: z.string().optional(),
profileId: z.string(),
loyalty: userLoyaltySchema,
}),
type: z.string(),
}),
@@ -74,7 +99,7 @@ export const getUserSchema = z
.transform((apiResponse) => {
return {
...apiResponse.data.attributes,
membership: getFriendsMembership(apiResponse.data.attributes.memberships),
membership: getFriendsMembership(apiResponse.data.attributes.loyalty),
name: `${apiResponse.data.attributes.firstName} ${apiResponse.data.attributes.lastName}`,
}
})
@@ -224,16 +249,6 @@ export const creditCardsSchema = z.object({
data: z.array(creditCardSchema),
})
export const getMembershipCardsSchema = z.array(
z.object({
currentPoints: z.number(),
expirationDate: z.string(),
membershipNumber: z.string(),
memberSince: z.string(),
membershipType: z.string(),
})
)
export const initiateSaveCardSchema = z.object({
data: z.object({
attribute: z.object({

View File

@@ -23,7 +23,6 @@ import {
import {
creditCardsSchema,
getFriendTransactionsSchema,
getMembershipCardsSchema,
getStaysSchema,
getUserSchema,
} from "./output"
@@ -37,7 +36,6 @@ import type {
} 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")
@@ -46,10 +44,6 @@ 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")
@@ -95,7 +89,7 @@ export const getVerifiedUser = cache(
}
getVerifiedUserCounter.add(1)
console.info("api.user.profile getVerifiedUser start", JSON.stringify({}))
const apiResponse = await api.get(api.endpoints.v1.Profile.profile, {
const apiResponse = await api.get(api.endpoints.v2.Profile.profile, {
headers: {
Authorization: `Bearer ${session.token.access_token}`,
},
@@ -186,8 +180,9 @@ export function parsedUser(data: User, isMFA: boolean) {
firstName: data.firstName,
language: data.language,
lastName: data.lastName,
membership: getFriendsMembership(data.memberships),
memberships: data.memberships,
membershipNumber: data.membershipNumber,
membership: getFriendsMembership(data.loyalty),
loyalty: data.loyalty,
name: `${data.firstName} ${data.lastName}`,
phoneNumber: data.phoneNumber,
profileId: data.profileId,
@@ -343,7 +338,7 @@ export const userQueryRouter = router({
return null
}
const membershipLevel = getFriendsMembership(verifiedData.data.memberships)
const membershipLevel = getFriendsMembership(verifiedData.data.loyalty)
return membershipLevel
}),
safeMembershipLevel: safeProtectedProcedure.query(async function ({ ctx }) {
@@ -356,7 +351,7 @@ export const userQueryRouter = router({
return null
}
const membershipLevel = getFriendsMembership(verifiedData.data.memberships)
const membershipLevel = getFriendsMembership(verifiedData.data.loyalty)
return membershipLevel
}),
userTrackingInfo: safeProtectedProcedure.query(async function ({ ctx }) {
@@ -429,14 +424,14 @@ export const userQueryRouter = router({
getPreviousStaysSuccessCounter.add(1)
console.info("api.booking.stays.past success", JSON.stringify({}))
const membership = getFriendsMembership(verifiedUserData.data.memberships)
const membership = getFriendsMembership(verifiedUserData.data.loyalty)
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,
memberLevel: membership?.membershipLevel,
noOfNightsStayed: verifiedPreviousStaysData.data.links?.totalCount ?? 0,
totalPointsAvailableToSpend: membership?.currentPoints,
loginAction: "login success",
@@ -795,23 +790,6 @@ export const userQueryRouter = router({
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)
return getMembershipCards(userData.data.loyalty)
}),
})

View File

@@ -1,5 +1,5 @@
export type PointsColumnProps = {
title: string
subtitle?: string
value?: number
value?: number | null
}

View File

@@ -3,9 +3,12 @@ import type { z } from "zod"
import type { RouterOutput } from "@/lib/trpc/client"
import type {
creditCardSchema,
friendsMembershipSchema,
getUserSchema,
membershipSchema,
sasMembershipSchema,
userLoyaltySchema,
} from "@/server/routers/user/output"
import type { getFriendsMembership } from "@/utils/user"
/**
* All extended field needs to be added by API team to response or
@@ -17,6 +20,12 @@ export type SafeUser = RouterOutput["user"]["getSafely"]
export type CreditCard = z.output<typeof creditCardSchema>
export type Membership = z.output<typeof membershipSchema>
export type UserLoyalty = z.output<typeof userLoyaltySchema>
export type Memberships = Membership[]
export type Membership = UserLoyalty["memberships"][number]
export type NativeFriendsMembership = z.output<typeof friendsMembershipSchema>
export type EurobonusMembership = z.output<typeof sasMembershipSchema>
export type FriendsMembership = ReturnType<typeof getFriendsMembership>
export type EurobonusTier = EurobonusMembership["tier"]

View File

@@ -3,50 +3,78 @@ import {
MembershipLevelEnum,
} from "@/constants/membershipLevels"
import type { z } from "zod"
import type {
EurobonusMembership,
Membership,
NativeFriendsMembership,
User,
UserLoyalty,
} from "@/types/user"
import type { Membership, Memberships, User } from "@/types/user"
import type { getMembershipCardsSchema } from "@/server/routers/user/output"
export enum scandicMemberships {
guestpr = "guestpr",
scandicfriends = "scandicfriend's",
sas_eb = "sas_eb",
scandic_native_tiers = "scandic_native_tiers",
export enum scandicMembershipTypes {
SCANDIC_NATIVE = "SCANDIC_NATIVE",
SAS_EB = "SAS_EB",
}
export function getFriendsMembership(memberships: Memberships) {
return memberships?.find(
(membership) =>
membership.membershipType.toLowerCase() === scandicMemberships.guestpr
) as FriendsMembership | undefined
export function isScandicNativeMembership(
membership: Membership
): membership is NativeFriendsMembership {
return membership.type === scandicMembershipTypes.SCANDIC_NATIVE
}
export type FriendsMembership = Omit<
NonNullable<Membership>,
"membershipLevel" | "nextLevel"
> & {
membershipLevel: MembershipLevel
nextLevel: MembershipLevel
}
export function getFriendsMembership(userLoyalty: UserLoyalty) {
const { memberships, ...loyalty } = userLoyalty
export function getEurobonusMembership(memberships: Memberships) {
return memberships?.find(
(membership) =>
membership.membershipType.toLowerCase() === scandicMemberships.sas_eb
)
}
const friendsMembership = memberships.find(isScandicNativeMembership)
export function getMembershipCards(
memberships: z.infer<typeof getMembershipCardsSchema>
) {
return memberships.filter(function (membership) {
return (
membership.membershipType.toLowerCase() !== scandicMemberships.guestpr &&
membership.membershipType.toLowerCase() !==
scandicMemberships.scandicfriends
if (!friendsMembership) return undefined
const pointExpiration = loyalty.pointExpirations
.sort(
(a, b) => new Date(a.expires).getTime() - new Date(b.expires).getTime()
)
})
.at(0)
// Map to the same format that was used with Profile V1 to avoid larger changes for now.
const result = {
membershipType: friendsMembership.type,
membershipNumber: friendsMembership.membershipNumber,
membershipLevel: loyalty.tier,
nextLevel: friendsMembership.nextTier,
currentPoints: loyalty.points.spendable,
expirationDate: loyalty.tierExpires,
nightsToTopTier: friendsMembership.nightsToTopTier,
pointsRequiredToNextlevel: friendsMembership.pointsToNextTier,
tierExpirationDate: loyalty.tierExpires,
pointsExpiryDate: pointExpiration?.expires,
pointsToExpire: pointExpiration?.points,
memberSince: friendsMembership.memberSince,
}
return result
}
function isEurobonusMembership(
membership: Membership
): membership is EurobonusMembership {
return membership.type === scandicMembershipTypes.SAS_EB
}
export function getEurobonusMembership(loyalty: UserLoyalty) {
return loyalty.memberships?.find(isEurobonusMembership)
}
export function getMembershipCards(userLoyalty: UserLoyalty) {
return userLoyalty.memberships
.filter(
(membership) => membership.type !== scandicMembershipTypes.SCANDIC_NATIVE
)
.map((membership) => ({
currentPoints: 0, // We only have points for Friends so we can't set this for now
expirationDate: membership.tierExpires,
membershipNumber: membership.membershipNumber,
membershipType: membership.type,
memberSince: membership.memberSince,
}))
}
export function isHighestMembership(