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

@@ -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

@@ -0,0 +1,240 @@
import { z } from "zod"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { dt } from "@scandic-hotels/common/dt"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import {
nullableStringEmailValidator,
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 { bookingReservationStatusSchema } from "../schema/bookingReservationStatusSchema"
const childBedPreferencesSchema = z.object({
bedType: z.nativeEnum(ChildBedTypeEnum),
quantity: z.number().int(),
code: z.string().nullable().default(""),
})
const ancillarySchema = z
.object({
comment: z.string().default(""),
deliveryTime: z.string().default(""),
})
.nullable()
.default({
comment: "",
deliveryTime: "",
})
const rateDefinitionSchema = z.object({
breakfastIncluded: z.boolean().default(false),
cancellationRule: z.string().nullable().default(""),
cancellationText: z.string().nullable().default(""),
generalTerms: z.array(z.string()).default([]),
isMemberRate: z.boolean().default(false),
mustBeGuaranteed: z.boolean().default(false),
rateCode: z.string().default(""),
title: z.string().nullable().default(""),
isCampaignRate: z.boolean().default(false),
})
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(),
checkoutDate: z.string(),
cancellationNumber: nullableStringValidator,
roomTypeCode: z.string().default(""),
adults: z.number().int(),
children: z.number().int(),
profileId: z.string().default(""),
})
const linksSchema = z.object({
addAncillary: z
.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
})
.nullable(),
cancel: z
.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
})
.nullable(),
guarantee: z
.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
})
.nullable(),
modify: z
.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
})
.nullable(),
self: z
.object({
href: z.string(),
meta: z.object({
method: z.string(),
}),
})
.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({
attributes: z.object({
adults: z.number().int(),
ancillary: ancillarySchema,
bookingType: z.string().optional(),
cancelationNumber: z.string().nullable().default(""),
checkInDate: z.string().refine((val) => dt(val).isValid()),
checkOutDate: z.string().refine((val) => dt(val).isValid()),
childBedPreferences: z.array(childBedPreferencesSchema).default([]),
childrenAges: z.array(z.number().int()).default([]),
canChangeDate: z.boolean(),
bookingCode: z.string().nullable(),
cheques: z.number(),
vouchers: z.number(),
guaranteeInfo: z
.object({
maskedCard: z.string(),
cardType: z.string(),
paymentMethod: z.string(),
paymentMethodDescription: z.string(),
})
.nullish(),
computedReservationStatus: z.string().nullable().default(""),
confirmationNumber: nullableStringValidator,
createDateTime: z.string().refine((val) => dt(val).isValid()),
currencyCode: z.nativeEnum(CurrencyEnum),
guest: guestSchema,
linkedReservations: nullableArrayObjectValidator(
linkedReservationSchema
),
hotelId: z.string(),
mainRoom: z.boolean(),
multiRoom: z.boolean(),
packages: z.array(packageSchema).default([]),
rateDefinition: rateDefinitionSchema,
reservationStatus: bookingReservationStatusSchema,
roomPoints: z.number(),
roomPointType: z
.enum(["Scandic", "EuroBonus"])
.nullable()
.default(null)
.catch(null),
roomPrice: z.number(),
roomTypeCode: z.string().default(""),
totalPoints: z.number(),
totalPrice: z.number(),
totalPriceExVat: z.number(),
vatAmount: z.number(),
vatPercentage: z.number(),
}),
id: z.string(),
type: z.literal("booking"),
links: linksSchema,
}),
})
.transform(({ data }) => ({
...data.attributes,
refId: calculateRefId(
data.attributes.confirmationNumber,
data.attributes.guest.lastName
),
linkedReservations: data.attributes.linkedReservations.map(
(linkedReservation) => {
/**
* We lazy load linked reservations in the client.
* The problem is that we need to load the reservation in order to
* calculate the refId for the reservation as the refId uses the guest's
* lastname in it. Ideally we should pass a promise to the React
* component that uses `use()` to resolve it. But right now we use tRPC
* in the client. That tRPC endpoint only uses the confirmationNumber
* from the refId. So that means we can pass whatever as the lastname
* here, because it is actually never read. We should change this ASAP.
*/
return {
...linkedReservation,
refId: calculateRefId(
linkedReservation.confirmationNumber,
"" // TODO: Empty lastname here, see comment above
),
}
}
),
packages: data.attributes.packages.filter((p) => p.type !== "Ancillary"),
ancillaries: data.attributes.packages.filter((p) => p.type === "Ancillary"),
extraBedTypes: data.attributes.childBedPreferences,
showAncillaries:
!!(
data.links.addAncillary ||
data.attributes.packages.some(
(p) =>
p.type === "Ancillary" ||
p.code === BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST
)
) && data.attributes.reservationStatus !== BookingStatusEnum.Cancelled,
isCancelable: !!data.links.cancel,
isModifiable: !!data.links.modify,
isGuaranteeable: !!data.links.guarantee,
canModifyAncillaries: !!data.links.addAncillary,
// Typo from API
cancellationNumber: data.attributes.cancelationNumber,
}))

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
}