Merged in feat/sw-2867-move-user-router-to-trpc-package (pull request #2428)

Move user router to trpc package

* Move more schemas in hotel router

* Fix deps

* fix getNonContentstackUrls

* Fix import error

* Fix entry error handling

* Fix generateMetadata metrics

* Fix alertType enum

* Fix duplicated types

* lint:fix

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package

* Fix broken imports

* Move booking router to trpc package

* Move partners router to trpc package

* Move autocomplete router to trpc package

* Move booking router to trpc package

* Remove translations from My Pages navigation trpc procedure

* Move navigation router to trpc package

* Move user router to trpc package

* Merge branch 'master' into feat/sw-2862-move-booking-router-to-trpc-package

* Merge branch 'feat/sw-2862-move-booking-router-to-trpc-package' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'master' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'master' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'master' into feat/sw-2865-move-navigation-router-to-trpc-package

* Merge branch 'feat/sw-2865-move-navigation-router-to-trpc-package' into feat/sw-2867-move-user-router-to-trpc-package

* Merge branch 'master' into feat/sw-2867-move-user-router-to-trpc-package


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-27 07:07:49 +00:00
parent 00bcdaaa28
commit 01ca2b4897
49 changed files with 609 additions and 562 deletions

View File

@@ -1,13 +1,17 @@
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
import { isLangLive } from "../lib/DUPLICATED/isLangLive"
import type { Lang } from "@scandic-hotels/common/constants/language"
/*
* ⚠️ Remember to also add environment variables to the corresponding config in sites that uses this package. ⚠️
*/
const TWENTYFOUR_HOURS = 24 * 60 * 60
export const env = createEnv({
const _env = createEnv({
/**
* Due to t3-env only checking typeof window === "undefined"
* and Netlify running Deno, window is never "undefined"
@@ -49,6 +53,18 @@ export const env = createEnv({
.refine((s) => s === "true" || s === "false")
.transform((s) => s === "true")
.default("false"),
/**
* Include the languages that should be hidden for the next release
* Should be in the format of "en,da,de,fi,no,sv" or empty
*/
NEW_SITE_LIVE_FOR_LANGS: z
.string()
.regex(/^([a-z]{2},)*([a-z]{2}){0,1}$/)
.transform((val) => {
return val.split(",")
})
.default(""),
SALESFORCE_PREFERENCE_BASE_URL: z.string(),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -70,5 +86,12 @@ export const env = createEnv({
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
PUBLIC_URL: process.env.NEXT_PUBLIC_PUBLIC_URL,
PRINT_QUERY: process.env.PRINT_QUERY,
NEW_SITE_LIVE_FOR_LANGS: process.env.NEXT_PUBLIC_NEW_SITE_LIVE_FOR_LANGS,
SALESFORCE_PREFERENCE_BASE_URL: process.env.SALESFORCE_PREFERENCE_BASE_URL,
},
})
export const env = {
..._env,
isLangLive: (lang: Lang) => isLangLive(lang, _env.NEW_SITE_LIVE_FOR_LANGS),
} as const

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest"
import { Lang } from "@scandic-hotels/common/constants/language"
import { isLangLive } from "./isLangLive"
describe("hideForNextRelease", () => {
it("should return true if en is part of live languages", () => {
expect(isLangLive(Lang.en, ["en", "sv"])).toBe(true)
expect(isLangLive(Lang.en, ["en"])).toBe(true)
})
it("should return false if en is not part of live languages", () => {
expect(isLangLive(Lang.en, [])).toBe(false)
expect(isLangLive(Lang.en, ["sv"])).toBe(false)
expect(isLangLive(Lang.en, ["sv,fi"])).toBe(false)
})
})

View File

@@ -0,0 +1,5 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
export function isLangLive(lang: Lang, liveLangs: string[]): boolean {
return liveLangs.includes(lang)
}

View File

@@ -0,0 +1,15 @@
export namespace Transactions {
export const enum rewardType {
stay = "stay",
rewardNight = "rewardnight",
enrollment = "enrollment",
expired = "expired",
redgift = "redgift",
ancillary = "ancillary",
pointShop = "pointshop",
tui_points = "tui_points",
mastercard_points = "mastercard_points",
stayAdj = "stay/adj",
othersAdj = "others/adj",
}
}

View File

@@ -1,4 +1,7 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { MembershipLevel } from "@scandic-hotels/common/constants/membershipLevels"
import type { LoginType } from "../types/loginType"
export type TrackingPageData = {
pageId: string
@@ -25,3 +28,19 @@ type TrackingSDKChannel =
| "hotels"
| "homepage"
| "campaign-overview-page"
export type TrackingUserData =
| {
loginStatus: "logged in"
loginType?: LoginType
memberId?: string
membershipNumber?: string
memberLevel?: MembershipLevel
noOfNightsStayed?: number
totalPointsAvailableToSpend?: number
loginAction?: "login success"
}
| {
loginStatus: "Non-logged in"
}
| { loginStatus: "Error" }

View File

@@ -56,3 +56,17 @@ function isEurobonusMembership(
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,
}))
}

View File

@@ -0,0 +1,6 @@
import { mergeRouters } from "@scandic-hotels/trpc"
import { userMutationRouter } from "./mutation"
import { userQueryRouter } from "./query"
export const userRouter = mergeRouters(userQueryRouter, userMutationRouter)

View File

@@ -0,0 +1,66 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { signUpSchema } from "./schemas"
// Query
export const userTrackingInput = z.object({
lang: z.nativeEnum(Lang).optional(),
})
export const staysInput = z
.object({
cursor: z
.number()
.optional()
.transform((num) => (num ? String(num) : undefined)),
limit: z.number().min(0).default(6),
lang: z.nativeEnum(Lang).optional(),
})
.default({})
export const friendTransactionsInput = z
.object({
limit: z.number().int().positive(),
page: z.number().int().positive(),
lang: z.nativeEnum(Lang).optional(),
})
.default({ limit: 5, page: 1 })
// Mutation
export const addCreditCardInput = z.object({
language: z.string(),
})
export const deleteCreditCardInput = z.object({
creditCardId: z.string(),
})
export const saveCreditCardInput = z.object({
transactionId: z.string(),
merchantId: z.string().optional(),
})
export const signupInput = signUpSchema
.extend({
language: z.nativeEnum(Lang),
})
.omit({ termsAccepted: true })
.transform((data) => ({
...data,
phoneNumber: data.phoneNumber.replace(/\s+/g, ""),
address: {
...data.address,
city: "",
country: "",
streetAddress: "",
},
}))
export const getSavedPaymentCardsInput = z.object({
supportedCards: z.array(z.string()),
})
export type GetSavedPaymentCardsInput = z.input<
typeof getSavedPaymentCardsInput
>

View File

@@ -0,0 +1,208 @@
import { signupVerify } from "@scandic-hotels/common/constants/routes/signup"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { env } from "../../../env/server"
import { router } from "../.."
import * as api from "../../api"
import { serverErrorByStatus } from "../../errors"
import { protectedProcedure, serviceProcedure } from "../../procedures"
import {
addCreditCardInput,
deleteCreditCardInput,
saveCreditCardInput,
signupInput,
} from "./input"
import { initiateSaveCardSchema, subscriberIdSchema } from "./output"
export const userMutationRouter = router({
creditCard: router({
add: protectedProcedure.input(addCreditCardInput).mutation(async function ({
ctx,
input,
}) {
console.info(
"api.user.creditCard.add start",
JSON.stringify({ query: { language: input.language } })
)
const apiResponse = await api.post(
api.endpoints.v1.Profile.CreditCards.initiateSaveCard,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
body: {
language: input.language,
mobileToken: false,
redirectUrl: `api/web/add-card-callback/${input.language}`,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
console.error(
"api.user.creditCard.add error",
JSON.stringify({
query: { language: input.language },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = initiateSaveCardSchema.safeParse(apiJson)
if (!verifiedData.success) {
console.error(
"api.user.creditCard.add validation error",
JSON.stringify({
query: { language: input.language },
error: verifiedData.error,
})
)
return null
}
console.info(
"api.user.creditCard.add success",
JSON.stringify({ query: { language: input.language } })
)
return verifiedData.data.data
}),
save: protectedProcedure
.input(saveCreditCardInput)
.mutation(async function ({ ctx, input }) {
console.info("api.user.creditCard.save start", JSON.stringify({}))
const apiResponse = await api.post(
api.endpoints.v1.Profile.CreditCards.transaction(input.transactionId),
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
console.error(
"api.user.creditCard.save error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return false
}
console.info("api.user.creditCard.save success", JSON.stringify({}))
return true
}),
delete: protectedProcedure
.input(deleteCreditCardInput)
.mutation(async function ({ ctx, input }) {
console.info(
"api.user.creditCard.delete start",
JSON.stringify({ query: {} })
)
const apiResponse = await api.remove(
api.endpoints.v1.Profile.CreditCards.deleteCreditCard(
input.creditCardId
),
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
console.error(
"api.user.creditCard.delete error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
query: {},
})
)
return false
}
console.info("api.user.creditCard.delete success", JSON.stringify({}))
return true
}),
}),
generatePreferencesLink: protectedProcedure.mutation(async function ({
ctx,
}) {
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}`,
},
})
if (!apiResponse.ok) {
await metricsGeneratePreferencesLink.httpError(apiResponse)
return null
}
const data = await apiResponse.json()
const validatedData = subscriberIdSchema.safeParse(data)
if (!validatedData.success) {
metricsGeneratePreferencesLink.validationError(validatedData.error)
return null
}
const preferencesLink = new URL(env.SALESFORCE_PREFERENCE_BASE_URL)
preferencesLink.searchParams.set("subKey", validatedData.data.subscriberId)
metricsGeneratePreferencesLink.success()
return preferencesLink.toString()
}),
signup: serviceProcedure.input(signupInput).mutation(async function ({
ctx,
input,
}) {
const signupCounter = createCounter("trpc.user", "signup")
const metricsSignup = signupCounter.init()
const apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
body: input,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
})
if (!apiResponse.ok) {
await metricsSignup.httpError(apiResponse)
const text = await apiResponse.text()
throw serverErrorByStatus(apiResponse.status, text)
}
metricsSignup.success()
return {
success: true,
redirectUrl: signupVerify[input.language],
}
}),
})

View File

@@ -1,6 +1,7 @@
import { z } from "zod"
import { countriesMap } from "../../constants/countries"
import { imageSchema } from "../../routers/hotels/schemas/image"
import { getFriendsMembership } from "./helpers"
const scandicFriendsTier = z.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"])
@@ -143,3 +144,134 @@ export const creditCardSchema = z
export const creditCardsSchema = z.object({
data: z.array(creditCardSchema),
})
// Schema is the same for upcoming and previous stays endpoints
export const getStaysSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
hotelOperaId: z.string(),
hotelInformation: z.object({
hotelContent: z.object({
images: imageSchema,
}),
hotelName: z.string(),
cityName: z.string().nullable(),
}),
confirmationNumber: z.string(),
checkinDate: z.string(),
checkoutDate: z.string(),
isWebAppOrigin: z.boolean(),
bookingUrl: z.string().default(""),
}),
relationships: z.object({
hotel: z.object({
links: z.object({
related: z.string().nullable().optional(),
}),
data: z.object({
id: z.string(),
type: z.string(),
}),
}),
}),
type: z.string(),
id: z.string(),
links: z.object({
self: z.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
}),
}),
})
),
links: z
.object({
self: z.string(),
offset: z.number(),
limit: z.number(),
totalCount: z.number(),
})
.optional()
.nullable(),
})
type GetStaysData = z.infer<typeof getStaysSchema>
export type Stay = GetStaysData["data"][number]
export const getFriendTransactionsSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
awardPoints: z.number().default(0),
checkinDate: z.string().default(""),
checkoutDate: z.string().default(""),
confirmationNumber: z.string().default(""),
hotelOperaId: z.string().default(""),
nights: z.number().default(1),
pointsCalculated: z.boolean().default(true),
transactionDate: z.string().default(""),
bookingUrl: z.string().default(""),
hotelInformation: z
.object({
city: z.string().default(""),
name: z.string().default(""),
hotelContent: z.object({
images: imageSchema,
}),
})
.optional(),
}),
relationships: z.object({
booking: z.object({
data: z.object({
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
}),
hotel: z
.object({
data: z.object({
id: z.string().default(""),
type: z.string().default(""),
}),
links: z.object({
related: z.string().default(""),
}),
})
.optional(),
}),
type: z.string().default(""),
})
),
links: z
.object({
self: z.string(),
})
.nullable(),
})
type GetFriendTransactionsData = z.infer<typeof getFriendTransactionsSchema>
export type FriendTransaction = GetFriendTransactionsData["data"][number]
export const initiateSaveCardSchema = z.object({
data: z.object({
attribute: z.object({
transactionId: z.string(),
link: z.string(),
mobileToken: z.string().optional(),
}),
type: z.string(),
}),
})
export const subscriberIdSchema = z.object({
subscriberId: z.string(),
})

View File

@@ -0,0 +1,418 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "@scandic-hotels/trpc"
import * as api from "@scandic-hotels/trpc/api"
import {
languageProtectedProcedure,
protectedProcedure,
safeProtectedProcedure,
} from "@scandic-hotels/trpc/procedures"
import {
getFriendsMembership,
getMembershipCards,
} from "@scandic-hotels/trpc/routers/user/helpers"
import { getVerifiedUser } from "@scandic-hotels/trpc/routers/user/utils"
import { toApiLang } from "@scandic-hotels/trpc/utils"
import { isValidSession } from "@scandic-hotels/trpc/utils/session"
import { Transactions } from "../../enums/transactions"
import {
friendTransactionsInput,
getSavedPaymentCardsInput,
staysInput,
userTrackingInput,
} from "./input"
import { getFriendTransactionsSchema } from "./output"
import {
getCreditCards,
getPreviousStays,
getUpcomingStays,
parsedUser,
updateStaysBookingUrl,
} from "./utils"
import type { LoginType } from "@scandic-hotels/trpc/types/loginType"
import type { TrackingUserData } from "../types"
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 &&
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 (!isValidSession(ctx.session)) {
return null
}
const data = await getVerifiedUser({ session: ctx.session })
if (!data || "error" in data) {
return null
}
return parsedUser(data.data, true)
}),
getWithExtendedPartnerData: safeProtectedProcedure.query(
async function getUser({ ctx }) {
if (!isValidSession(ctx.session)) {
return null
}
const data = await getVerifiedUser({
session: ctx.session,
includeExtendedPartnerData: true,
})
if (!data || "error" in data) {
return null
}
return parsedUser(data.data, true)
}
),
name: safeProtectedProcedure.query(async function ({ ctx }) {
if (!isValidSession(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 ||
!verifiedData.data.loyalty
) {
return null
}
const membershipLevel = getFriendsMembership(verifiedData.data.loyalty)
return membershipLevel
}),
safeMembershipLevel: safeProtectedProcedure.query(async function ({ ctx }) {
if (!isValidSession(ctx.session)) {
return null
}
const verifiedData = await getVerifiedUser({ session: ctx.session })
if (
!verifiedData ||
"error" in verifiedData ||
!verifiedData.data.loyalty
) {
return null
}
const membershipLevel = getFriendsMembership(verifiedData.data.loyalty)
return membershipLevel
}),
userTrackingInfo: safeProtectedProcedure
.input(userTrackingInput)
.query(async function ({ ctx, input }) {
const { lang } = input
const language = lang || ctx.lang
const userTrackingInfoCounter = createCounter("user", "userTrackingInfo")
const metricsUserTrackingInfo = userTrackingInfoCounter.init()
metricsUserTrackingInfo.start()
const notLoggedInUserTrackingData: TrackingUserData = {
loginStatus: "Non-logged in",
}
if (!isValidSession(ctx.session)) {
metricsUserTrackingInfo.success({
reason: "invalid session",
data: notLoggedInUserTrackingData,
})
return notLoggedInUserTrackingData
}
try {
const verifiedUserData = await getVerifiedUser({ session: ctx.session })
if (
!verifiedUserData ||
"error" in verifiedUserData ||
!verifiedUserData.data.loyalty
) {
metricsUserTrackingInfo.success({
reason: "invalid user data",
data: notLoggedInUserTrackingData,
})
return notLoggedInUserTrackingData
}
const previousStaysData = await getPreviousStays(
ctx.session.token.access_token,
1,
language
)
if (!previousStaysData) {
metricsUserTrackingInfo.success({
reason: "no previous stays data",
data: notLoggedInUserTrackingData,
})
return notLoggedInUserTrackingData
}
const membership = getFriendsMembership(verifiedUserData.data.loyalty)
const loggedInUserTrackingData: TrackingUserData = {
loginStatus: "logged in",
loginType: ctx.session.token.loginType as LoginType,
memberId: verifiedUserData.data.profileId,
membershipNumber: membership?.membershipNumber,
memberLevel: membership?.membershipLevel,
noOfNightsStayed: previousStaysData.links?.totalCount ?? 0,
totalPointsAvailableToSpend: membership?.currentPoints,
loginAction: "login success",
}
metricsUserTrackingInfo.success({
reason: "valid logged in",
data: loggedInUserTrackingData,
})
return loggedInUserTrackingData
} catch (error) {
metricsUserTrackingInfo.fail(error)
return notLoggedInUserTrackingData
}
}),
stays: router({
previous: languageProtectedProcedure
.input(staysInput)
.query(async ({ ctx, input }) => {
const { limit, cursor, lang } = input
const language = lang || ctx.lang
const data = await getPreviousStays(
ctx.session.token.access_token,
limit,
language,
cursor
)
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
.input(staysInput)
.query(async ({ ctx, input }) => {
const { limit, cursor, lang } = input
const language = lang || ctx.lang
const data = await getUpcomingStays(
ctx.session.token.access_token,
limit,
language,
cursor
)
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({
friendTransactions: languageProtectedProcedure
.input(friendTransactionsInput)
.query(async ({ ctx, input }) => {
const { limit, page, lang } = input
const friendTransactionsCounter = createCounter(
"trpc.user.transactions",
"friendTransactions"
)
const metricsFriendTransactions = friendTransactionsCounter.init({
limit,
page,
lang,
})
metricsFriendTransactions.start()
const language = lang ?? ctx.lang
const apiResponse = await api.get(
api.endpoints.v1.Profile.Transaction.friendTransactions,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
},
{
language: toApiLang(language),
}
)
if (!apiResponse.ok) {
await metricsFriendTransactions.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getFriendTransactionsSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsFriendTransactions.validationError(verifiedData.error)
return null
}
const updatedData = await updateStaysBookingUrl(
verifiedData.data.data,
ctx.session,
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)
const result = {
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),
},
}
metricsFriendTransactions.success()
return result
}),
}),
creditCards: protectedProcedure.query(async function ({ ctx }) {
return await getCreditCards({ session: ctx.session })
}),
safePaymentCards: safeProtectedProcedure
.input(getSavedPaymentCardsInput)
.query(async function ({ ctx, input }) {
if (!isValidSession(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 || !userData.data.loyalty) {
return null
}
return getMembershipCards(userData.data.loyalty)
}),
})

View File

@@ -0,0 +1,59 @@
import { z } from "zod"
import { passwordValidator } from "@scandic-hotels/common/utils/zod/passwordValidator"
import { phoneValidator } from "@scandic-hotels/common/utils/zod/phoneValidator"
export const signupErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
EMAIL_INVALID: "EMAIL_INVALID",
EMAIL_REQUIRED: "EMAIL_REQUIRED",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED",
PASSWORD_REQUIRED: "PASSWORD_REQUIRED",
TERMS_REQUIRED: "TERMS_REQUIRED",
ZIP_CODE_REQUIRED: "ZIP_CODE_REQUIRED",
ZIP_CODE_INVALID: "ZIP_CODE_INVALID",
} as const
export const signUpSchema = z.object({
firstName: z
.string()
.max(250)
.trim()
.min(1, signupErrors.FIRST_NAME_REQUIRED),
lastName: z.string().max(250).trim().min(1, signupErrors.LAST_NAME_REQUIRED),
email: z
.string()
.max(250)
.min(1, signupErrors.EMAIL_REQUIRED)
.email({ message: signupErrors.EMAIL_INVALID }),
phoneNumber: phoneValidator(
signupErrors.PHONE_REQUIRED,
signupErrors.PHONE_REQUESTED
),
phoneNumberCC: z.string(),
dateOfBirth: z.string().min(1, {
message: signupErrors.BIRTH_DATE_REQUIRED,
}),
address: z.object({
countryCode: z
.string({
required_error: signupErrors.COUNTRY_REQUIRED,
invalid_type_error: signupErrors.COUNTRY_REQUIRED,
})
.min(1, signupErrors.COUNTRY_REQUIRED),
zipCode: z
.string()
.min(1, signupErrors.ZIP_CODE_REQUIRED)
.regex(/^[A-Za-z0-9-\s]{1,9}$/g, signupErrors.ZIP_CODE_INVALID),
}),
password: passwordValidator(signupErrors.PASSWORD_REQUIRED),
termsAccepted: z
.boolean()
.refine((value) => value === true, signupErrors.TERMS_REQUIRED),
})
export type SignUpSchema = z.infer<typeof signUpSchema>

View File

@@ -0,0 +1,116 @@
{
"data": [
{
"attributes": {
"hotelOperaId": "216",
"confirmationNumber": "991646189",
"checkInDate": "2023-09-16",
"checkOutDate": "2023-09-17",
"transactionDate": "2023-04-18",
"nights": 1,
"awardPoints": 1863,
"pointsCalculated": true,
"hotelInformation": {
"hotelName": "Scandic Landvetter",
"city": "Stockholm",
"hotelContent": {
"images": {
"metaData": {
"title": "Lobby",
"altText": "lobby at scandic landvetter in gothenburg",
"altText_En": "lobby at scandic landvetter in gothenburg",
"copyRight": "Werner Nystrand"
},
"imageSizes": {
"tiny": "https://test3.scandichotels.com/imagevault/publishedmedia/1cz71gn106ej1mz7u4nr/Scandic-Landvetter-lobby-0013-2-vald.jpg",
"small": "https://test3.scandichotels.com/imagevault/publishedmedia/29ejr75mwp7riv63nz0x/Scandic-Landvetter-lobby-0013-2-vald.jpg",
"medium": "https://test3.scandichotels.com/imagevault/publishedmedia/bldh2liyfddkv74szp9v/Scandic-Landvetter-lobby-0013-2-vald.jpg",
"large": "https://test3.scandichotels.com/imagevault/publishedmedia/kbmpmkb714o028ufcgu4/Scandic-Landvetter-lobby-0013-2-vald.jpg"
}
}
}
}
},
"relationships": {
"hotel": {
"links": {
"related": "https://api-test.scandichotels.com/hotels/V0/fdea883a-8092-4604-8afb-032391a59009/hotels"
},
"data": {
"id": "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
"type": "hotels"
}
},
"booking": {
"links": {
"related": "https://api-test.scandichotels.com/booking/v1/bookings/991646189"
},
"data": {
"id": "991646189",
"type": "booking"
}
}
},
"type": "stay"
},
{
"attributes": {
"hotelOperaId": "216",
"confirmationNumber": "991646190",
"checkInDate": "2023-09-16",
"checkOutDate": "2023-09-17",
"transactionDate": "2023-04-18",
"nights": 1,
"awardPoints": 1863,
"pointsCalculated": true,
"hotelInformation": {
"hotelName": "Scandic Landvetter",
"city": "Stockholm",
"hotelContent": {
"images": {
"metaData": {
"title": "Lobby",
"altText": "lobby at scandic landvetter in gothenburg",
"altText_En": "lobby at scandic landvetter in gothenburg",
"copyRight": "Werner Nystrand"
},
"imageSizes": {
"tiny": "https://test3.scandichotels.com/imagevault/publishedmedia/1cz71gn106ej1mz7u4nr/Scandic-Landvetter-lobby-0013-2-vald.jpg",
"small": "https://test3.scandichotels.com/imagevault/publishedmedia/29ejr75mwp7riv63nz0x/Scandic-Landvetter-lobby-0013-2-vald.jpg",
"medium": "https://test3.scandichotels.com/imagevault/publishedmedia/bldh2liyfddkv74szp9v/Scandic-Landvetter-lobby-0013-2-vald.jpg",
"large": "https://test3.scandichotels.com/imagevault/publishedmedia/kbmpmkb714o028ufcgu4/Scandic-Landvetter-lobby-0013-2-vald.jpg"
}
}
}
}
},
"relationships": {
"hotel": {
"links": {
"related": "https://api-test.scandichotels.com/hotels/V0/fdea883a-8092-4604-8afb-032391a59009/hotels"
},
"data": {
"id": "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
"type": "hotels"
}
},
"booking": {
"links": {
"related": "https://api-test.scandichotels.com/booking/v1/bookings/991646189"
},
"data": {
"id": "991646189",
"type": "booking"
}
}
},
"type": "stay"
}
],
"links": {
"self": "https://api-test.scandichotels.com/profile/v1/transaction/friendTransactions?language=en&offset=1&limit=20",
"offset": 2,
"limit": 20,
"totalCount": 40
}
}

View File

@@ -1,12 +1,27 @@
import { myStay } from "@scandic-hotels/common/constants/routes/myStay"
import { dt } from "@scandic-hotels/common/dt"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as maskValue from "@scandic-hotels/common/utils/maskValue"
import { getCurrentWebUrl } from "@scandic-hotels/common/utils/url"
import { env } from "../../../env/server"
import * as api from "../../api"
import { countries } from "../../constants/countries"
import { cache } from "../../DUPLICATED/cache"
import { getFriendsMembership } from "../../routers/user/helpers"
import { creditCardsSchema } from "../../routers/user/output"
import { toApiLang } from "../../utils"
import { encrypt } from "../../utils/encryption"
import { isValidSession } from "../../utils/session"
import { getUserSchema } from "./output"
import { type FriendTransaction, getStaysSchema, type Stay } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
import type { Session } from "next-auth"
import type { User } from "../../types/user"
export async function getMembershipNumber(
session: Session | null
): Promise<string | undefined> {
@@ -91,3 +106,279 @@ export const getVerifiedUser = cache(
return verifiedData
}
)
export async function getPreviousStays(
accessToken: string,
limit: number = 10,
language: Lang,
cursor?: string
) {
const getPreviousStaysCounter = createCounter("user", "getPreviousStays")
const metricsGetPreviousStays = getPreviousStaysCounter.init({
limit,
cursor,
language,
})
metricsGetPreviousStays.start()
const params: Record<string, string> = {
limit: String(limit),
language: toApiLang(language),
}
if (cursor) {
params.offset = cursor
}
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.past,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetPreviousStays.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = getStaysSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetPreviousStays.validationError(verifiedData.error)
return null
}
metricsGetPreviousStays.success()
return verifiedData.data
}
export async function getUpcomingStays(
accessToken: string,
limit: number = 10,
language: Lang,
cursor?: string
) {
const getUpcomingStaysCounter = createCounter("user", "getUpcomingStays")
const metricsGetUpcomingStays = getUpcomingStaysCounter.init({
limit,
cursor,
language,
})
metricsGetUpcomingStays.start()
const params: Record<string, string> = {
limit: String(limit),
language: toApiLang(language),
}
if (cursor) {
params.offset = cursor
}
const apiResponse = await api.get(
api.endpoints.v1.Booking.Stays.future,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
params
)
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: data.loyalty ? getFriendsMembership(data.loyalty) : null,
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.isLangLive(lang)
? 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
}
export const myBookingPath: LangRoute = {
da: "/hotelreservation/min-booking",
de: "/hotelreservation/my-booking",
en: "/hotelreservation/my-booking",
fi: "/varaa-hotelli/varauksesi",
no: "/hotelreservation/my-booking",
sv: "/hotelreservation/din-bokning",
}