Merged in feat/booking-flow-submit (pull request #580)

This implements the actual call to the API to create a booking. That’s the only thing it does, it doesn’t handle the response in any way.

This PR is just to get it there and the new booking sub team will handle it further, with payment etc.

Approved-by: Michael Zetterberg
Approved-by: Fredrik Thorsson
Approved-by: Simon.Emanuelsson
This commit is contained in:
Niclas Edenvin
2024-09-20 13:05:23 +00:00
parent d1621f77ac
commit b9dbcf7d90
9 changed files with 268 additions and 5 deletions

View File

@@ -1,6 +1,62 @@
"use client"
import styles from "./payment.module.css"
import { trpc } from "@/lib/trpc/client"
import Button from "@/components/TempDesignSystem/Button"
export default function Payment() {
return <div className={styles.wrapper}>Payment TBI</div>
const initiateBooking = trpc.booking.booking.create.useMutation({
onSuccess: (result) => {
// TODO: Handle success, poll for payment link and redirect the user to the payment
console.log("Res", result)
},
onError: () => {
// TODO: Handle error
console.log("Error")
},
})
return (
<Button
onClick={() =>
// TODO: Use real values
initiateBooking.mutate({
hotelId: "811",
checkInDate: "2024-12-10",
checkOutDate: "2024-12-11",
rooms: [
{
adults: 1,
children: 0,
rateCode: "SAVEEU",
roomTypeCode: "QC",
guest: {
title: "Mr",
firstName: "Test",
lastName: "User",
email: "test.user@scandichotels.com",
phoneCountryCodePrefix: "string",
phoneNumber: "string",
countryCode: "string",
},
smsConfirmationRequested: true,
},
],
payment: {
cardHolder: {
Email: "test.user@scandichotels.com",
Name: "Test User",
PhoneCountryCode: "",
PhoneSubscriber: "",
},
success: "success/handle",
error: "error/handle",
cancel: "cancel/handle",
},
})
}
>
Create booking
</Button>
)
}

View File

@@ -1,2 +0,0 @@
.wrapper {
}

View File

@@ -16,6 +16,7 @@ export namespace endpoints {
hotels = "hotel/v1/Hotels",
intiateSaveCard = `${creditCards}/initiateSaveCard`,
deleteCreditCard = `${profile}/creditCards`,
booking = "booking/v1/Bookings",
}
}

View File

@@ -1,10 +1,12 @@
/** Routers */
import { bookingRouter } from "./routers/booking"
import { contentstackRouter } from "./routers/contentstack"
import { hotelsRouter } from "./routers/hotels"
import { userRouter } from "./routers/user"
import { router } from "./trpc"
export const appRouter = router({
booking: bookingRouter,
contentstack: contentstackRouter,
hotel: hotelsRouter,
user: userRouter,

View File

@@ -0,0 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { bookingMutationRouter } from "./mutation"
export const bookingRouter = mergeRouters(bookingMutationRouter)

View File

@@ -0,0 +1,38 @@
import { z } from "zod"
// Query
// Mutation
export const createBookingInput = z.object({
hotelId: z.string(),
checkInDate: z.string(),
checkOutDate: z.string(),
rooms: z.array(
z.object({
adults: z.number().int().nonnegative(),
children: z.number().int().nonnegative(),
rateCode: z.string(),
roomTypeCode: z.string(),
guest: z.object({
title: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
phoneCountryCodePrefix: z.string(),
phoneNumber: z.string(),
countryCode: z.string(),
}),
smsConfirmationRequested: z.boolean(),
})
),
payment: z.object({
cardHolder: z.object({
Email: z.string().email(),
Name: z.string(),
PhoneCountryCode: z.string(),
PhoneSubscriber: z.string(),
}),
success: z.string(),
error: z.string(),
cancel: z.string(),
}),
})

View File

@@ -0,0 +1,129 @@
import { metrics } from "@opentelemetry/api"
import * as api from "@/lib/api"
import { getVerifiedUser } from "@/server/routers/user/query"
import { router, safeProtectedProcedure } from "@/server/trpc"
import { getMembership } from "@/utils/user"
import { createBookingInput } 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"
)
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({
booking: router({
create: safeProtectedProcedure
.input(createBookingInput)
.mutation(async function ({ ctx, input }) {
const { checkInDate, checkOutDate, hotelId } = input
const loggingAttributes = {
membershipNumber: await getMembershipNumber(ctx.session),
checkInDate,
checkOutDate,
hotelId,
}
createBookingCounter.add(1, { hotelId, checkInDate, checkOutDate })
console.info(
"api.booking.booking.create start",
JSON.stringify({
query: loggingAttributes,
})
)
const headers = ctx.session
? {
Authorization: `Bearer ${ctx.session?.token.access_token}`,
}
: undefined
const apiResponse = await api.post(api.endpoints.v1.booking, {
headers,
body: input,
})
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.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.booking.create validation error",
JSON.stringify({
query: loggingAttributes,
error: verifiedData.error,
})
)
return null
}
createBookingSuccessCounter.add(1, {
hotelId,
checkInDate,
checkOutDate,
})
console.info(
"api.booking.booking.create success",
JSON.stringify({
query: loggingAttributes,
})
)
return verifiedData.data
}),
}),
})

View File

@@ -0,0 +1,34 @@
import { z } from "zod"
export const createBookingSchema = z
.object({
data: z.object({
attributes: z.object({
confirmationNumber: z.string(),
cancellationNumber: z.string().nullable(),
reservationStatus: z.string(),
paymentUrl: z.string().nullable(),
}),
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,
confirmationNumber: d.data.attributes.confirmationNumber,
cancellationNumber: d.data.attributes.cancellationNumber,
reservationStatus: d.data.attributes.reservationStatus,
paymentUrl: d.data.attributes.paymentUrl,
}))
type CreateBookingData = z.infer<typeof createBookingSchema>

View File

@@ -84,7 +84,7 @@ const getCreditCardsFailCounter = meter.createCounter(
"trpc.user.creditCards-fail"
)
async function getVerifiedUser({ session }: { session: Session }) {
export async function getVerifiedUser({ session }: { session: Session }) {
const now = Date.now()
if (session.token.expires_at && session.token.expires_at < now) {