Merged in chore/refactor-trpc-booking-routes (pull request #3510)

feat(BOOK-750): refactor booking endpoints

* WIP

* wip

* wip

* parse dates in UTC

* wip

* no more errors

* Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/refactor-trpc-booking-routes

* .

* cleanup

* import named z from zod

* fix(BOOK-750): updateBooking api endpoint expects dateOnly, we passed ISO date


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2026-02-02 14:28:14 +00:00
parent 8ac2c4ba22
commit 16cc26632e
44 changed files with 1621 additions and 1041 deletions

View File

@@ -1,4 +1,5 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
@@ -25,7 +26,7 @@ import ModifyContact from "../ModifyContact"
import styles from "./guestDetails.module.css"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import {
type ModifyContactSchema,
@@ -34,9 +35,9 @@ import {
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
import type { SafeUser } from "@/types/user"
interface GuestDetailsProps {
type GuestDetailsProps = {
refId: string
guest: Guest
guest: BookingConfirmation["booking"]["guest"]
isCancelled: boolean
user: SafeUser
}
@@ -76,6 +77,7 @@ export default function GuestDetails({
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
const isMemberBooking =
!!user?.membership?.membershipNumber &&
guest.membershipNumber === user?.membership?.membershipNumber
const updateGuest = trpc.booking.update.useMutation({
@@ -196,7 +198,7 @@ export default function GuestDetails({
{guest.firstName} {guest.lastName}
</p>
</Typography>
{isMemberBooking && user.membership && (
{isMemberBooking && user?.membership && (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.memberNumber} data-hj-suppress>
{intl.formatMessage(

View File

@@ -8,7 +8,7 @@ import accessBooking, {
} from "./accessBooking"
import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { SafeUser } from "@/types/user"
@@ -201,7 +201,7 @@ const badAuthenticatedUser: SafeUser = {
profilingConsentUpdateDate: undefined,
}
const loggedOutGuest: Guest = {
const loggedOutGuest: BookingConfirmation["booking"]["guest"] = {
email: "logged+out@scandichotels.com",
firstName: "Anonymous",
lastName: "Booking",
@@ -210,7 +210,7 @@ const loggedOutGuest: Guest = {
countryCode: "SE",
}
const loggedInGuest: Guest = {
const loggedInGuest: BookingConfirmation["booking"]["guest"] = {
email: "logged+in@scandichotels.com",
firstName: "Authenticated",
lastName: "Booking",

View File

@@ -1,5 +1,5 @@
import type { AdditionalInfoCookieValue } from "@scandic-hotels/booking-flow/types/components/findMyBooking/additionalInfoCookieValue"
import type { Guest } from "@scandic-hotels/trpc/routers/booking/output"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { SafeUser } from "@/types/user"
@@ -15,7 +15,7 @@ export {
* Whether a request can access a confirmed booking or not.
*/
function accessBooking(
guest: Guest,
guest: BookingConfirmation["booking"]["guest"],
lastName: string,
user: SafeUser | null,
cookie: string = ""

View File

@@ -13,22 +13,20 @@ import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkele
import { MyStayContext } from "@/contexts/MyStay"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
BookingConfirmation,
BookingConfirmationSchema,
} from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { RoomCategories } from "@scandic-hotels/trpc/types/hotel"
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
import type { Packages } from "@/types/components/myPages/myStay/ancillaries"
import type { MyStayStore } from "@/types/contexts/my-stay"
import type { getLinkedReservations } from "@/lib/trpc/memoizedRequests"
interface MyStayProviderProps {
bookingConfirmation: BookingConfirmation
breakfastPackages: Packages | null
isLoggedIn?: boolean
lang: Lang
linkedReservationsPromise: Promise<BookingConfirmationSchema[]>
linkedReservationsPromise: ReturnType<typeof getLinkedReservations>
refId: string
roomCategories: RoomCategories
savedCreditCards: CreditCard[] | null

View File

@@ -52,7 +52,7 @@ import { getPaymentHeadingConfig } from "./utils"
import styles from "./payment.module.css"
import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/input"
import type { CreditCard } from "@scandic-hotels/trpc/types/user"
import type { PriceChangeData } from "../PriceChangeData"
@@ -128,45 +128,46 @@ export default function PaymentClient({
const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (result) => {
if (result) {
if ("error" in result) {
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.set("errorCode", result.cause)
window.history.replaceState(
{},
"",
`${pathname}?${queryParams.toString()}`
)
handlePaymentError(result.cause)
return
}
const { booking } = result
const mainRoom = booking.rooms[0]
if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) {
clearBookingWidgetState()
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
// eslint-disable-next-line react-hooks/immutability
document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
router.push(confirmationUrl)
return
}
setRefId(mainRoom.refId)
const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
if (hasPriceChange) {
const priceChangeData = booking.rooms
.map((room) => room.priceChangedMetadata || null)
.filter(isNotNull)
setPriceChangeData(priceChangeData)
} else {
setIsPollingForBookingStatus(true)
}
} else {
if (!result) {
handlePaymentError("No confirmation number")
return
}
if ("error" in result) {
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.set("errorCode", result.cause)
window.history.replaceState(
{},
"",
`${pathname}?${queryParams.toString()}`
)
handlePaymentError(result.cause)
return
}
const { booking } = result
const mainRoom = booking.rooms[0]
if (booking.reservationStatus == BookingStatusEnum.BookingCompleted) {
clearBookingWidgetState()
// Cookie is used by Booking Confirmation page to validate that the user came from payment callback
// eslint-disable-next-line react-hooks/immutability
document.cookie = `bcsig=${result.sig}; Path=/; Max-Age=60; Secure; SameSite=Strict`
const confirmationUrl = `${bookingConfirmation(lang)}?RefId=${encodeURIComponent(mainRoom.refId)}`
router.push(confirmationUrl)
return
}
setRefId(mainRoom.refId)
const hasPriceChange = booking.rooms.some((r) => r.priceChangedMetadata)
if (hasPriceChange) {
const priceChangeData = booking.rooms
.map((room) => room.priceChangedMetadata || null)
.filter(isNotNull)
setPriceChangeData(priceChangeData)
} else {
setIsPollingForBookingStatus(true)
}
},
onError: (error) => {
@@ -419,6 +420,7 @@ export default function PaymentClient({
}
),
}
initiateBooking.mutate(payload)
},
[

View File

@@ -9,7 +9,7 @@ import {
import { logger } from "@scandic-hotels/common/logger"
import { getServiceToken } from "@scandic-hotels/common/tokenManager"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import { getBooking } from "@scandic-hotels/trpc/routers/booking/utils"
import { getBooking } from "@scandic-hotels/trpc/services/booking/getBooking"
import { encrypt } from "@scandic-hotels/trpc/utils/encryption"
import { BookingFlowConfig } from "../bookingFlowConfig/bookingFlowConfig"
@@ -18,7 +18,7 @@ import { HandleSuccessCallback } from "../components/EnterDetails/Payment/Paymen
import { serverClient } from "../trpc"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { CreateBookingSchema } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema"
import type { BookingStatus } from "@scandic-hotels/trpc/services/booking/getBookingStatus"
import type { NextSearchParams } from "../types"
@@ -99,7 +99,7 @@ export async function PaymentCallbackPage({
notFound()
}
const booking = await getBooking(confirmationNumber, lang, token)
const booking = await getBooking({ confirmationNumber, lang }, token)
const refId = booking?.refId
const caller = await serverClient()
@@ -156,7 +156,7 @@ function HandleBookingStatusError({
config,
status,
}: {
booking: CreateBookingSchema | null
booking: BookingStatus | null
confirmationNumber?: string
returnUrl: string
config: BookingFlowConfig

View File

@@ -79,6 +79,13 @@ export function unprocessableContent(cause?: TRPCCause, message: string = "") {
})
}
export function badGatewayError(cause?: TRPCCause, message: string = "") {
return new TRPCError({
code: "BAD_GATEWAY",
cause: harmonizeCause(cause, message),
})
}
export function internalServerError(cause?: TRPCCause, message: string = "") {
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",

View File

@@ -30,20 +30,6 @@ export const cancelBookingsInput = z.object({
language: z.nativeEnum(Lang),
})
export const guaranteeBookingInput = z.object({
card: z
.object({
alias: z.string(),
expiryDate: z.string(),
cardType: z.string(),
})
.optional(),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
success: z.string().nullable(),
error: z.string().nullable(),
cancel: z.string().nullable(),
})
export const createRefIdInput = z.object({
confirmationNumber: z
.string()
@@ -63,7 +49,7 @@ export const updateBookingInput = z.object({
countryCode: z.string().optional(),
})
.optional(),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
language: z.nativeEnum(Lang),
})
// Query
@@ -85,7 +71,4 @@ export const findBookingInput = z.object({
})
export type LinkedReservationsInput = z.input<typeof getLinkedReservationsInput>
export const getBookingStatusInput = z.object({
lang: z.nativeEnum(Lang).optional(),
})
export type { CreateBookingInput } from "./mutation/createBookingRoute/schema"

View File

@@ -0,0 +1,42 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { addPackageToBooking } from "../../../services/booking/addPackageToBooking"
const addPackageInput = z.object({
ancillaryComment: z.string(),
ancillaryDeliveryTime: z.string().nullish(),
packages: z.array(
z.object({
code: z.string(),
quantity: z.number(),
comment: z.string().optional(),
})
),
language: z.nativeEnum(Lang),
})
const refIdPlugin = createRefIdPlugin()
export const addPackagesRoute = safeProtectedServiceProcedure
.input(addPackageInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumber } = ctx
const { language, refId, ...body } = input
return await addPackageToBooking(
{ confirmationNumber, lang: language, ...body },
ctx.token ?? ctx.serviceToken
)
})

View File

@@ -0,0 +1,51 @@
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { cancelBooking } from "../../../services/booking/cancelBooking"
import { cancelBookingsInput } from "../input"
const bookingLogger = createLogger("trpc.booking.cancelBooking")
const refIdPlugin = createRefIdPlugin()
export const cancelBookingRoute = safeProtectedServiceProcedure
.input(cancelBookingsInput)
.concat(refIdPlugin.toConfirmationNumbers)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumbers } = ctx
const { language } = input
const token = ctx.token ?? ctx.serviceToken
const responses = await Promise.allSettled(
confirmationNumbers.map((confirmationNumber) =>
cancelBooking({ confirmationNumber, language }, token)
)
)
const cancelledRoomsSuccessfully: (string | null)[] = []
for (const [idx, response] of responses.entries()) {
if (response.status === "fulfilled") {
if (response.value) {
cancelledRoomsSuccessfully.push(confirmationNumbers[idx])
continue
}
} else {
bookingLogger.error(
`Cancelling booking failed for confirmationNumber: ${confirmationNumbers[idx]}`,
response.reason
)
}
cancelledRoomsSuccessfully.push(null)
}
return cancelledRoomsSuccessfully
})

View File

@@ -1,95 +0,0 @@
import "server-only"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../../api"
import { safeProtectedServiceProcedure } from "../../../../procedures"
import { encrypt } from "../../../../utils/encryption"
import { createBookingInput, createBookingSchema } from "./schema"
export const create = safeProtectedServiceProcedure
.input(createBookingInput)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { language, ...inputWithoutLang } = input
const { rooms, ...loggableInput } = inputWithoutLang
const createBookingCounter = createCounter("trpc.booking.create")
const metricsCreateBooking = createBookingCounter.init({
language,
...loggableInput,
rooms: inputWithoutLang.rooms.map(({ guest, ...room }) => {
const { becomeMember, membershipNumber } = guest
return { ...room, guest: { becomeMember, membershipNumber } }
}),
})
metricsCreateBooking.start()
const headers = {
Authorization: `Bearer ${ctx.token ?? ctx.serviceToken}`,
}
const includePartnerSpecific =
inputWithoutLang.payment?.paymentMethod ===
PaymentMethodEnum.PartnerPoints
if (includePartnerSpecific) {
const session = await ctx.auth()
const token = session?.token.access_token
if (!token) {
throw new Error(
"Cannot create booking with partner points without partner token"
)
}
inputWithoutLang.partnerSpecific = {
eurobonusAccessToken: session?.token.access_token,
}
}
const apiResponse = await api.post(
api.endpoints.v1.Booking.bookings,
{
headers,
body: inputWithoutLang,
},
{ language }
)
if (!apiResponse.ok) {
await metricsCreateBooking.httpError(apiResponse)
const apiJson = await apiResponse.json()
if ("errors" in apiJson && apiJson.errors.length) {
const error = apiJson.errors[0]
return { error: true, cause: error.code } as const
}
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsCreateBooking.validationError(verifiedData.error)
return null
}
metricsCreateBooking.success()
const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry
return {
booking: verifiedData.data,
sig: encrypt(expire.toString()),
}
})

View File

@@ -0,0 +1,46 @@
import "server-only"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { safeProtectedServiceProcedure } from "../../../../procedures"
import { createBooking } from "../../../../services/booking/createBooking"
import { encrypt } from "../../../../utils/encryption"
import { createBookingInput } from "./schema"
export const createBookingRoute = safeProtectedServiceProcedure
.input(createBookingInput)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
if (input.payment?.paymentMethod === PaymentMethodEnum.PartnerPoints) {
const session = await ctx.auth()
const token = session?.token.access_token
if (!token) {
throw new Error(
"Cannot create booking with partner points without partner token"
)
}
input.partnerSpecific = {
eurobonusAccessToken: session?.token.access_token,
}
}
const booking = await createBooking(input, ctx.token ?? ctx.serviceToken)
if ("error" in booking) {
return { ...booking }
}
const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry
return {
booking,
sig: encrypt(expire.toString()),
}
})

View File

@@ -1,11 +1,31 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { langToApiLang } from "../../../../constants/apiLang"
import { ChildBedTypeEnum } from "../../../../enums/childBedTypeEnum"
import { calculateRefId } from "../../../../utils/refId"
import { guestSchema } from "../../output"
const paymentSchema = z.object({
paymentMethod: z.nativeEnum(PaymentMethodEnum),
card: z
.object({
alias: z.string(),
expiryDate: z.string(),
cardType: z.string(),
})
.optional(),
cardHolder: z
.object({
email: z.string().email(),
name: z.string(),
phoneCountryCode: z.string(),
phoneSubscriber: z.string(),
})
.optional(),
success: z.string(),
error: z.string(),
cancel: z.string(),
})
const roomsSchema = z
.array(
@@ -75,28 +95,6 @@ const roomsSchema = z
})
})
const paymentSchema = z.object({
paymentMethod: z.string(),
card: z
.object({
alias: z.string(),
expiryDate: z.string(),
cardType: z.string(),
})
.optional(),
cardHolder: z
.object({
email: z.string().email(),
name: z.string(),
phoneCountryCode: z.string(),
phoneSubscriber: z.string(),
})
.optional(),
success: z.string(),
error: z.string(),
cancel: z.string(),
})
export type CreateBookingInput = z.input<typeof createBookingInput>
export const createBookingInput = z.object({
hotelId: z.string(),
@@ -104,78 +102,10 @@ export const createBookingInput = z.object({
checkOutDate: z.string(),
rooms: roomsSchema,
payment: paymentSchema.optional(),
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
language: z.nativeEnum(Lang),
partnerSpecific: z
.object({
eurobonusAccessToken: z.string(),
})
.optional(),
})
export const createBookingSchema = z
.object({
data: z.object({
attributes: z.object({
reservationStatus: z.string(),
guest: guestSchema.optional(),
paymentUrl: z.string().nullable().optional(),
paymentMethod: z.string().nullable().optional(),
rooms: z
.array(
z.object({
confirmationNumber: z.string(),
cancellationNumber: z.string().nullable(),
priceChangedMetadata: z
.object({
roomPrice: z.number(),
totalPrice: z.number(),
})
.nullable()
.optional(),
})
)
.default([]),
errors: z
.array(
z.object({
confirmationNumber: z.string().nullable().optional(),
errorCode: z.string(),
description: z.string().nullable().optional(),
meta: z
.record(z.string(), z.union([z.string(), z.number()]))
.nullable()
.optional(),
})
)
.default([]),
}),
type: z.string(),
id: z.string(),
links: z.object({
self: z.object({
href: z.string().url(),
meta: z.object({
method: z.string(),
}),
}),
}),
}),
})
.transform((d) => ({
id: d.data.id,
links: d.data.links,
type: d.data.type,
reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl,
paymentMethod: d.data.attributes.paymentMethod,
rooms: d.data.attributes.rooms.map((room) => {
const lastName = d.data.attributes.guest?.lastName ?? ""
return {
...room,
refId: calculateRefId(room.confirmationNumber, lastName),
}
}),
errors: d.data.attributes.errors,
guest: d.data.attributes.guest,
}))
export type CreateBookingSchema = z.infer<typeof createBookingSchema>

View File

@@ -0,0 +1,19 @@
import { serverErrorByStatus } from "../../../errors"
import { serviceProcedure } from "../../../procedures"
import { encrypt } from "../../../utils/encryption"
import { createRefIdInput } from "../input"
export const createRefIdRoute = serviceProcedure
.input(createRefIdInput)
.mutation(async function ({ input }) {
const { confirmationNumber, lastName } = input
const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`)
if (!encryptedRefId) {
throw serverErrorByStatus(422, "Was not able to encrypt ref id")
}
return {
refId: encryptedRefId,
}
})

View File

@@ -0,0 +1,43 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { guaranteeBooking } from "../../../services/booking/guaranteeBooking"
const guaranteeBookingInput = z.object({
card: z
.object({
alias: z.string(),
expiryDate: z.string(),
cardType: z.string(),
})
.optional(),
language: z.nativeEnum(Lang),
success: z.string().nullable(),
error: z.string().nullable(),
cancel: z.string().nullable(),
})
const refIdPlugin = createRefIdPlugin()
export const guaranteeBookingRoute = safeProtectedServiceProcedure
.input(guaranteeBookingInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumber } = ctx
const { language, refId, ...body } = input
const token = ctx.token ?? ctx.serviceToken
return guaranteeBooking({ confirmationNumber, language, ...body }, token)
})

View File

@@ -1,226 +1,34 @@
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { dt } from "@scandic-hotels/common/dt"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "../../.."
import * as api from "../../../api"
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { updateBooking } from "../../../services/booking/updateBooking"
import {
addPackageInput,
cancelBookingsInput,
guaranteeBookingInput,
removePackageInput,
resendConfirmationInput,
updateBookingInput,
} from "../input"
import { bookingConfirmationSchema } from "../output"
import { cancelBooking } from "../utils"
import { createBookingSchema } from "./create/schema"
import { create } from "./create"
import { addPackagesRoute } from "./addPackagesRoute"
import { cancelBookingRoute } from "./cancelBookingRoute"
import { createBookingRoute } from "./createBookingRoute"
import { createRefIdRoute } from "./createRefIdRoute"
import { guaranteeBookingRoute } from "./guaranteeBookingRoute"
import { priceChangeRoute } from "./priceChangeRoute"
import { validatePartnerPayment } from "./validatePartnerPayment"
const refIdPlugin = createRefIdPlugin()
const bookingLogger = createLogger("trpc.booking")
export const bookingMutationRouter = router({
create,
create: createBookingRoute,
createRefId: createRefIdRoute,
validatePartnerPayment,
priceChange: safeProtectedServiceProcedure
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx }) {
const { confirmationNumber } = ctx
const priceChangeCounter = createCounter("trpc.booking.price-change")
const metricsPriceChange = priceChangeCounter.init({ confirmationNumber })
metricsPriceChange.start()
const token = ctx.token ?? ctx.serviceToken
const headers = {
Authorization: `Bearer ${token}`,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.priceChange(confirmationNumber),
{
headers,
}
)
if (!apiResponse.ok) {
await metricsPriceChange.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsPriceChange.validationError(verifiedData.error)
return null
}
metricsPriceChange.success()
return verifiedData.data
}),
cancel: safeProtectedServiceProcedure
.input(cancelBookingsInput)
.concat(refIdPlugin.toConfirmationNumbers)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumbers } = ctx
const { language } = input
const token = ctx.token ?? ctx.serviceToken
const responses = await Promise.allSettled(
confirmationNumbers.map((confirmationNumber) =>
cancelBooking(confirmationNumber, language, token)
)
)
const cancelledRoomsSuccessfully: (string | null)[] = []
for (const [idx, response] of responses.entries()) {
if (response.status === "fulfilled") {
if (response.value) {
cancelledRoomsSuccessfully.push(confirmationNumbers[idx])
continue
}
} else {
bookingLogger.error(
`Cancelling booking failed for confirmationNumber: ${confirmationNumbers[idx]}`,
response.reason
)
}
cancelledRoomsSuccessfully.push(null)
}
return cancelledRoomsSuccessfully
}),
packages: safeProtectedServiceProcedure
.input(addPackageInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumber } = ctx
const { language, refId, ...body } = input
const addPackageCounter = createCounter("trpc.booking.package.add")
const metricsAddPackage = addPackageCounter.init({
confirmationNumber,
language,
})
metricsAddPackage.start()
const token = ctx.token ?? ctx.serviceToken
const headers = {
Authorization: `Bearer ${token}`,
}
const apiResponse = await api.post(
api.endpoints.v1.Booking.packages(confirmationNumber),
{
headers,
body: body,
},
{ language }
)
if (!apiResponse.ok) {
await metricsAddPackage.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsAddPackage.validationError(verifiedData.error)
return null
}
metricsAddPackage.success()
return verifiedData.data
}),
guarantee: safeProtectedServiceProcedure
.input(guaranteeBookingInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumber } = ctx
const { language, refId, ...body } = input
const guaranteeBookingCounter = createCounter("trpc.booking.guarantee")
const metricsGuaranteeBooking = guaranteeBookingCounter.init({
confirmationNumber,
language,
})
metricsGuaranteeBooking.start()
const token = ctx.token ?? ctx.serviceToken
const headers = {
Authorization: `Bearer ${token}`,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.guarantee(confirmationNumber),
{
headers,
body: body,
},
{ language }
)
if (!apiResponse.ok) {
await metricsGuaranteeBooking.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGuaranteeBooking.validationError(verifiedData.error)
return null
}
metricsGuaranteeBooking.success()
return verifiedData.data
}),
priceChange: priceChangeRoute,
cancel: cancelBookingRoute,
packages: addPackagesRoute,
guarantee: guaranteeBookingRoute,
update: safeProtectedServiceProcedure
.input(updateBookingInput)
.concat(refIdPlugin.toConfirmationNumber)
@@ -235,43 +43,21 @@ export const bookingMutationRouter = router({
})
.mutation(async function ({ ctx, input }) {
const { confirmationNumber } = ctx
const { language, refId, ...body } = input
const updateBookingCounter = createCounter("trpc.booking.update")
const metricsUpdateBooking = updateBookingCounter.init({
confirmationNumber,
language,
})
metricsUpdateBooking.start()
const { language, refId, ...rest } = input
const token = ctx.token ?? ctx.serviceToken
const apiResponse = await api.put(
api.endpoints.v1.Booking.booking(confirmationNumber),
return updateBooking(
{
body,
headers: {
Authorization: `Bearer ${token}`,
},
confirmationNumber,
lang: language,
checkInDate: rest.checkInDate ? dt.utc(rest.checkInDate) : undefined,
checkOutDate: rest.checkOutDate
? dt.utc(rest.checkOutDate)
: undefined,
guest: rest.guest,
},
{ language }
token
)
if (!apiResponse.ok) {
await metricsUpdateBooking.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = bookingConfirmationSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsUpdateBooking.validationError(verifiedData.error)
return null
}
metricsUpdateBooking.success()
return verifiedData.data
}),
removePackage: safeProtectedServiceProcedure
.input(removePackageInput)

View File

@@ -0,0 +1,24 @@
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { priceChange } from "../../../services/booking/priceChange"
const refIdPlugin = createRefIdPlugin()
export const priceChangeRoute = safeProtectedServiceProcedure
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, next }) => {
const token = await ctx.getScandicUserToken()
return next({
ctx: {
token,
},
})
})
.mutation(async function ({ ctx }) {
const { confirmationNumber } = ctx
return await priceChange(
{ confirmationNumber },
ctx.token ?? ctx.serviceToken
)
})

View File

@@ -1,6 +1,6 @@
import "server-only"
import z from "zod"
import { z } from "zod"
import { createCounter } from "@scandic-hotels/common/telemetry"

View File

@@ -1,319 +1,12 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "../.."
import * as api from "../../api"
import {
badRequestError,
extractResponseDetails,
notFoundError,
serverErrorByStatus,
} from "../../errors"
import { createRefIdPlugin } from "../../plugins/refIdToConfirmationNumber"
import {
safeProtectedServiceProcedure,
serviceProcedure,
} from "../../procedures"
import { toApiLang } from "../../utils"
import { encrypt } from "../../utils/encryption"
import { isValidSession } from "../../utils/session"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { getHotel } from "../hotels/services/getHotel"
import { createBookingSchema } from "./mutation/create/schema"
import { getHotelRoom } from "./helpers"
import {
createRefIdInput,
findBookingInput,
getBookingInput,
getBookingStatusInput,
getLinkedReservationsInput,
} from "./input"
import { findBooking, getBooking } from "./utils"
const refIdPlugin = createRefIdPlugin()
import { findBookingRoute } from "./query/findBookingRoute"
import { getBookingRoute } from "./query/getBookingRoute"
import { getBookingStatusRoute } from "./query/getBookingStatusRoute"
import { getLinkedReservationsRoute } from "./query/getLinkedReservationsRoute"
export const bookingQueryRouter = router({
get: safeProtectedServiceProcedure
.input(getBookingInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang
const token = await ctx.getScandicUserToken()
return next({
ctx: {
lang,
token,
},
})
})
.query(async function ({ ctx }) {
const { confirmationNumber, lang, token, serviceToken } = ctx
const getBookingCounter = createCounter("trpc.booking.get")
const metricsGetBooking = getBookingCounter.init({ confirmationNumber })
metricsGetBooking.start()
const booking = await getBooking(
confirmationNumber,
lang,
token ?? serviceToken
)
if (!booking) {
metricsGetBooking.dataError(
`Fail to get booking data for ${confirmationNumber}`,
{ confirmationNumber }
)
return null
}
const [hotelData, hotelPages] = await Promise.all([
getHotel(
{
hotelId: booking.hotelId,
isCardOnlyPayment: false,
language: lang,
},
serviceToken
),
getHotelPageUrls(lang),
])
const hotelPage = hotelPages.find(
(page) => page.hotelId === booking.hotelId
)
if (!hotelData) {
metricsGetBooking.dataError(
`Failed to get hotel data for ${booking.hotelId}`,
{
hotelId: booking.hotelId,
}
)
throw notFoundError({
message: "Hotel data not found",
errorDetails: { hotelId: booking.hotelId },
})
}
metricsGetBooking.success()
return {
...hotelData,
url: hotelPage?.url || null,
booking,
room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode),
}
}),
findBooking: safeProtectedServiceProcedure
.input(findBookingInput)
.use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang
const token = isValidSession(ctx.session)
? ctx.session.token.access_token
: ctx.serviceToken
return next({
ctx: {
lang,
token,
},
})
})
.query(async function ({
ctx,
input: { confirmationNumber, lastName, firstName, email },
}) {
const { lang, token, serviceToken } = ctx
const findBookingCounter = createCounter("trpc.booking.findBooking")
const metricsFindBooking = findBookingCounter.init({ confirmationNumber })
metricsFindBooking.start()
const booking = await findBooking(
confirmationNumber,
lang,
token,
lastName,
firstName,
email
)
if (!booking) {
metricsFindBooking.dataError(
`Fail to find booking data for ${confirmationNumber}`,
{ confirmationNumber }
)
return null
}
const [hotelData, hotelPages] = await Promise.all([
getHotel(
{
hotelId: booking.hotelId,
isCardOnlyPayment: false,
language: lang,
},
serviceToken
),
getHotelPageUrls(lang),
])
const hotelPage = hotelPages.find(
(page) => page.hotelId === booking.hotelId
)
if (!hotelData) {
metricsFindBooking.dataError(
`Failed to find hotel data for ${booking.hotelId}`,
{
hotelId: booking.hotelId,
}
)
throw notFoundError({
message: "Hotel data not found",
errorDetails: { hotelId: booking.hotelId },
})
}
metricsFindBooking.success()
return {
...hotelData,
url: hotelPage?.url || null,
booking,
room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode),
}
}),
linkedReservations: safeProtectedServiceProcedure
.input(getLinkedReservationsInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang
const token = isValidSession(ctx.session)
? ctx.session.token.access_token
: ctx.serviceToken
return next({
ctx: {
lang,
token,
},
})
})
.query(async function ({ ctx }) {
const { confirmationNumber, lang, token } = ctx
const getLinkedReservationsCounter = createCounter(
"trpc.booking.linkedReservations"
)
const metricsGetLinkedReservations = getLinkedReservationsCounter.init({
confirmationNumber,
})
metricsGetLinkedReservations.start()
const booking = await getBooking(confirmationNumber, lang, token)
if (!booking) {
return []
}
const linkedReservationsResults = await Promise.allSettled(
booking.linkedReservations.map((linkedReservation) =>
getBooking(linkedReservation.confirmationNumber, lang, token)
)
)
const linkedReservations = []
for (const linkedReservationsResult of linkedReservationsResults) {
if (linkedReservationsResult.status === "fulfilled") {
if (linkedReservationsResult.value) {
linkedReservations.push(linkedReservationsResult.value)
} else {
metricsGetLinkedReservations.dataError(
`Unexpected value for linked reservation`
)
}
} else {
metricsGetLinkedReservations.dataError(
`Failed to get linked reservation`
)
}
}
metricsGetLinkedReservations.success()
return linkedReservations
}),
status: serviceProcedure
.input(getBookingStatusInput)
.concat(refIdPlugin.toConfirmationNumber)
.query(async function ({ ctx, input }) {
const lang = input.lang ?? ctx.lang
const { confirmationNumber } = ctx
const language = toApiLang(lang)
const getBookingStatusCounter = createCounter("trpc.booking.status")
const metricsGetBookingStatus = getBookingStatusCounter.init({
confirmationNumber,
})
metricsGetBookingStatus.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.status(confirmationNumber),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
{
language,
}
)
if (!apiResponse.ok) {
await metricsGetBookingStatus.httpError(apiResponse)
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"getBookingStatus failed"
)
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetBookingStatus.validationError(verifiedData.error)
throw badRequestError({
message: "Invalid booking data",
errorDetails: verifiedData.error.formErrors,
})
}
metricsGetBookingStatus.success()
const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry
return {
booking: verifiedData.data,
sig: encrypt(expire.toString()),
}
}),
createRefId: serviceProcedure
.input(createRefIdInput)
.mutation(async function ({ input }) {
const { confirmationNumber, lastName } = input
const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`)
if (!encryptedRefId) {
throw serverErrorByStatus(422, "Was not able to encrypt ref id")
}
return {
refId: encryptedRefId,
}
}),
get: getBookingRoute,
findBooking: findBookingRoute,
linkedReservations: getLinkedReservationsRoute,
status: getBookingStatusRoute,
})

View File

@@ -0,0 +1,87 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { notFoundError } from "../../../errors"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { findBooking } from "../../../services/booking/findBooking"
import { isValidSession } from "../../../utils/session"
import { getHotelPageUrls } from "../../contentstack/hotelPage/utils"
import { getHotel } from "../../hotels/services/getHotel"
import { getHotelRoom } from "../helpers"
import { findBookingInput } from "../input"
export const findBookingRoute = safeProtectedServiceProcedure
.input(findBookingInput)
.use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang
const token = isValidSession(ctx.session)
? ctx.session.token.access_token
: ctx.serviceToken
return next({
ctx: {
lang,
token,
},
})
})
.query(async function ({
ctx,
input: { confirmationNumber, lastName, firstName, email },
}) {
const { lang, token, serviceToken } = ctx
const findBookingCounter = createCounter("trpc.booking.findBooking")
const metricsFindBooking = findBookingCounter.init({ confirmationNumber })
metricsFindBooking.start()
const booking = await findBooking(
{ confirmationNumber, lang, lastName, firstName, email },
token
)
if (!booking) {
metricsFindBooking.dataError(
`Fail to find booking data for ${confirmationNumber}`,
{ confirmationNumber }
)
return null
}
const [hotelData, hotelPages] = await Promise.all([
getHotel(
{
hotelId: booking.hotelId,
isCardOnlyPayment: false,
language: lang,
},
serviceToken
),
getHotelPageUrls(lang),
])
const hotelPage = hotelPages.find(
(page) => page.hotelId === booking.hotelId
)
if (!hotelData) {
metricsFindBooking.dataError(
`Failed to find hotel data for ${booking.hotelId}`,
{
hotelId: booking.hotelId,
}
)
throw notFoundError({
message: "Hotel data not found",
errorDetails: { hotelId: booking.hotelId },
})
}
metricsFindBooking.success()
return {
...hotelData,
url: hotelPage?.url || null,
booking,
room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode),
}
})

View File

@@ -0,0 +1,84 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { notFoundError } from "../../../errors"
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { getBooking } from "../../../services/booking/getBooking"
import { getHotelPageUrls } from "../../contentstack/hotelPage/utils"
import { getHotel } from "../../hotels/services/getHotel"
import { getHotelRoom } from "../helpers"
import { getBookingInput } from "../input"
const refIdPlugin = createRefIdPlugin()
export const getBookingRoute = safeProtectedServiceProcedure
.input(getBookingInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang
const token = await ctx.getScandicUserToken()
return next({
ctx: {
lang,
token,
},
})
})
.query(async function ({ ctx }) {
const { confirmationNumber, lang, token, serviceToken } = ctx
const getBookingCounter = createCounter("trpc.booking.get")
const metricsGetBooking = getBookingCounter.init({ confirmationNumber })
metricsGetBooking.start()
const booking = await getBooking(
{ confirmationNumber, lang },
token ?? serviceToken
)
if (!booking) {
metricsGetBooking.dataError(
`Fail to get booking data for ${confirmationNumber}`,
{ confirmationNumber }
)
return null
}
const [hotelData, hotelPages] = await Promise.all([
getHotel(
{
hotelId: booking.hotelId,
isCardOnlyPayment: false,
language: lang,
},
serviceToken
),
getHotelPageUrls(lang),
])
const hotelPage = hotelPages.find(
(page) => page.hotelId === booking.hotelId
)
if (!hotelData) {
metricsGetBooking.dataError(
`Failed to get hotel data for ${booking.hotelId}`,
{
hotelId: booking.hotelId,
}
)
throw notFoundError({
message: "Hotel data not found",
errorDetails: { hotelId: booking.hotelId },
})
}
metricsGetBooking.success()
return {
...hotelData,
url: hotelPage?.url || null,
booking,
room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode),
}
})

View File

@@ -0,0 +1,33 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { getBookingStatus } from "../../../services/booking/getBookingStatus"
import { encrypt } from "../../../utils/encryption"
const getBookingStatusInput = z.object({
lang: z.nativeEnum(Lang).optional(),
})
const refIdPlugin = createRefIdPlugin()
export const getBookingStatusRoute = safeProtectedServiceProcedure
.input(getBookingStatusInput)
.concat(refIdPlugin.toConfirmationNumber)
.query(async function ({ ctx, input }) {
const lang = input.lang ?? ctx.lang
const { confirmationNumber } = ctx
const booking = await getBookingStatus(
{ confirmationNumber, lang },
ctx.serviceToken
)
const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry
return {
booking,
sig: encrypt(expire.toString()),
}
})

View File

@@ -0,0 +1,34 @@
import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber"
import { safeProtectedServiceProcedure } from "../../../procedures"
import { getLinkedReservations } from "../../../services/booking/linkedReservations"
import { isValidSession } from "../../../utils/session"
import { getLinkedReservationsInput } from "../input"
const refIdPlugin = createRefIdPlugin()
export const getLinkedReservationsRoute = safeProtectedServiceProcedure
.input(getLinkedReservationsInput)
.concat(refIdPlugin.toConfirmationNumber)
.use(async ({ ctx, input, next }) => {
const lang = input.lang ?? ctx.lang
const token = isValidSession(ctx.session)
? ctx.session.token.access_token
: ctx.serviceToken
return next({
ctx: {
lang,
token,
},
})
})
.query(async function ({ ctx }) {
const { confirmationNumber, lang, token } = ctx
return getLinkedReservations(
{
confirmationNumber,
lang,
},
token
)
})

View File

@@ -1,171 +0,0 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../api"
import {
badRequestError,
extractResponseDetails,
serverErrorByStatus,
} from "../../errors"
import { toApiLang } from "../../utils"
import { createBookingSchema } from "./mutation/create/schema"
import { bookingConfirmationSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function getBooking(
confirmationNumber: string,
lang: Lang,
token: string
) {
const getBookingCounter = createCounter("booking.get")
const metricsGetBooking = getBookingCounter.init({ confirmationNumber })
metricsGetBooking.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.booking(confirmationNumber),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
{ language: toApiLang(lang) }
)
if (!apiResponse.ok) {
await metricsGetBooking.httpError(apiResponse)
// If the booking is not found, return null.
// This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them.
if (apiResponse.status === 404) {
return null
}
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"getBooking failed"
)
}
const apiJson = await apiResponse.json()
const booking = bookingConfirmationSchema.safeParse(apiJson)
if (!booking.success) {
metricsGetBooking.validationError(booking.error)
throw badRequestError()
}
metricsGetBooking.success()
return booking.data
}
export async function findBooking(
confirmationNumber: string,
lang: Lang,
token: string,
lastName?: string,
firstName?: string,
email?: string
) {
const findBookingCounter = createCounter("booking.find")
const metricsGetBooking = findBookingCounter.init({
confirmationNumber,
lastName,
firstName,
email,
})
metricsGetBooking.start()
const apiResponse = await api.post(
api.endpoints.v1.Booking.find(confirmationNumber),
{
headers: {
Authorization: `Bearer ${token}`,
},
body: {
lastName,
firstName,
email,
},
},
{ language: toApiLang(lang) }
)
if (!apiResponse.ok) {
await metricsGetBooking.httpError(apiResponse)
// If the booking is not found, return null.
// This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them.
if (apiResponse.status === 400 || apiResponse.status === 404) {
return null
}
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"findBooking failed"
)
}
const apiJson = await apiResponse.json()
const booking = bookingConfirmationSchema.safeParse(apiJson)
if (!booking.success) {
metricsGetBooking.validationError(booking.error)
throw badRequestError()
}
metricsGetBooking.success()
return booking.data
}
export async function cancelBooking(
confirmationNumber: string,
language: Lang,
token: string
) {
const cancelBookingCounter = createCounter("booking.cancel")
const metricsCancelBooking = cancelBookingCounter.init({
confirmationNumber,
language,
})
metricsCancelBooking.start()
const headers = {
Authorization: `Bearer ${token}`,
}
const booking = await getBooking(confirmationNumber, language, token)
if (!booking) {
metricsCancelBooking.noDataError({ confirmationNumber })
return null
}
const { firstName, lastName, email } = booking.guest
const apiResponse = await api.remove(
api.endpoints.v1.Booking.cancel(confirmationNumber),
{
headers,
body: { firstName, lastName, email },
},
{ language: toApiLang(language) }
)
if (!apiResponse.ok) {
await metricsCancelBooking.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsCancelBooking.validationError(verifiedData.error)
return null
}
metricsCancelBooking.success()
return verifiedData.data
}

View File

@@ -1,4 +1,4 @@
import z from "zod"
import { z } from "zod"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"

View File

@@ -0,0 +1,60 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import { toApiLang } from "../../../utils"
import { bookingConfirmationSchema } from "../getBooking/schema"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function addPackageToBooking(
{
lang,
...input
}: {
confirmationNumber: string
lang: Lang
packages: {
code: string
quantity: number
comment?: string | undefined
}[]
ancillaryComment: string
ancillaryDeliveryTime?: string | null | undefined
},
token: string
) {
const addPackageCounter = createCounter("trpc.booking.package.add")
const metricsAddPackage = addPackageCounter.init({
confirmationNumber: input.confirmationNumber,
language: lang,
})
metricsAddPackage.start()
const apiResponse = await api.post(
api.endpoints.v1.Booking.packages(input.confirmationNumber),
{
headers: {
Authorization: `Bearer ${token}`,
},
body: input,
},
{ language: toApiLang(lang) }
)
if (!apiResponse.ok) {
await metricsAddPackage.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = bookingConfirmationSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsAddPackage.validationError(verifiedData.error)
return null
}
metricsAddPackage.success()
return verifiedData.data
}

View File

@@ -0,0 +1,76 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import {
badGatewayError,
extractResponseDetails,
serverErrorByStatus,
} from "../../../errors"
import { toApiLang } from "../../../utils"
import { getBooking } from "../getBooking"
import { cancelBookingSchema } from "./schema"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { CancelBooking } from "./schema"
export async function cancelBooking(
{
confirmationNumber,
language,
}: { confirmationNumber: string; language: Lang },
token: string
): Promise<CancelBooking | null> {
const cancelBookingCounter = createCounter("booking.cancel")
const metricsCancelBooking = cancelBookingCounter.init({
confirmationNumber,
language,
})
metricsCancelBooking.start()
const headers = {
Authorization: `Bearer ${token}`,
}
const booking = await getBooking(
{ confirmationNumber, lang: language },
token
)
if (!booking) {
metricsCancelBooking.noDataError({ confirmationNumber })
return null
}
const { firstName, lastName, email } = booking.guest
const apiResponse = await api.remove(
api.endpoints.v1.Booking.cancel(confirmationNumber),
{
headers,
body: { firstName, lastName, email },
},
{ language: toApiLang(language) }
)
if (!apiResponse.ok) {
await metricsCancelBooking.httpError(apiResponse)
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
`cancelBooking failed for ${confirmationNumber}`
)
}
const apiJson = await apiResponse.json()
const verifiedData = cancelBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsCancelBooking.validationError(verifiedData.error)
throw badGatewayError({
message: "Invalid response from cancelBooking",
errorDetails: { validationError: verifiedData.error },
})
}
metricsCancelBooking.success()
return verifiedData.data
}

View File

@@ -0,0 +1,56 @@
import { z } from "zod"
import { bookingReservationStatusSchema } from "../schema/bookingReservationStatusSchema"
export type CancelBooking = z.infer<typeof cancelBookingSchema>
export const cancelBookingSchema = z
.object({
data: z.object({
attributes: z.object({
reservationStatus: bookingReservationStatusSchema,
paymentUrl: z.string().nullable().optional(),
paymentMethod: z.string().nullable().optional(),
rooms: z
.array(
z.object({
confirmationNumber: z.string(),
cancellationNumber: z.string().nullable(),
priceChangedMetadata: z
.object({
roomPrice: z.number(),
totalPrice: z.number(),
})
.nullable()
.optional(),
})
)
.default([]),
errors: z
.array(
z.object({
confirmationNumber: z.string().nullable().optional(),
errorCode: z.string(),
description: z.string().nullable().optional(),
meta: z
.record(z.string(), z.union([z.string(), z.number()]))
.nullable()
.optional(),
})
)
.default([]),
}),
type: z.string(),
id: z.string(),
}),
})
.transform((apiResponse) => {
return {
id: apiResponse.data.id,
type: apiResponse.data.type,
reservationStatus: apiResponse.data.attributes.reservationStatus,
paymentUrl: apiResponse.data.attributes.paymentUrl,
paymentMethod: apiResponse.data.attributes.paymentMethod,
rooms: apiResponse.data.attributes.rooms,
errors: apiResponse.data.attributes.errors,
}
})

View File

@@ -0,0 +1,88 @@
import "server-only"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import {
badGatewayError,
extractResponseDetails,
serverErrorByStatus,
} from "../../../errors"
import { toApiLang } from "../../../utils"
import { createBookingSchema } from "./schema"
import type { CreateBookingInput } from "../../../routers/booking/mutation/createBookingRoute/schema"
export async function createBooking(input: CreateBookingInput, token: string) {
validateInputData(input)
const createBookingCounter = createCounter("trpc.booking.create")
const metricsCreateBooking = createBookingCounter.init({
...input,
rooms: input.rooms.map(({ guest, ...room }) => {
const { becomeMember, membershipNumber } = guest
return { ...room, guest: { becomeMember, membershipNumber } }
}),
})
metricsCreateBooking.start()
const apiResponse = await api.post(
api.endpoints.v1.Booking.bookings,
{
headers: {
Authorization: `Bearer ${token}`,
},
body: input,
},
{ language: toApiLang(input.language) }
)
if (!apiResponse.ok) {
await metricsCreateBooking.httpError(apiResponse)
const apiJson = await apiResponse.json()
if ("errors" in apiJson && apiJson.errors.length) {
const error = apiJson.errors[0]
return { error: true, cause: error.code } as const
}
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"createBooking failed"
)
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsCreateBooking.validationError(verifiedData.error)
throw badGatewayError({
message: "Invalid response from createBooking",
errorDetails: { validationError: verifiedData.error },
})
}
metricsCreateBooking.success()
return verifiedData.data
}
function validateInputData(input: CreateBookingInput) {
if (!input.payment) {
return
}
if (input.payment.paymentMethod !== PaymentMethodEnum.PartnerPoints) {
return
}
if (!input.partnerSpecific?.eurobonusAccessToken) {
throw new Error(
"Missing partnerSpecific data for PartnerPoints payment method"
)
}
}

View File

@@ -0,0 +1,84 @@
import { z } from "zod"
import {
nullableStringEmailValidator,
nullableStringValidator,
} from "@scandic-hotels/common/utils/zod/stringValidator"
import { calculateRefId } from "../../../utils/refId"
const guestSchema = z.object({
email: nullableStringEmailValidator,
firstName: nullableStringValidator,
lastName: nullableStringValidator,
membershipNumber: nullableStringValidator,
phoneNumber: nullableStringValidator,
countryCode: nullableStringValidator,
})
export const createBookingSchema = z
.object({
data: z.object({
attributes: z.object({
reservationStatus: z.string(),
guest: guestSchema.optional(),
paymentUrl: z.string().nullable().optional(),
paymentMethod: z.string().nullable().optional(),
rooms: z
.array(
z.object({
confirmationNumber: z.string(),
cancellationNumber: z.string().nullable(),
priceChangedMetadata: z
.object({
roomPrice: z.number(),
totalPrice: z.number(),
})
.nullable()
.optional(),
})
)
.default([]),
errors: z
.array(
z.object({
confirmationNumber: z.string().nullable().optional(),
errorCode: z.string(),
description: z.string().nullable().optional(),
meta: z
.record(z.string(), z.union([z.string(), z.number()]))
.nullable()
.optional(),
})
)
.default([]),
}),
type: z.string(),
id: z.string(),
links: z.object({
self: z.object({
href: z.string().url(),
meta: z.object({
method: z.string(),
}),
}),
}),
}),
})
.transform((d) => ({
id: d.data.id,
links: d.data.links,
type: d.data.type,
reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl,
paymentMethod: d.data.attributes.paymentMethod,
rooms: d.data.attributes.rooms.map((room) => {
const lastName = d.data.attributes.guest?.lastName ?? ""
return {
...room,
refId: calculateRefId(room.confirmationNumber, lastName),
}
}),
errors: d.data.attributes.errors,
guest: d.data.attributes.guest,
}))

View File

@@ -0,0 +1,81 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import {
badRequestError,
extractResponseDetails,
serverErrorByStatus,
} from "../../../errors"
import { toApiLang } from "../../../utils"
import { bookingConfirmationSchema } from "../getBooking/schema"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function findBooking(
{
confirmationNumber,
lang,
lastName,
firstName,
email,
}: {
confirmationNumber: string
lang: Lang
lastName?: string
firstName?: string
email?: string
},
token: string
) {
const findBookingCounter = createCounter("booking.find")
const metricsGetBooking = findBookingCounter.init({
confirmationNumber,
lastName,
firstName,
email,
})
metricsGetBooking.start()
const apiResponse = await api.post(
api.endpoints.v1.Booking.find(confirmationNumber),
{
headers: {
Authorization: `Bearer ${token}`,
},
body: {
lastName,
firstName,
email,
},
},
{ language: toApiLang(lang) }
)
if (!apiResponse.ok) {
await metricsGetBooking.httpError(apiResponse)
// If the booking is not found, return null.
// This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them.
if (apiResponse.status === 400 || apiResponse.status === 404) {
return null
}
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"findBooking failed"
)
}
const apiJson = await apiResponse.json()
const booking = bookingConfirmationSchema.safeParse(apiJson)
if (!booking.success) {
metricsGetBooking.validationError(booking.error)
throw badRequestError()
}
metricsGetBooking.success()
return booking.data
}

View File

@@ -0,0 +1,59 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import {
badRequestError,
extractResponseDetails,
serverErrorByStatus,
} from "../../../errors"
import { toApiLang } from "../../../utils"
import { bookingConfirmationSchema } from "./schema"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function getBooking(
{ confirmationNumber, lang }: { confirmationNumber: string; lang: Lang },
token: string
) {
const getBookingCounter = createCounter("booking.get")
const metricsGetBooking = getBookingCounter.init({ confirmationNumber })
metricsGetBooking.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.booking(confirmationNumber),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
{ language: toApiLang(lang) }
)
if (!apiResponse.ok) {
await metricsGetBooking.httpError(apiResponse)
// If the booking is not found, return null.
// This scenario is expected to happen when a logged in user trying to access a booking that doesn't belong to them.
if (apiResponse.status === 404) {
return null
}
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"getBooking failed"
)
}
const apiJson = await apiResponse.json()
const booking = bookingConfirmationSchema.safeParse(apiJson)
if (!booking.success) {
metricsGetBooking.validationError(booking.error)
throw badRequestError()
}
metricsGetBooking.success()
return booking.data
}

View File

@@ -8,57 +8,18 @@ import {
nullableStringValidator,
} from "@scandic-hotels/common/utils/zod/stringValidator"
import { BookingStatusEnum } from "../../enums/bookingStatus"
import { BreakfastPackageEnum } from "../../enums/breakfast"
import { ChildBedTypeEnum } from "../../enums/childBedTypeEnum"
import { calculateRefId } from "../../utils/refId"
import { BookingStatusEnum } from "../../../enums/bookingStatus"
import { BreakfastPackageEnum } from "../../../enums/breakfast"
import { ChildBedTypeEnum } from "../../../enums/childBedTypeEnum"
import { calculateRefId } from "../../../utils/refId"
import { bookingReservationStatusSchema } from "../schema/bookingReservationStatusSchema"
export const guestSchema = z.object({
email: nullableStringEmailValidator,
firstName: nullableStringValidator,
lastName: nullableStringValidator,
membershipNumber: nullableStringValidator,
phoneNumber: nullableStringValidator,
countryCode: nullableStringValidator,
})
export type Guest = z.output<typeof guestSchema>
// QUERY
const childBedPreferencesSchema = z.object({
bedType: z.nativeEnum(ChildBedTypeEnum),
quantity: z.number().int(),
code: z.string().nullable().default(""),
})
const priceSchema = z.object({
currency: z.nativeEnum(CurrencyEnum).default(CurrencyEnum.Unknown),
totalPrice: z.number().nullish(),
totalUnit: z.number().int().nullish(),
unit: z.number().int().nullish(),
unitPrice: z.number(),
})
export const packageSchema = z
.object({
code: nullableStringValidator,
comment: z.string().nullish(),
description: nullableStringValidator,
price: priceSchema,
type: z.string().nullish(),
})
.transform((packageData) => ({
code: packageData.code,
comment: packageData.comment,
currency: packageData.price.currency,
description: packageData.description,
totalPrice: packageData.price.totalPrice ?? 0,
totalUnit: packageData.price.totalUnit ?? 0,
type: packageData.type,
unit: packageData.price.unit ?? 0,
unitPrice: packageData.price.unitPrice,
}))
const ancillarySchema = z
.object({
comment: z.string().default(""),
@@ -82,7 +43,16 @@ const rateDefinitionSchema = z.object({
isCampaignRate: z.boolean().default(false),
})
export const linkedReservationSchema = z.object({
const guestSchema = z.object({
email: nullableStringEmailValidator,
firstName: nullableStringValidator,
lastName: nullableStringValidator,
membershipNumber: nullableStringValidator,
phoneNumber: nullableStringValidator,
countryCode: nullableStringValidator,
})
const linkedReservationSchema = z.object({
confirmationNumber: z.string().default(""),
hotelId: z.string().default(""),
checkinDate: z.string(),
@@ -137,6 +107,34 @@ const linksSchema = z.object({
.nullable(),
})
const priceSchema = z.object({
currency: z.nativeEnum(CurrencyEnum).default(CurrencyEnum.Unknown),
totalPrice: z.number().nullish(),
totalUnit: z.number().int().nullish(),
unit: z.number().int().nullish(),
unitPrice: z.number(),
})
export const packageSchema = z
.object({
code: nullableStringValidator,
comment: z.string().nullish(),
description: nullableStringValidator,
price: priceSchema,
type: z.string().nullish(),
})
.transform((packageData) => ({
code: packageData.code,
comment: packageData.comment,
currency: packageData.price.currency,
description: packageData.description,
totalPrice: packageData.price.totalPrice ?? 0,
totalUnit: packageData.price.totalUnit ?? 0,
type: packageData.type,
unit: packageData.price.unit ?? 0,
unitPrice: packageData.price.unitPrice,
}))
export const bookingConfirmationSchema = z
.object({
data: z.object({
@@ -174,7 +172,7 @@ export const bookingConfirmationSchema = z
multiRoom: z.boolean(),
packages: z.array(packageSchema).default([]),
rateDefinition: rateDefinitionSchema,
reservationStatus: z.string().nullable().default(""),
reservationStatus: bookingReservationStatusSchema,
roomPoints: z.number(),
roomPointType: z
.enum(["Scandic", "EuroBonus"])

View File

@@ -0,0 +1,65 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import {
badRequestError,
extractResponseDetails,
serverErrorByStatus,
} from "../../../errors"
import { toApiLang } from "../../../utils"
import { bookingStatusSchema } from "./schema"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { BookingStatus } from "./schema"
export type { BookingStatus } from "./schema"
export async function getBookingStatus(
{ confirmationNumber, lang }: { confirmationNumber: string; lang: Lang },
serviceToken: string
): Promise<BookingStatus> {
const language = toApiLang(lang)
const getBookingStatusCounter = createCounter("trpc.booking.status")
const metricsGetBookingStatus = getBookingStatusCounter.init({
confirmationNumber,
})
metricsGetBookingStatus.start()
const apiResponse = await api.get(
api.endpoints.v1.Booking.status(confirmationNumber),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
{
language,
}
)
if (!apiResponse.ok) {
await metricsGetBookingStatus.httpError(apiResponse)
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"getBookingStatus failed"
)
}
const apiJson = await apiResponse.json()
const verifiedData = bookingStatusSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetBookingStatus.validationError(verifiedData.error)
throw badRequestError({
message: "Invalid booking data",
errorDetails: verifiedData.error.formErrors,
})
}
metricsGetBookingStatus.success()
return verifiedData.data
}

View File

@@ -0,0 +1,61 @@
import { z } from "zod"
import { calculateRefId } from "../../../utils/refId"
import { bookingReservationStatusSchema } from "../schema/bookingReservationStatusSchema"
export type BookingStatus = z.infer<typeof bookingStatusSchema>
export const bookingStatusSchema = z
.object({
data: z.object({
attributes: z.object({
reservationStatus: bookingReservationStatusSchema,
paymentUrl: z.string().nullable().optional(),
paymentMethod: z.string().nullable().optional(),
rooms: z
.array(
z.object({
confirmationNumber: z.string(),
cancellationNumber: z.string().nullable(),
priceChangedMetadata: z
.object({
roomPrice: z.number(),
totalPrice: z.number(),
})
.nullable()
.optional(),
})
)
.default([]),
errors: z
.array(
z.object({
confirmationNumber: z.string().nullable().optional(),
errorCode: z.string(),
description: z.string().nullable().optional(),
meta: z
.record(z.string(), z.union([z.string(), z.number()]))
.nullable()
.optional(),
})
)
.default([]),
}),
type: z.string(),
id: z.string(),
}),
})
.transform((d) => ({
id: d.data.id,
type: d.data.type,
reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl,
paymentMethod: d.data.attributes.paymentMethod,
errors: d.data.attributes.errors,
rooms: d.data.attributes.rooms.map((room) => {
const lastName = ""
return {
...room,
refId: calculateRefId(room.confirmationNumber, lastName),
}
}),
}))

View File

@@ -0,0 +1,63 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import { langToApiLang } from "../../../constants/apiLang"
import { slimBookingSchema } from "../schema"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function guaranteeBooking(
{
confirmationNumber,
language,
success,
error,
cancel,
card,
}: {
confirmationNumber: string
language: Lang
success: string | null
error: string | null
cancel: string | null
card?: { alias: string; expiryDate: string; cardType: string }
},
token: string
) {
const guaranteeBookingCounter = createCounter("trpc.booking.guarantee")
const metricsGuaranteeBooking = guaranteeBookingCounter.init({
confirmationNumber,
language,
})
metricsGuaranteeBooking.start()
const headers = {
Authorization: `Bearer ${token}`,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.guarantee(confirmationNumber),
{
headers,
body: { success, error, cancel, card },
},
{ language: langToApiLang[language] }
)
if (!apiResponse.ok) {
await metricsGuaranteeBooking.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = slimBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGuaranteeBooking.validationError(verifiedData.error)
return null
}
metricsGuaranteeBooking.success()
return verifiedData.data
}

View File

@@ -0,0 +1,64 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { getBooking } from "../getBooking"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function getLinkedReservations(
{
confirmationNumber,
lang,
}: {
confirmationNumber: string
lang: Lang
},
token: string
): Promise<NonNullable<Awaited<ReturnType<typeof getBooking>>>[]> {
const getLinkedReservationsCounter = createCounter(
"booking.linkedReservations"
)
const metricsGetLinkedReservations = getLinkedReservationsCounter.init({
confirmationNumber,
})
metricsGetLinkedReservations.start()
const booking = await getBooking({ confirmationNumber, lang }, token)
if (!booking) {
return []
}
const linkedReservationsResults = await Promise.allSettled(
booking.linkedReservations.map((linkedReservation) =>
getBooking(
{ confirmationNumber: linkedReservation.confirmationNumber, lang },
token
)
)
)
const linkedReservations: NonNullable<
Awaited<ReturnType<typeof getBooking>>
>[] = []
for (const linkedReservationsResult of linkedReservationsResults) {
if (linkedReservationsResult.status !== "fulfilled") {
metricsGetLinkedReservations.dataError(`Failed to get linked reservation`)
continue
}
if (!linkedReservationsResult.value) {
metricsGetLinkedReservations.dataError(
`Unexpected value for linked reservation`
)
continue
}
linkedReservations.push(linkedReservationsResult.value)
}
metricsGetLinkedReservations.success()
return linkedReservations
}

View File

@@ -0,0 +1,41 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../../api"
import { slimBookingSchema } from "../schema"
export async function priceChange(
{ confirmationNumber }: { confirmationNumber: string },
token: string
) {
const priceChangeCounter = createCounter("trpc.booking.price-change")
const metricsPriceChange = priceChangeCounter.init({ confirmationNumber })
metricsPriceChange.start()
const headers = {
Authorization: `Bearer ${token}`,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.priceChange(confirmationNumber),
{
headers,
}
)
if (!apiResponse.ok) {
await metricsPriceChange.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = slimBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsPriceChange.validationError(verifiedData.error)
return null
}
metricsPriceChange.success()
return verifiedData.data
}

View File

@@ -0,0 +1,84 @@
import { z } from "zod"
import {
nullableStringEmailValidator,
nullableStringValidator,
} from "@scandic-hotels/common/utils/zod/stringValidator"
import { calculateRefId } from "../../utils/refId"
const guestSchema = z.object({
email: nullableStringEmailValidator,
firstName: nullableStringValidator,
lastName: nullableStringValidator,
membershipNumber: nullableStringValidator,
phoneNumber: nullableStringValidator,
countryCode: nullableStringValidator,
})
export const slimBookingSchema = z
.object({
data: z.object({
attributes: z.object({
reservationStatus: z.string(),
guest: guestSchema.optional(),
paymentUrl: z.string().nullable().optional(),
paymentMethod: z.string().nullable().optional(),
rooms: z
.array(
z.object({
confirmationNumber: z.string(),
cancellationNumber: z.string().nullable(),
priceChangedMetadata: z
.object({
roomPrice: z.number(),
totalPrice: z.number(),
})
.nullable()
.optional(),
})
)
.default([]),
errors: z
.array(
z.object({
confirmationNumber: z.string().nullable().optional(),
errorCode: z.string(),
description: z.string().nullable().optional(),
meta: z
.record(z.string(), z.union([z.string(), z.number()]))
.nullable()
.optional(),
})
)
.default([]),
}),
type: z.string(),
id: z.string(),
links: z.object({
self: z.object({
href: z.string().url(),
meta: z.object({
method: z.string(),
}),
}),
}),
}),
})
.transform((d) => ({
id: d.data.id,
links: d.data.links,
type: d.data.type,
reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl,
paymentMethod: d.data.attributes.paymentMethod,
rooms: d.data.attributes.rooms.map((room) => {
const lastName = d.data.attributes.guest?.lastName ?? ""
return {
...room,
refId: calculateRefId(room.confirmationNumber, lastName),
}
}),
errors: d.data.attributes.errors,
guest: d.data.attributes.guest,
}))

View File

@@ -0,0 +1,22 @@
import { z } from "zod"
export const bookingReservationStatusSchema = z
.enum([
"CreatedInOhip",
"PendingAcceptPriceChange",
"PendingGuarantee",
"PendingPayment",
"PaymentRegistered",
"PaymentAuthorized",
"ConfirmedInOhip",
"PaymentSucceeded",
"PaymentFailed",
"PaymentError",
"PaymentCancelled",
"BookingCompleted",
"Cancelled",
"CheckedOut",
"PendingMembership",
"Unknown",
])
.catch("Unknown")

View File

@@ -0,0 +1,80 @@
import { type Dayjs, dt } from "@scandic-hotels/common/dt"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "../../api"
import { langToApiLang } from "../../constants/apiLang"
import {
badGatewayError,
extractResponseDetails,
serverErrorByStatus,
} from "../../errors"
import { bookingConfirmationSchema } from "./getBooking/schema"
import type { Lang } from "@scandic-hotels/common/constants/language"
export async function updateBooking(
input: {
confirmationNumber: string
lang: Lang
checkInDate: Dayjs | Date | undefined
checkOutDate: Dayjs | Date | undefined
guest?: {
email?: string | undefined
phoneNumber?: string | undefined
countryCode?: string | undefined
}
},
token: string
) {
const updateBookingCounter = createCounter("booking.update")
const metricsUpdateBooking = updateBookingCounter.init({
confirmationNumber: input.confirmationNumber,
language: input.lang,
})
metricsUpdateBooking.start()
const body = {
checkInDate: input.checkInDate
? dt(input.checkInDate).format("YYYY-MM-DD")
: undefined,
checkOutDate: input.checkOutDate
? dt(input.checkOutDate).format("YYYY-MM-DD")
: undefined,
guest: input.guest,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.booking(input.confirmationNumber),
{
body,
headers: {
Authorization: `Bearer ${token}`,
},
},
{ language: langToApiLang[input.lang] }
)
if (!apiResponse.ok) {
await metricsUpdateBooking.httpError(apiResponse)
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"updateBooking failed for " + input.confirmationNumber
)
}
const apiJson = await apiResponse.json()
const verifiedData = bookingConfirmationSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsUpdateBooking.validationError(verifiedData.error)
throw badGatewayError({
message: "Invalid response from updateBooking",
errorDetails: { validationError: verifiedData.error },
})
}
metricsUpdateBooking.success()
return verifiedData.data
}

View File

@@ -3,9 +3,8 @@ import type { z } from "zod"
import type {
bookingConfirmationSchema,
packageSchema,
} from "../routers/booking/output"
} from "../services/booking/getBooking/schema"
import type { HotelData, Room } from "./hotel"
export interface BookingConfirmationSchema extends z.output<
typeof bookingConfirmationSchema
> {}

View File

@@ -1,4 +1,4 @@
import z from "zod"
import { z } from "zod"
const baseResolveSchema = z.object({
system: z.object({

View File

@@ -33,6 +33,7 @@
"./routers/autocomplete/*": "./lib/routers/autocomplete/*.ts",
"./routers/navigation/*": "./lib/routers/navigation/*.ts",
"./routers/appRouter": "./lib/routers/appRouter.ts",
"./services/*": "./lib/services/*/index.ts",
"./enums/*": "./lib/enums/*.ts",
"./types/*": "./lib/types/*.ts",
"./constants/*": "./lib/constants/*.ts",