Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1 * Move web to subfolder /apps/scandic-web * Yarn + transitive deps - Move to yarn - design-system package removed for now since yarn doesn't support the parameter for token (ie project currently broken) - Add missing transitive dependencies as Yarn otherwise prevents these imports - VS Code doesn't pick up TS path aliases unless you open /apps/scandic-web instead of root (will be fixed with monorepo) * Pin framer-motion to temporarily fix typing issue https://github.com/adobe/react-spectrum/issues/7494 * Pin zod to avoid typ error There seems to have been a breaking change in the types returned by zod where error is now returned as undefined instead of missing in the type. We should just handle this but to avoid merge conflicts just pin the dependency for now. * Pin react-intl version Pin version of react-intl to avoid tiny type issue where formatMessage does not accept a generic any more. This will be fixed in a future commit, but to avoid merge conflicts just pin for now. * Pin typescript version Temporarily pin version as newer versions as stricter and results in a type error. Will be fixed in future commit after merge. * Setup workspaces * Add design-system as a monorepo package * Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN * Fix husky for monorepo setup * Update netlify.toml * Add lint script to root package.json * Add stub readme * Fix react-intl formatMessage types * Test netlify.toml in root * Remove root toml * Update netlify.toml publish path * Remove package-lock.json * Update build for branch/preview builds Approved-by: Linus Flood
This commit is contained in:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
9
apps/scandic-web/server/routers/booking/index.ts
Normal file
9
apps/scandic-web/server/routers/booking/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { mergeRouters } from "@/server/trpc"
|
||||
|
||||
import { bookingMutationRouter } from "./mutation"
|
||||
import { bookingQueryRouter } from "./query"
|
||||
|
||||
export const bookingRouter = mergeRouters(
|
||||
bookingMutationRouter,
|
||||
bookingQueryRouter
|
||||
)
|
||||
118
apps/scandic-web/server/routers/booking/input.ts
Normal file
118
apps/scandic-web/server/routers/booking/input.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { ChildBedTypeEnum } from "@/constants/booking"
|
||||
import { Lang, langToApiLang } from "@/constants/languages"
|
||||
|
||||
const signupSchema = z.discriminatedUnion("becomeMember", [
|
||||
z.object({
|
||||
dateOfBirth: z.string(),
|
||||
postalCode: z.string(),
|
||||
becomeMember: z.literal<boolean>(true),
|
||||
}),
|
||||
z.object({ becomeMember: z.literal<boolean>(false) }),
|
||||
])
|
||||
|
||||
const roomsSchema = z.array(
|
||||
z.object({
|
||||
adults: z.number().int().nonnegative(),
|
||||
childrenAges: z
|
||||
.array(
|
||||
z.object({
|
||||
age: z.number().int().nonnegative(),
|
||||
bedType: z.nativeEnum(ChildBedTypeEnum),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
rateCode: z.string(),
|
||||
roomTypeCode: z.coerce.string(),
|
||||
guest: z.intersection(
|
||||
z.object({
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
email: z.string().email(),
|
||||
phoneNumber: z.string(),
|
||||
countryCode: z.string(),
|
||||
membershipNumber: z.string().optional(),
|
||||
}),
|
||||
signupSchema
|
||||
),
|
||||
smsConfirmationRequested: z.boolean(),
|
||||
packages: z.object({
|
||||
breakfast: z.boolean(),
|
||||
allergyFriendly: z.boolean(),
|
||||
petFriendly: z.boolean(),
|
||||
accessibility: z.boolean(),
|
||||
}),
|
||||
roomPrice: z.object({
|
||||
publicPrice: z.number().or(z.string().transform((val) => Number(val))),
|
||||
memberPrice: z
|
||||
.number()
|
||||
.or(z.string().transform((val) => Number(val)))
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
// Mutation
|
||||
export const createBookingInput = z.object({
|
||||
hotelId: z.string(),
|
||||
checkInDate: z.string(),
|
||||
checkOutDate: z.string(),
|
||||
rooms: roomsSchema,
|
||||
payment: paymentSchema,
|
||||
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
|
||||
})
|
||||
|
||||
export const addPackageInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
ancillaryComment: z.string(),
|
||||
ancillaryDeliveryTime: z.string().optional(),
|
||||
packages: z.array(
|
||||
z.object({
|
||||
code: z.string(),
|
||||
quantity: z.number(),
|
||||
comment: z.string().optional(),
|
||||
})
|
||||
),
|
||||
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
|
||||
})
|
||||
|
||||
export const priceChangeInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
})
|
||||
|
||||
export const cancelBookingInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]),
|
||||
})
|
||||
|
||||
// Query
|
||||
const confirmationNumberInput = z.object({
|
||||
confirmationNumber: z.string(),
|
||||
})
|
||||
|
||||
export const bookingConfirmationInput = confirmationNumberInput
|
||||
|
||||
export const getBookingStatusInput = confirmationNumberInput
|
||||
382
apps/scandic-web/server/routers/booking/mutation.ts
Normal file
382
apps/scandic-web/server/routers/booking/mutation.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { getVerifiedUser } from "@/server/routers/user/query"
|
||||
import { router, safeProtectedServiceProcedure } from "@/server/trpc"
|
||||
|
||||
import { getMembership } from "@/utils/user"
|
||||
|
||||
import {
|
||||
cancelBookingInput,
|
||||
addPackageInput,
|
||||
createBookingInput,
|
||||
priceChangeInput,
|
||||
} from "./input"
|
||||
import { createBookingSchema } from "./output"
|
||||
|
||||
import type { Session } from "next-auth"
|
||||
|
||||
const meter = metrics.getMeter("trpc.bookings")
|
||||
const createBookingCounter = meter.createCounter("trpc.bookings.create")
|
||||
const createBookingSuccessCounter = meter.createCounter(
|
||||
"trpc.bookings.create-success"
|
||||
)
|
||||
const createBookingFailCounter = meter.createCounter(
|
||||
"trpc.bookings.create-fail"
|
||||
)
|
||||
|
||||
const priceChangeCounter = meter.createCounter("trpc.bookings.price-change")
|
||||
const priceChangeSuccessCounter = meter.createCounter(
|
||||
"trpc.bookings.price-change-success"
|
||||
)
|
||||
const priceChangeFailCounter = meter.createCounter(
|
||||
"trpc.bookings.price-change-fail"
|
||||
)
|
||||
const cancelBookingCounter = meter.createCounter("trpc.bookings.cancel")
|
||||
const cancelBookingSuccessCounter = meter.createCounter(
|
||||
"trpc.bookings.cancel-success"
|
||||
)
|
||||
const cancelBookingFailCounter = meter.createCounter(
|
||||
"trpc.bookings.cancel-fail"
|
||||
)
|
||||
|
||||
const addPackageCounter = meter.createCounter("trpc.bookings.add-package")
|
||||
const addPackageSuccessCounter = meter.createCounter(
|
||||
"trpc.bookings.add-package-success"
|
||||
)
|
||||
const addPackageFailCounter = meter.createCounter(
|
||||
"trpc.bookings.add-package-fail"
|
||||
)
|
||||
|
||||
async function getMembershipNumber(
|
||||
session: Session | null
|
||||
): Promise<string | undefined> {
|
||||
if (!session) return undefined
|
||||
|
||||
const verifiedUser = await getVerifiedUser({ session })
|
||||
if (!verifiedUser || "error" in verifiedUser) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const membership = getMembership(verifiedUser.data.memberships)
|
||||
return membership?.membershipNumber
|
||||
}
|
||||
|
||||
export const bookingMutationRouter = router({
|
||||
create: safeProtectedServiceProcedure
|
||||
.input(createBookingInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
const { language, ...inputWithoutLang } = input
|
||||
const { hotelId, checkInDate, checkOutDate } = inputWithoutLang
|
||||
|
||||
const loggingAttributes = {
|
||||
membershipNumber: await getMembershipNumber(ctx.session),
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
hotelId,
|
||||
language,
|
||||
}
|
||||
|
||||
createBookingCounter.add(1, loggingAttributes)
|
||||
|
||||
console.info(
|
||||
"api.booking.create start",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
})
|
||||
)
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
|
||||
const apiResponse = await api.post(
|
||||
api.endpoints.v1.Booking.bookings,
|
||||
{
|
||||
headers,
|
||||
body: inputWithoutLang,
|
||||
},
|
||||
{ language }
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
createBookingFailCounter.add(1, {
|
||||
hotelId,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.create error",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
error: text,
|
||||
},
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
createBookingFailCounter.add(1, {
|
||||
hotelId,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
error_type: "validation_error",
|
||||
})
|
||||
|
||||
console.error(
|
||||
"api.booking.create validation error",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
createBookingSuccessCounter.add(1, {
|
||||
hotelId,
|
||||
checkInDate,
|
||||
checkOutDate,
|
||||
})
|
||||
|
||||
console.info(
|
||||
"api.booking.create success",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
})
|
||||
)
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
priceChange: safeProtectedServiceProcedure
|
||||
.input(priceChangeInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
const { confirmationNumber } = input
|
||||
|
||||
priceChangeCounter.add(1, { confirmationNumber })
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
|
||||
const apiResponse = await api.put(
|
||||
api.endpoints.v1.Booking.priceChange(confirmationNumber),
|
||||
{
|
||||
headers,
|
||||
body: input,
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
priceChangeFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.priceChange error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
error: text,
|
||||
},
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
priceChangeFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
})
|
||||
console.error(
|
||||
"api.booking.priceChange validation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
priceChangeSuccessCounter.add(1, { confirmationNumber })
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
cancel: safeProtectedServiceProcedure
|
||||
.input(cancelBookingInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
const { confirmationNumber, language } = input
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
|
||||
const cancellationReason = {
|
||||
reasonCode: "WEB-CANCEL",
|
||||
reason: "WEB-CANCEL",
|
||||
}
|
||||
|
||||
const loggingAttributes = {
|
||||
confirmationNumber,
|
||||
language,
|
||||
}
|
||||
|
||||
cancelBookingCounter.add(1, loggingAttributes)
|
||||
|
||||
console.info(
|
||||
"api.booking.cancel start",
|
||||
JSON.stringify({
|
||||
request: loggingAttributes,
|
||||
headers,
|
||||
})
|
||||
)
|
||||
|
||||
const apiResponse = await api.remove(
|
||||
api.endpoints.v1.Booking.cancel(confirmationNumber),
|
||||
{
|
||||
headers,
|
||||
body: JSON.stringify(cancellationReason),
|
||||
} as RequestInit,
|
||||
{ language }
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
cancelBookingFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.cancel error",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
query: loggingAttributes,
|
||||
})
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
|
||||
if (!verifiedData.success) {
|
||||
cancelBookingFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
})
|
||||
|
||||
console.error(
|
||||
"api.booking.cancel validation error",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
cancelBookingSuccessCounter.add(1, loggingAttributes)
|
||||
|
||||
console.info(
|
||||
"api.booking.cancel success",
|
||||
JSON.stringify({
|
||||
query: loggingAttributes,
|
||||
})
|
||||
)
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
packages: safeProtectedServiceProcedure
|
||||
.input(addPackageInput)
|
||||
.mutation(async function ({ ctx, input }) {
|
||||
const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
const { confirmationNumber, ...body } = input
|
||||
|
||||
addPackageCounter.add(1, { confirmationNumber })
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
|
||||
const apiResponse = await api.post(
|
||||
api.endpoints.v1.Booking.packages(confirmationNumber),
|
||||
{
|
||||
headers,
|
||||
body: body,
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
addPackageFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.addPackage error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
error: text,
|
||||
},
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
addPackageFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
})
|
||||
console.error(
|
||||
"api.booking.addPackage validation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
addPackageSuccessCounter.add(1, { confirmationNumber })
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
})
|
||||
223
apps/scandic-web/server/routers/booking/output.ts
Normal file
223
apps/scandic-web/server/routers/booking/output.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { ChildBedTypeEnum } from "@/constants/booking"
|
||||
|
||||
import { phoneValidator } from "@/utils/zod/phoneValidator"
|
||||
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||
|
||||
// MUTATION
|
||||
export const createBookingSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
reservationStatus: z.string(),
|
||||
paymentUrl: 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,
|
||||
rooms: d.data.attributes.rooms,
|
||||
errors: d.data.attributes.errors,
|
||||
}))
|
||||
|
||||
// QUERY
|
||||
const childBedPreferencesSchema = z.object({
|
||||
bedType: z.nativeEnum(ChildBedTypeEnum),
|
||||
quantity: z.number().int(),
|
||||
code: z.string().nullable().default(""),
|
||||
})
|
||||
|
||||
const guestSchema = z.object({
|
||||
email: z.string().email().nullable().default(""),
|
||||
firstName: z.string().nullable().default(""),
|
||||
lastName: z.string().nullable().default(""),
|
||||
membershipNumber: z.string().nullable().default(""),
|
||||
phoneNumber: phoneValidator().nullable().default(""),
|
||||
})
|
||||
|
||||
const packageSchema = z
|
||||
.object({
|
||||
description: z.string().nullable().default(""),
|
||||
type: z.string().nullable().default(""),
|
||||
code: z.string().nullable().default(""),
|
||||
price: z.object({
|
||||
unit: z.number().int().nullable(),
|
||||
unitPrice: z.number(),
|
||||
totalPrice: z.number().nullable(),
|
||||
totalUnit: z.number().int().nullable(),
|
||||
currency: z.string().default(""),
|
||||
points: z.number().int().nullable(),
|
||||
}),
|
||||
})
|
||||
.transform((packageData) => ({
|
||||
description: packageData.description,
|
||||
code: packageData.code,
|
||||
type: packageData.type,
|
||||
currency: packageData.price.currency,
|
||||
points: packageData.price.points,
|
||||
totalPrice: packageData.price.totalPrice ?? 0,
|
||||
totalUnit: packageData.price.totalUnit ?? 0,
|
||||
unit: packageData.price.unit ?? 0,
|
||||
unitPrice: packageData.price.unitPrice,
|
||||
}))
|
||||
|
||||
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().nullable().default(""),
|
||||
title: z.string().nullable().default(""),
|
||||
})
|
||||
|
||||
export const linkedReservationsSchema = 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(),
|
||||
})
|
||||
|
||||
export const bookingConfirmationSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
adults: z.number().int(),
|
||||
ancillary: ancillarySchema,
|
||||
checkInDate: z.date({ coerce: true }),
|
||||
checkOutDate: z.date({ coerce: true }),
|
||||
childBedPreferences: z.array(childBedPreferencesSchema).default([]),
|
||||
childrenAges: z.array(z.number().int()).default([]),
|
||||
canChangeDate: z.boolean(),
|
||||
computedReservationStatus: z.string().nullable().default(""),
|
||||
confirmationNumber: z.string().nullable().default(""),
|
||||
createDateTime: z.date({ coerce: true }),
|
||||
currencyCode: z.string(),
|
||||
guest: guestSchema,
|
||||
isGuaranteedForLateArrival: z.boolean().optional(),
|
||||
linkedReservations: z.array(linkedReservationsSchema).default([]),
|
||||
hotelId: z.string(),
|
||||
packages: z.array(packageSchema).default([]),
|
||||
rateDefinition: rateDefinitionSchema,
|
||||
reservationStatus: z.string().nullable().default(""),
|
||||
roomPrice: z.number(),
|
||||
roomTypeCode: z.string().nullable().default(""),
|
||||
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,
|
||||
showAncillaries: !!data.links.addAncillary,
|
||||
isCancelable: !!data.links.cancel,
|
||||
isModifiable: !!data.links.modify,
|
||||
}))
|
||||
231
apps/scandic-web/server/routers/booking/query.ts
Normal file
231
apps/scandic-web/server/routers/booking/query.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc"
|
||||
import {
|
||||
router,
|
||||
safeProtectedServiceProcedure,
|
||||
serviceProcedure,
|
||||
} from "@/server/trpc"
|
||||
|
||||
import { getHotel } from "../hotels/query"
|
||||
import { bookingConfirmationInput, getBookingStatusInput } from "./input"
|
||||
import { bookingConfirmationSchema, createBookingSchema } from "./output"
|
||||
import { getBookedHotelRoom } from "./utils"
|
||||
|
||||
const meter = metrics.getMeter("trpc.booking")
|
||||
const getBookingConfirmationCounter = meter.createCounter(
|
||||
"trpc.booking.confirmation"
|
||||
)
|
||||
const getBookingConfirmationSuccessCounter = meter.createCounter(
|
||||
"trpc.booking.confirmation-success"
|
||||
)
|
||||
const getBookingConfirmationFailCounter = meter.createCounter(
|
||||
"trpc.booking.confirmation-fail"
|
||||
)
|
||||
|
||||
const getBookingStatusCounter = meter.createCounter("trpc.booking.status")
|
||||
const getBookingStatusSuccessCounter = meter.createCounter(
|
||||
"trpc.booking.status-success"
|
||||
)
|
||||
const getBookingStatusFailCounter = meter.createCounter(
|
||||
"trpc.booking.status-fail"
|
||||
)
|
||||
|
||||
export const bookingQueryRouter = router({
|
||||
confirmation: safeProtectedServiceProcedure
|
||||
.input(bookingConfirmationInput)
|
||||
.query(async function ({ ctx, input: { confirmationNumber } }) {
|
||||
getBookingConfirmationCounter.add(1, { confirmationNumber })
|
||||
|
||||
const token = ctx.session?.token.access_token ?? ctx.serviceToken
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Booking.booking(confirmationNumber),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const responseMessage = await apiResponse.text()
|
||||
getBookingConfirmationFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: responseMessage,
|
||||
})
|
||||
console.error(
|
||||
"api.booking.confirmation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text: responseMessage,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// 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) {
|
||||
return null
|
||||
}
|
||||
|
||||
throw serverErrorByStatus(apiResponse.status, apiResponse)
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const booking = bookingConfirmationSchema.safeParse(apiJson)
|
||||
if (!booking.success) {
|
||||
getBookingConfirmationFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(booking.error),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.confirmation validation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: booking.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
const hotelData = await getHotel(
|
||||
{
|
||||
hotelId: booking.data.hotelId,
|
||||
isCardOnlyPayment: false,
|
||||
language: ctx.lang,
|
||||
},
|
||||
ctx.serviceToken
|
||||
)
|
||||
|
||||
if (!hotelData) {
|
||||
getBookingConfirmationFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
hotelId: booking.data.hotelId,
|
||||
error_type: "http_error",
|
||||
error: "Couldn`t get hotel",
|
||||
})
|
||||
console.error(
|
||||
"api.booking.confirmation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber, hotelId: booking.data.hotelId },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text: "Couldn`t get hotel",
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw serverErrorByStatus(404)
|
||||
}
|
||||
|
||||
getBookingConfirmationSuccessCounter.add(1, { confirmationNumber })
|
||||
console.info(
|
||||
"api.booking.confirmation success",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Add hotels check in and out times to booking check in and out date
|
||||
* as that is date only (YYYY-MM-DD)
|
||||
*/
|
||||
const checkInTime = hotelData.hotel.hotelFacts.checkin.checkInTime
|
||||
const [checkInHour, checkInMinute] = checkInTime.split(":")
|
||||
const checkIn = dt(booking.data.checkInDate)
|
||||
.set("hour", Number(checkInHour))
|
||||
.set("minute", Number(checkInMinute))
|
||||
const checkOutTime = hotelData.hotel.hotelFacts.checkin.checkOutTime
|
||||
const [checkOutHour, checkOutMinute] = checkOutTime.split(":")
|
||||
const checkOut = dt(booking.data.checkOutDate)
|
||||
.set("hour", Number(checkOutHour))
|
||||
.set("minute", Number(checkOutMinute))
|
||||
|
||||
booking.data.checkInDate = checkIn.toDate()
|
||||
booking.data.checkOutDate = checkOut.toDate()
|
||||
|
||||
return {
|
||||
...hotelData,
|
||||
booking: booking.data,
|
||||
room: getBookedHotelRoom(
|
||||
hotelData.roomCategories,
|
||||
booking.data.roomTypeCode
|
||||
),
|
||||
}
|
||||
}),
|
||||
status: serviceProcedure.input(getBookingStatusInput).query(async function ({
|
||||
ctx,
|
||||
input,
|
||||
}) {
|
||||
const { confirmationNumber } = input
|
||||
getBookingStatusCounter.add(1, { confirmationNumber })
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Booking.status(confirmationNumber),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const responseMessage = await apiResponse.text()
|
||||
getBookingStatusFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "http_error",
|
||||
error: responseMessage,
|
||||
})
|
||||
console.error(
|
||||
"api.booking.status error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text: responseMessage,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw serverErrorByStatus(apiResponse.status, apiResponse)
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedData = createBookingSchema.safeParse(apiJson)
|
||||
if (!verifiedData.success) {
|
||||
getBookingStatusFailCounter.add(1, {
|
||||
confirmationNumber,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(verifiedData.error),
|
||||
})
|
||||
console.error(
|
||||
"api.booking.status validation error",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
error: verifiedData.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
getBookingStatusSuccessCounter.add(1, { confirmationNumber })
|
||||
console.info(
|
||||
"api.booking.status success",
|
||||
JSON.stringify({
|
||||
query: { confirmationNumber },
|
||||
})
|
||||
)
|
||||
|
||||
return verifiedData.data
|
||||
}),
|
||||
})
|
||||
27
apps/scandic-web/server/routers/booking/utils.ts
Normal file
27
apps/scandic-web/server/routers/booking/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Room } from "@/types/hotel"
|
||||
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
export function getBookedHotelRoom(
|
||||
rooms: Room[] | undefined,
|
||||
roomTypeCode: BookingConfirmation["booking"]["roomTypeCode"]
|
||||
) {
|
||||
if (!rooms?.length || !roomTypeCode) {
|
||||
return null
|
||||
}
|
||||
const room = rooms?.find((r) => {
|
||||
return r.roomTypes.find((roomType) => roomType.code === roomTypeCode)
|
||||
})
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
const bedType = room.roomTypes.find(
|
||||
(roomType) => roomType.code === roomTypeCode
|
||||
)
|
||||
if (!bedType) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...room,
|
||||
bedType,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user