374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import type { BookingResponseDto, WaitlistResponseDto, WaitlistStatus } from '@/api/generated/models'
|
|
import {
|
|
errorResult,
|
|
handleServiceError,
|
|
type ServiceCallOptions,
|
|
type ServiceResult,
|
|
shouldBubbleErrorToParent,
|
|
successResult,
|
|
} from '@/services/errorHandler'
|
|
import { unwrapApiData } from '@/services/apiResponse'
|
|
import { getBookings } from '@/api/generated/bookings/bookings'
|
|
import { getWaitlist } from '@/api/generated/waitlist/waitlist'
|
|
import { unwrapApiPayload } from '@/helpers/listResponse'
|
|
import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics'
|
|
import { getEventAttribution } from '@/lib/trafficAttribution'
|
|
import { texts } from '@/texts'
|
|
|
|
// Shape mirrors the admin `bookingsColumns` fields used for the same
|
|
// underlying entity in app/(dashboard)/users/[id]/page.tsx (bookingCode,
|
|
// eventTitle, status, checkedInAt, createdAt) — plus `id`/`eventId`, which
|
|
// this self-service list needs for keys and the booking-detail modal.
|
|
// The `event*` fields come from bookingSelect's event relation
|
|
// (backend/src/modules/bookings/bookings.select.ts) — added alongside this
|
|
// service so the list + detail views don't need a second round-trip to
|
|
// /events/:id for title/price/fee.
|
|
export type MyBooking = BookingResponseDto
|
|
|
|
export type MyWaitlistEntry = WaitlistResponseDto
|
|
|
|
// Waitlist entries still considered "in progress" for this event — mirrors
|
|
// the DB constraint in waitlist.service.ts (assertNoActiveWaitlist).
|
|
const ACTIVE_WAITLIST_STATUSES: WaitlistStatus[] = ['waiting', 'notified', 'accepted']
|
|
const bookingsApi = getBookings()
|
|
const waitlistApi = getWaitlist()
|
|
|
|
export const GET_MY_BOOKINGS = async (options?: ServiceCallOptions): Promise<ServiceResult<MyBooking[]>> => {
|
|
try {
|
|
const res = await bookingsApi.bookingsControllerListMine({ page: 1, pageSize: 100 })
|
|
const payload = unwrapApiPayload(res.data)
|
|
const items = Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []
|
|
|
|
return successResult(items as MyBooking[])
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** Guest's own active (pending_payment/confirmed) booking for one event, if any — event-booking.md entry checks. */
|
|
export const GET_MY_ACTIVE_BOOKING_FOR_EVENT = async (
|
|
eventId: string,
|
|
options?: ServiceCallOptions
|
|
): Promise<ServiceResult<MyBooking | null>> => {
|
|
try {
|
|
// At most one active booking per (user, event); list endpoint still
|
|
// requires pagination, so pageSize=1 is enough for this lookup.
|
|
const res = await bookingsApi.bookingsControllerListMine({
|
|
filters: { eventId },
|
|
pageSize: 1,
|
|
sort: '-createdAt',
|
|
})
|
|
const payload = unwrapApiPayload(res.data)
|
|
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyBooking[]
|
|
const latest = items[0]
|
|
const active = latest && (latest.status === 'pending_payment' || latest.status === 'confirmed') ? latest : null
|
|
|
|
return successResult(active)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** Guest's own active waitlist entry for one event, if any — event-booking.md Path B. */
|
|
export const GET_MY_WAITLIST_ENTRY_FOR_EVENT = async (
|
|
eventId: string,
|
|
options?: ServiceCallOptions
|
|
): Promise<ServiceResult<MyWaitlistEntry | null>> => {
|
|
try {
|
|
// At most one active waitlist row per (user, event); pageSize=1 is enough.
|
|
const res = await waitlistApi.waitlistControllerListMine({
|
|
filters: { eventId },
|
|
pageSize: 1,
|
|
sort: '-createdAt',
|
|
})
|
|
const payload = unwrapApiPayload(res.data)
|
|
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[]
|
|
const latest = items[0]
|
|
const active = latest && ACTIVE_WAITLIST_STATUSES.includes(latest.status) ? latest : null
|
|
|
|
return successResult(active)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** POST /events/:eventId/bookings — direct reserve path (event-booking.md, Path A). */
|
|
export const CREATE_BOOKING = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyBooking>> => {
|
|
try {
|
|
const attributionSource = getEventAttribution(eventId)
|
|
const res = await bookingsApi.bookingsControllerCreate(eventId, {
|
|
...(attributionSource ? { attributionSource } : {}),
|
|
})
|
|
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
|
|
if (data?.id) {
|
|
trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CREATED, data.id, {
|
|
booking_id: data.id,
|
|
event_id: eventId,
|
|
booking_status: data.status,
|
|
...(attributionSource ? { traffic_source: attributionSource } : {}),
|
|
})
|
|
}
|
|
|
|
if (!data) {
|
|
throw new Error(texts.bookings.emptyServerResponse)
|
|
}
|
|
|
|
return successResult(data)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** POST /events/:eventId/waitlist — join waitlist path (event-booking.md, Path B). */
|
|
export const JOIN_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyWaitlistEntry>> => {
|
|
try {
|
|
const attributionSource = getEventAttribution(eventId)
|
|
const res = await waitlistApi.waitlistControllerJoin(eventId, {
|
|
...(attributionSource ? { attributionSource } : {}),
|
|
})
|
|
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
|
|
if (!data) {
|
|
throw new Error(texts.bookings.emptyServerResponse)
|
|
}
|
|
|
|
return successResult(data)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /events/:eventId/waitlist — host-only view of the waiting queue for
|
|
* their own event, primarily used to hand-pick who gets an opened seat when
|
|
* `settings.waitlistAutoOffer` is `false`.
|
|
*/
|
|
export const GET_EVENT_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyWaitlistEntry[]>> => {
|
|
try {
|
|
const res = await waitlistApi.waitlistControllerListForEvent(eventId, { pageSize: 100 })
|
|
const payload = unwrapApiPayload(res.data)
|
|
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[]
|
|
|
|
return successResult(items)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** POST /events/:eventId/waitlist/:entryId/offer — host manually offers a seat to a chosen entry. */
|
|
export const OFFER_WAITLIST_SEAT = async (
|
|
eventId: string,
|
|
entryId: string,
|
|
options?: ServiceCallOptions
|
|
): Promise<ServiceResult<MyWaitlistEntry>> => {
|
|
try {
|
|
const res = await waitlistApi.waitlistControllerOfferToEntry(eventId, entryId)
|
|
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
|
|
if (!data) {
|
|
throw new Error(texts.bookings.emptyServerResponse)
|
|
}
|
|
|
|
return successResult(data)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** PATCH /waitlist/:id/accept — guest accepts a notified offer and receives the pending booking for checkout. */
|
|
export const ACCEPT_WAITLIST = async (
|
|
entryId: string,
|
|
options?: ServiceCallOptions
|
|
): Promise<ServiceResult<{ waitlistEntryId: string; booking: MyBooking }>> => {
|
|
try {
|
|
const res = await waitlistApi.waitlistControllerAccept(entryId)
|
|
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
|
|
if (!data?.booking) {
|
|
throw new Error(texts.bookings.emptyServerResponse)
|
|
}
|
|
|
|
return successResult({ waitlistEntryId: data.waitlistEntryId, booking: data.booking as MyBooking })
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** GET /bookings/:id — single booking detail (owner only). */
|
|
export const GET_BOOKING = async (id: string, options?: ServiceCallOptions): Promise<ServiceResult<MyBooking>> => {
|
|
try {
|
|
const res = await bookingsApi.bookingsControllerFindOne(id)
|
|
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
|
|
if (!data) {
|
|
throw new Error(texts.bookings.emptyServerResponse)
|
|
}
|
|
|
|
return successResult(data)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/** PATCH /bookings/:id/cancel — guest-initiated cancel with mandatory reason (booking-cancellation-refund.md). */
|
|
export const CANCEL_BOOKING = async (
|
|
id: string,
|
|
cancellationReason: string,
|
|
options?: ServiceCallOptions
|
|
): Promise<ServiceResult<MyBooking>> => {
|
|
try {
|
|
const res = await bookingsApi.bookingsControllerCancel(id, { cancellationReason })
|
|
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
|
|
if (!data) {
|
|
throw new Error(texts.bookings.emptyServerResponse)
|
|
}
|
|
|
|
trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CANCELLED, data.id, {
|
|
booking_id: data.id,
|
|
event_id: data.eventId,
|
|
booking_status: data.status,
|
|
})
|
|
|
|
return successResult(data)
|
|
} catch (error) {
|
|
const normalizedError = handleServiceError(error, options)
|
|
|
|
if (shouldBubbleErrorToParent(options)) {
|
|
throw normalizedError
|
|
}
|
|
|
|
return errorResult(normalizedError)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Client-side guest-cancel refund preview — booking-cancellation-refund.md
|
|
* formula: refund = paidGross - selected cancellation fee. The platform
|
|
* commission is taken only from that fee and the remainder belongs to the
|
|
* host after the event completes.
|
|
* Mirrors refund.service.ts#refundGuestCancellation exactly (same
|
|
* floor() rounding on each component) so this preview never shows a
|
|
* different number than what's actually credited.
|
|
*
|
|
* `ticketPrice` must be the **amount actually paid** (`payments.total_amount`
|
|
* / `MyBooking.payableAmount`), not the list `eventPrice`. When a discount
|
|
* code was applied, those diverge — using list price overstates commission,
|
|
* host fee, and the toast refund. Callers: `payableAmount ?? eventPrice`.
|
|
*
|
|
* `commissionPercent` must be the booking's resolved rate
|
|
* (`MyBooking.eventEffectiveCommissionPercent` — event override if set, else
|
|
* the platform default; see bookings.service.ts#mapBooking on the backend)
|
|
* and NOT a locally hardcoded constant: the platform default has changed
|
|
* before (COMMISSION_PERCENT, backend/src/common/constants/business.constants.ts)
|
|
* and events can carry their own override, so any constant duplicated here
|
|
* silently drifts from what actually gets charged. Only meaningful for a
|
|
* `confirmed` (paid) booking; `pending_payment` has nothing to refund
|
|
* (never actually paid).
|
|
*/
|
|
export interface CancellationPreview {
|
|
ticketPrice: number
|
|
platformCommission: number
|
|
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
|
|
commissionPercent: number
|
|
cancellationFeePercent: number
|
|
hostCancellationFee: number
|
|
refundToGuest: number
|
|
}
|
|
|
|
export interface CancellationFeePolicy {
|
|
cancellationFeePercent: number
|
|
cancellationFeePercent12To24Hours: number
|
|
cancellationFeePercentMoreThan24Hours: number
|
|
}
|
|
|
|
const HOUR_MS = 60 * 60 * 1000
|
|
|
|
export const resolveCancellationFeePercent = (
|
|
policy: CancellationFeePolicy,
|
|
eventStartsAt: string | Date,
|
|
cancelledAt: Date = new Date()
|
|
): number => {
|
|
const remainingMs = new Date(eventStartsAt).getTime() - cancelledAt.getTime()
|
|
|
|
if (remainingMs <= 12 * HOUR_MS) return policy.cancellationFeePercent
|
|
if (remainingMs <= 24 * HOUR_MS) return policy.cancellationFeePercent12To24Hours
|
|
|
|
return policy.cancellationFeePercentMoreThan24Hours
|
|
}
|
|
|
|
export const calculateCancellationPreview = (
|
|
ticketPrice: number,
|
|
policy: CancellationFeePolicy,
|
|
commissionPercent: number,
|
|
eventStartsAt: string | Date,
|
|
cancelledAt: Date = new Date()
|
|
): CancellationPreview => {
|
|
const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
|
|
const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
|
|
const platformCommission = Math.floor(grossCancellationFee * (commissionPercent / 100))
|
|
const hostCancellationFee = grossCancellationFee - platformCommission
|
|
const refundToGuest = ticketPrice - grossCancellationFee
|
|
|
|
return { ticketPrice, platformCommission, commissionPercent, cancellationFeePercent, hostCancellationFee, refundToGuest }
|
|
}
|
|
|
|
/** Gross base for guest-cancel preview — paid amount after discount, else list price. */
|
|
export const resolveCancellationTicketPrice = (booking: Pick<MyBooking, 'eventPrice' | 'payableAmount'>): number =>
|
|
booking.payableAmount ?? booking.eventPrice
|