admin/services/bookings.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

346 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 × (1 - platform commission % - cancellationFeePercent).
* 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
hostCancellationFee: number
refundToGuest: number
}
export const calculateCancellationPreview = (
ticketPrice: number,
cancellationFeePercent: number,
commissionPercent: number
): CancellationPreview => {
const platformCommission = Math.floor(ticketPrice * (commissionPercent / 100))
const hostCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
const refundToGuest = ticketPrice - platformCommission - hostCancellationFee
return { ticketPrice, platformCommission, commissionPercent, 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