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:
24
packages/common/constants/routes/signup.ts
Normal file
24
packages/common/constants/routes/signup.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
|
||||
|
||||
export const signup: LangRoute = {
|
||||
en: "/en/scandic-friends/join",
|
||||
sv: "/sv/scandic-friends/bli-medlem",
|
||||
no: "/no/scandic-friends/registrer-deg",
|
||||
fi: "/fi/scandic-friends/liity-jaseneksi",
|
||||
da: "/da/scandic-friends/tilmeld-dig",
|
||||
de: "/de/scandic-friends/mitglied-werden",
|
||||
}
|
||||
|
||||
export const signupVerify: LangRoute = {
|
||||
en: `${signup.en}/verify`,
|
||||
sv: `${signup.sv}/verifiera`,
|
||||
no: `${signup.no}/bekreft`,
|
||||
fi: `${signup.fi}/vahvista`,
|
||||
da: `${signup.da}/bekraeft`,
|
||||
de: `${signup.de}/verifizieren`,
|
||||
}
|
||||
|
||||
export function isSignupPage(path: string): boolean {
|
||||
const signupPaths = [...Object.values(signup), ...Object.values(signupVerify)]
|
||||
return signupPaths.some((signupPath) => signupPath.includes(path))
|
||||
}
|
||||
@@ -22,9 +22,8 @@
|
||||
"./utils/languages": "./utils/languages.ts",
|
||||
"./utils/chunk": "./utils/chunk.ts",
|
||||
"./utils/isDefined": "./utils/isDefined.ts",
|
||||
"./utils/zod/stringValidator": "./utils/zod/stringValidator.ts",
|
||||
"./utils/zod/numberValidator": "./utils/zod/numberValidator.ts",
|
||||
"./utils/zod/arrayValidator": "./utils/zod/arrayValidator.ts",
|
||||
"./utils/maskValue": "./utils/maskValue.ts",
|
||||
"./utils/zod/*": "./utils/zod/*.ts",
|
||||
"./constants/language": "./constants/language.ts",
|
||||
"./constants/membershipLevels": "./constants/membershipLevels.ts",
|
||||
"./constants/paymentMethod": "./constants/paymentMethod.ts",
|
||||
|
||||
52
packages/common/utils/maskValue.ts
Normal file
52
packages/common/utils/maskValue.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
function maskAll(str: string) {
|
||||
return "*".repeat(str.length)
|
||||
}
|
||||
|
||||
function maskAllButFirstChar(str: string) {
|
||||
const first = str[0]
|
||||
const rest = str.substring(1)
|
||||
const restMasked = maskAll(rest)
|
||||
|
||||
return `${first}${restMasked}`
|
||||
}
|
||||
|
||||
function maskAllButLastTwoChar(str: string) {
|
||||
const lastTwo = str.slice(-2)
|
||||
const rest = str.substring(0, str.length - 2)
|
||||
const restMasked = maskAll(rest)
|
||||
|
||||
return `${restMasked}${lastTwo}`
|
||||
}
|
||||
|
||||
export function email(str: string) {
|
||||
const parts = str.split("@")
|
||||
|
||||
const aliasMasked = maskAllButFirstChar(parts[0])
|
||||
|
||||
if (parts[1]) {
|
||||
const domainParts = parts[1].split(".")
|
||||
if (domainParts.length > 1) {
|
||||
const domainTLD = domainParts.pop()
|
||||
const domainPartsMasked = domainParts
|
||||
.map((domainPart, i) => {
|
||||
return maskAllButFirstChar(domainPart)
|
||||
})
|
||||
.join(".")
|
||||
return `${aliasMasked}@${domainPartsMasked}.${domainTLD}`
|
||||
}
|
||||
}
|
||||
|
||||
return maskAllButFirstChar(str)
|
||||
}
|
||||
|
||||
export function phone(str: string) {
|
||||
return maskAllButLastTwoChar(str)
|
||||
}
|
||||
|
||||
export function text(str: string) {
|
||||
return maskAllButFirstChar(str)
|
||||
}
|
||||
|
||||
export function all(str: string) {
|
||||
return maskAll(str)
|
||||
}
|
||||
27
packages/common/utils/maskvalue.test.ts
Normal file
27
packages/common/utils/maskvalue.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "vitest"
|
||||
|
||||
import { all, email, phone, text } from "./maskValue"
|
||||
|
||||
describe("Mask value", () => {
|
||||
test("masks e-mails properly", () => {
|
||||
expect(email("test@example.com")).toBe("t***@e******.com")
|
||||
expect(email("test@sub.example.com")).toBe("t***@s**.e******.com")
|
||||
expect(email("test_no_atexample.com")).toBe("t********************")
|
||||
expect(email("test_no_dot@examplecom")).toBe("t*********************")
|
||||
expect(email("test_no_at_no_dot_com")).toBe("t********************")
|
||||
})
|
||||
|
||||
test("masks phone number properly", () => {
|
||||
expect(phone("0000000000")).toBe("********00")
|
||||
})
|
||||
|
||||
test("masks text strings properly", () => {
|
||||
expect(text("test")).toBe("t***")
|
||||
expect(text("test.with.dot")).toBe("t************")
|
||||
})
|
||||
|
||||
test("masks whole string properly", () => {
|
||||
expect(all("test")).toBe("****")
|
||||
expect(all("123jknasd@iajsd.c")).toBe("*****************")
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Lang } from "../constants/language"
|
||||
|
||||
export function removeMultipleSlashes(pathname: string) {
|
||||
return pathname.replaceAll(/\/\/+/g, "/")
|
||||
}
|
||||
@@ -9,3 +11,52 @@ export function removeTrailingSlash(pathname: string) {
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the TLD (top-level domain) for a given language.
|
||||
* @param lang - The language to get the TLD for
|
||||
* @returns The TLD for the given language
|
||||
*/
|
||||
export function getTldForLanguage(lang: Lang): string {
|
||||
switch (lang) {
|
||||
case Lang.sv:
|
||||
return "se"
|
||||
case Lang.no:
|
||||
return "no"
|
||||
case Lang.da:
|
||||
return "dk"
|
||||
case Lang.fi:
|
||||
return "fi"
|
||||
case Lang.de:
|
||||
return "de"
|
||||
default:
|
||||
return "com"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a URL with the correct TLD (top-level domain) based on lang, for current web.
|
||||
* @param params - Object containing path, lang, and baseUrl
|
||||
* @param params.path - The path to append to the URL
|
||||
* @param params.lang - The language to use for TLD
|
||||
* @param params.baseUrl - The base URL to use (e.g. https://www.scandichotels.com)
|
||||
* @returns The complete URL with language-specific TLD
|
||||
*/
|
||||
export function getCurrentWebUrl({
|
||||
path,
|
||||
lang,
|
||||
baseUrl = "https://www.scandichotels.com", // Fallback for ephemeral environments (e.g. deploy previews).
|
||||
}: {
|
||||
path: string
|
||||
lang: Lang
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
const tld = getTldForLanguage(lang)
|
||||
const url = new URL(path, baseUrl)
|
||||
|
||||
if (tld !== "com") {
|
||||
url.host = url.host.replace(".com", `.${tld}`)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
46
packages/common/utils/zod/passwordValidator.ts
Normal file
46
packages/common/utils/zod/passwordValidator.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const passwordValidators = {
|
||||
length: {
|
||||
matcher: (password: string) =>
|
||||
password.length >= 10 && password.length <= 40,
|
||||
message: "10 to 40 characters",
|
||||
},
|
||||
hasUppercase: {
|
||||
matcher: (password: string) => /[A-Z]/.test(password),
|
||||
message: "1 uppercase letter",
|
||||
},
|
||||
hasLowercase: {
|
||||
matcher: (password: string) => /[a-z]/.test(password),
|
||||
message: "1 lowercase letter",
|
||||
},
|
||||
hasNumber: {
|
||||
matcher: (password: string) => /[0-9]/.test(password),
|
||||
message: "1 number",
|
||||
},
|
||||
hasSpecialChar: {
|
||||
matcher: (password: string) =>
|
||||
/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/.test(password),
|
||||
message: "1 special character",
|
||||
},
|
||||
}
|
||||
|
||||
export const passwordValidator = (msg = "Required field") =>
|
||||
z
|
||||
.string()
|
||||
.min(1, msg)
|
||||
.refine(passwordValidators.length.matcher, {
|
||||
message: passwordValidators.length.message,
|
||||
})
|
||||
.refine(passwordValidators.hasUppercase.matcher, {
|
||||
message: passwordValidators.hasUppercase.message,
|
||||
})
|
||||
.refine(passwordValidators.hasLowercase.matcher, {
|
||||
message: passwordValidators.hasLowercase.message,
|
||||
})
|
||||
.refine(passwordValidators.hasNumber.matcher, {
|
||||
message: passwordValidators.hasNumber.message,
|
||||
})
|
||||
.refine(passwordValidators.hasSpecialChar.matcher, {
|
||||
message: passwordValidators.hasSpecialChar.message,
|
||||
})
|
||||
26
packages/common/utils/zod/phoneValidator.ts
Normal file
26
packages/common/utils/zod/phoneValidator.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const phoneErrors = {
|
||||
PHONE_NUMBER_TOO_SHORT: "PHONE_NUMBER_TOO_SHORT",
|
||||
PHONE_REQUESTED: "PHONE_REQUESTED",
|
||||
} as const
|
||||
|
||||
export function phoneValidator(
|
||||
msg = "Required field",
|
||||
invalidMsg = "Invalid type"
|
||||
) {
|
||||
return z
|
||||
.string({ invalid_type_error: invalidMsg, required_error: msg })
|
||||
.min(5, phoneErrors.PHONE_NUMBER_TOO_SHORT)
|
||||
.superRefine((value, ctx) => {
|
||||
if (value) {
|
||||
const containsAlphabeticChars = /[a-z]/gi.test(value)
|
||||
if (containsAlphabeticChars) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: phoneErrors.PHONE_REQUESTED,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
25
packages/trpc/env/server.ts
vendored
25
packages/trpc/env/server.ts
vendored
@@ -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
|
||||
|
||||
18
packages/trpc/lib/DUPLICATED/isLangLive.test.ts
Normal file
18
packages/trpc/lib/DUPLICATED/isLangLive.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
5
packages/trpc/lib/DUPLICATED/isLangLive.ts
Normal file
5
packages/trpc/lib/DUPLICATED/isLangLive.ts
Normal 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)
|
||||
}
|
||||
15
packages/trpc/lib/enums/transactions.ts
Normal file
15
packages/trpc/lib/enums/transactions.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
6
packages/trpc/lib/routers/user/index.ts
Normal file
6
packages/trpc/lib/routers/user/index.ts
Normal 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)
|
||||
66
packages/trpc/lib/routers/user/input.ts
Normal file
66
packages/trpc/lib/routers/user/input.ts
Normal 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
|
||||
>
|
||||
208
packages/trpc/lib/routers/user/mutation.ts
Normal file
208
packages/trpc/lib/routers/user/mutation.ts
Normal 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],
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
418
packages/trpc/lib/routers/user/query.ts
Normal file
418
packages/trpc/lib/routers/user/query.ts
Normal 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)
|
||||
}),
|
||||
})
|
||||
59
packages/trpc/lib/routers/user/schemas.ts
Normal file
59
packages/trpc/lib/routers/user/schemas.ts
Normal 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>
|
||||
116
packages/trpc/lib/routers/user/tempFriendTransactions.json
Normal file
116
packages/trpc/lib/routers/user/tempFriendTransactions.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user