From 8541c4a2d1836c615e33b9b3ca8eef34b35f976c Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 12 Apr 2026 21:43:30 +0800 Subject: [PATCH] feat(bookings): implement booking system and confirmation flow Add database tables and repository for managing bookings Create API endpoints for booking submission and capacity management Update landing page to persist bookings before WhatsApp redirection --- app/layouts/default.vue | 6 + app/pages/bookings/index.vue | 514 ++++++++++++++++++ app/pages/confirmation/[token].vue | 209 +++++++ app/pages/index.vue | 132 ++--- server/api/bookings.get.ts | 21 + server/api/bookings/capacity.patch.ts | 26 + server/api/public/bookings.post.ts | 56 ++ server/api/public/bookings/[token].get.ts | 15 + .../public/bookings/[token]/confirm.post.ts | 35 ++ server/utils/app-url.ts | 18 + server/utils/booking-repository.ts | 322 +++++++++++ server/utils/bookings.ts | 90 +++ server/utils/db-init.ts | 37 ++ server/utils/user-repository.ts | 30 + server/utils/webauthn.ts | 14 +- shared/auth.ts | 2 +- shared/booking.ts | 150 +++++ 17 files changed, 1585 insertions(+), 92 deletions(-) create mode 100644 app/pages/bookings/index.vue create mode 100644 app/pages/confirmation/[token].vue create mode 100644 server/api/bookings.get.ts create mode 100644 server/api/bookings/capacity.patch.ts create mode 100644 server/api/public/bookings.post.ts create mode 100644 server/api/public/bookings/[token].get.ts create mode 100644 server/api/public/bookings/[token]/confirm.post.ts create mode 100644 server/utils/app-url.ts create mode 100644 server/utils/booking-repository.ts create mode 100644 server/utils/bookings.ts create mode 100644 shared/booking.ts diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 01c7703..65ab727 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -326,6 +326,12 @@ const mobileMenuOpen = ref(false) await auth.fetchSession() const allSystemMenuItems: SystemMenuItem[] = [ + { + label: 'Bookings', + to: '/bookings', + icon: 'i-lucide-clipboard-list', + matches: (path) => path.startsWith('/bookings') + }, { label: 'Security', to: '/security', diff --git a/app/pages/bookings/index.vue b/app/pages/bookings/index.vue new file mode 100644 index 0000000..526dead --- /dev/null +++ b/app/pages/bookings/index.vue @@ -0,0 +1,514 @@ + + + diff --git a/app/pages/confirmation/[token].vue b/app/pages/confirmation/[token].vue new file mode 100644 index 0000000..03251c4 --- /dev/null +++ b/app/pages/confirmation/[token].vue @@ -0,0 +1,209 @@ + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index 01ba12d..59fa547 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -2,9 +2,19 @@ import type { FormError, FormSubmitEvent } from '@nuxt/ui' import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth' +import type { CreateBookingResponse } from '~~/shared/booking' +import { + BOOKING_MODE_OPTIONS, + BOOKING_TICKET_CATALOG, + formatBookingCurrency, + getSeatCount, + getTicketCatalogItem, + type BookingMode, + type TicketType +} from '~~/shared/booking' + +import { getErrorMessage } from '../utils/errors' -type BookingMode = 'table' | 'pax' -type TicketType = 'vip' | 'supporter' const toast = useToast() const apiClient = useApiClient() @@ -27,32 +37,6 @@ const eventDetails = [ } ] as const -const bookingModeOptions = [ - { - value: 'table', - label: 'Table (10 pax)' - }, - { - value: 'pax', - label: 'Person' - } -] satisfies Array<{ value: BookingMode, label: string }> - -const ticketCatalog = [ - { - value: 'vip', - label: 'VIP', - description: 'RM150 / pax', - price: 150 - }, - { - value: 'supporter', - label: 'Supporter', - description: 'RM60 / pax', - price: 60 - } -] satisfies Array<{ value: TicketType, label: string, description: string, price: number }> - const contactsResponse = await apiClient<{ contacts: PublicContact[] }>('/api/public/contacts') const personInCharge = computed(() => { return contactsResponse.contacts.map((contact) => ({ @@ -61,13 +45,6 @@ const personInCharge = computed(() => { })) }) -const priceFormatter = new Intl.NumberFormat('en-MY', { - style: 'currency', - currency: 'MYR', - minimumFractionDigits: 0, - maximumFractionDigits: 0 -}) - const form = reactive({ name: '', phone: '', @@ -82,19 +59,16 @@ const selectedPersonInChargeRecord = computed(() => { return contactsResponse.contacts.find((contact) => contact.id === selectedPersonInCharge.value) ?? null }) -const selectedTicket = computed(() => { - return ticketCatalog.find((ticket) => ticket.value === form.ticketType) ?? ticketCatalog[0] -}) - -const seatMultiplier = computed(() => form.bookingMode === 'table' ? 10 : 1) +const selectedTicket = computed(() => getTicketCatalogItem(form.ticketType) ?? BOOKING_TICKET_CATALOG[0]) +const submittingBooking = ref(false) const quantityLabel = computed(() => { return form.bookingMode === 'table' ? 'Number of tables' : 'Number of people' }) -const totalPrice = computed(() => form.quantity * seatMultiplier.value * selectedTicket.value.price) +const totalPrice = computed(() => getSeatCount(form.bookingMode, form.quantity) * selectedTicket.value.price) -const totalFormatted = computed(() => priceFormatter.format(totalPrice.value)) +const totalFormatted = computed(() => formatBookingCurrency(totalPrice.value)) function validateBooking(state: typeof form): FormError[] { const errors: FormError[] = [] @@ -116,23 +90,7 @@ function validateBooking(state: typeof form): FormError[] { return errors } -function buildBookingMessage() { - const bookingModeLabel = form.bookingMode === 'table' ? 'Table (10 pax each)' : 'Per person' - - return [ - "I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.", - '', - `Name: ${form.name.trim()}`, - `Phone Number: ${form.phone.trim()}`, - `Booking Mode: ${bookingModeLabel}`, - `Quantity: ${form.quantity}`, - `Ticket Category: ${selectedTicket.value.label}`, - `Seats Covered: ${form.quantity * seatMultiplier.value}`, - `Total Price: ${totalFormatted.value}` - ].join('\n') -} - -function bookTicket(event: FormSubmitEvent) { +async function bookTicket(event: FormSubmitEvent) { const selectedPic = selectedPersonInChargeRecord.value if (!selectedPic) { @@ -145,28 +103,46 @@ function bookTicket(event: FormSubmitEvent) { return } - const encodedMessage = encodeURIComponent(buildBookingMessage()) - const whatsappUrl = `https://wa.me/${selectedPic.phoneNumber}?text=${encodedMessage}` - const bookingWindow = window.open(whatsappUrl, '_blank', 'noopener,noreferrer') + event.preventDefault() + + submittingBooking.value = true + + try { + const response = await apiClient('/api/public/bookings', { + method: 'POST', + body: { + customerName: form.name.trim(), + customerPhone: form.phone.trim(), + bookingMode: form.bookingMode, + quantity: form.quantity, + ticketType: form.ticketType, + personInChargeId: selectedPic.id + } + }) + + const bookingWindow = window.open(response.whatsappUrl, '_blank', 'noopener,noreferrer') + + if (!bookingWindow) { + window.location.assign(response.whatsappUrl) + return + } - if (!bookingWindow) { toast.add({ - title: 'WhatsApp could not be opened', - description: 'Allow pop-ups for this site, then submit the booking again.', + title: 'WhatsApp booking draft opened', + description: `Booking details and the confirmation link were sent to ${selectedPic.fullName}.`, + color: 'success', + icon: 'i-lucide-check-circle-2' + }) + } catch (error) { + toast.add({ + title: 'Booking could not be created', + description: getErrorMessage(error, 'Please try again in a moment.'), color: 'error', icon: 'i-lucide-circle-alert' }) - return + } finally { + submittingBooking.value = false } - - toast.add({ - title: 'WhatsApp booking draft opened', - description: `Your reservation details were sent to ${selectedPic.fullName}.`, - color: 'success', - icon: 'i-lucide-check-circle-2' - }) - - event.preventDefault() } @@ -206,7 +182,7 @@ function bookTicket(event: FormSubmitEvent) { @@ -218,7 +194,7 @@ function bookTicket(event: FormSubmitEvent) { @@ -242,7 +218,7 @@ function bookTicket(event: FormSubmitEvent) { + class="w-full justify-center" :disabled="!selectedPersonInCharge" :loading="submittingBooking" /> diff --git a/server/api/bookings.get.ts b/server/api/bookings.get.ts new file mode 100644 index 0000000..3eb63ed --- /dev/null +++ b/server/api/bookings.get.ts @@ -0,0 +1,21 @@ +import { requireAuth } from '../utils/auth' +import { getBookingCapacitySettings, getBookingInventorySummary, listBookings } from '../utils/booking-repository' + +export default defineEventHandler(async (event) => { + const auth = await requireAuth(event) + const [bookings, settings, summary] = await Promise.all([ + listBookings( + auth.user.role === 'super_admin' + ? undefined + : { personInChargeId: auth.user.id } + ), + getBookingCapacitySettings(), + getBookingInventorySummary() + ]) + + return { + bookings, + settings, + summary + } +}) diff --git a/server/api/bookings/capacity.patch.ts b/server/api/bookings/capacity.patch.ts new file mode 100644 index 0000000..78314fe --- /dev/null +++ b/server/api/bookings/capacity.patch.ts @@ -0,0 +1,26 @@ +import { requireRole } from '../../utils/auth' +import { parseBookingCapacityInput } from '../../utils/bookings' +import { getBookingInventorySummary, updateBookingCapacitySettings } from '../../utils/booking-repository' +import { assertBadRequest } from '../../utils/http' + +export default defineEventHandler(async (event) => { + await requireRole(event, 'super_admin') + + const body = await readBody<{ + totalTables?: number | string | null + }>(event) + + const input = parseBookingCapacityInput(body) + const summary = await getBookingInventorySummary() + + assertBadRequest( + input.totalTables === null || (input.totalTables * 10) >= summary.soldCapacitySeats, + `Total tables cannot be lower than the currently sold capacity of ${summary.soldTables} tables and ${summary.soldSeats} seats` + ) + + const settings = await updateBookingCapacitySettings(input) + + return { + settings + } +}) diff --git a/server/api/public/bookings.post.ts b/server/api/public/bookings.post.ts new file mode 100644 index 0000000..d4a80c8 --- /dev/null +++ b/server/api/public/bookings.post.ts @@ -0,0 +1,56 @@ +import type { BookingMode, CreateBookingResponse, TicketType } from '~~/shared/booking' + +import { getTicketCatalogItem, getSeatCount } from '~~/shared/booking' + +import { buildAppUrl } from '../../utils/app-url' +import { createBooking } from '../../utils/booking-repository' +import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings' +import { assertBadRequest } from '../../utils/http' +import { getPublicContactById } from '../../utils/user-repository' + +export default defineEventHandler(async (event): Promise => { + const body = await readBody<{ + customerName?: string + customerPhone?: string + bookingMode?: BookingMode + quantity?: number + ticketType?: TicketType + personInChargeId?: string + }>(event) + + const input = parseCreateBookingInput(body) + const personInCharge = await getPublicContactById(input.personInChargeId) + + assertBadRequest(personInCharge, 'Selected person in charge is not available') + + const ticket = getTicketCatalogItem(input.ticketType) + + assertBadRequest(ticket, 'Ticket category is invalid') + + const seatCount = getSeatCount(input.bookingMode, input.quantity) + const totalPrice = seatCount * ticket.price + + const { booking, confirmationToken } = await createBooking({ + customerName: input.customerName, + customerPhone: input.customerPhone, + bookingMode: input.bookingMode, + quantity: input.quantity, + seatCount, + ticketType: input.ticketType, + unitPrice: ticket.price, + totalPrice, + personInChargeId: personInCharge.id, + personInChargeName: personInCharge.fullName, + personInChargePhoneNumber: personInCharge.phoneNumber + }) + + const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`) + const whatsappMessage = buildBookingMessage(booking, confirmationUrl) + const whatsappUrl = `https://wa.me/${booking.personInChargePhoneNumber}?text=${encodeURIComponent(whatsappMessage)}` + + return { + booking, + confirmationUrl, + whatsappUrl + } +}) diff --git a/server/api/public/bookings/[token].get.ts b/server/api/public/bookings/[token].get.ts new file mode 100644 index 0000000..6f4e06f --- /dev/null +++ b/server/api/public/bookings/[token].get.ts @@ -0,0 +1,15 @@ +import { getRequiredRouteParam, httpError } from '../../../utils/http' +import { getBookingByConfirmationToken } from '../../../utils/booking-repository' + +export default defineEventHandler(async (event) => { + const token = getRequiredRouteParam(event, 'token', 'Confirmation token') + const booking = await getBookingByConfirmationToken(token) + + if (!booking) { + httpError(404, 'Booking not found') + } + + return { + booking + } +}) diff --git a/server/api/public/bookings/[token]/confirm.post.ts b/server/api/public/bookings/[token]/confirm.post.ts new file mode 100644 index 0000000..84ec526 --- /dev/null +++ b/server/api/public/bookings/[token]/confirm.post.ts @@ -0,0 +1,35 @@ +import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository' +import { getRequiredRouteParam, httpError } from '../../../../utils/http' + +export default defineEventHandler(async (event) => { + const token = getRequiredRouteParam(event, 'token', 'Confirmation token') + const existingBooking = await getBookingByConfirmationToken(token) + + if (!existingBooking) { + httpError(404, 'Booking not found') + } + + if (existingBooking.status === 'confirmed') { + return { + booking: existingBooking, + alreadyConfirmed: true + } + } + + const summary = await getBookingInventorySummary() + + if (summary.leftCapacitySeats !== null && existingBooking.seatCount > summary.leftCapacitySeats) { + httpError(409, 'Not enough capacity left to confirm this booking') + } + + const booking = await confirmBookingByConfirmationToken(token) + + if (!booking) { + httpError(404, 'Booking not found') + } + + return { + booking, + alreadyConfirmed: false + } +}) diff --git a/server/utils/app-url.ts b/server/utils/app-url.ts new file mode 100644 index 0000000..3129765 --- /dev/null +++ b/server/utils/app-url.ts @@ -0,0 +1,18 @@ +import type { H3Event } from 'h3' + +import { getRequestURL } from 'h3' + +export function getAppOrigin(event: H3Event) { + const config = useRuntimeConfig() + + if (config.public.appUrl) { + return new URL(config.public.appUrl).origin + } + + const url = getRequestURL(event) + return `${url.protocol}//${url.host}` +} + +export function buildAppUrl(event: H3Event, path: string) { + return new URL(path, getAppOrigin(event)).toString() +} diff --git a/server/utils/booking-repository.ts b/server/utils/booking-repository.ts new file mode 100644 index 0000000..45469eb --- /dev/null +++ b/server/utils/booking-repository.ts @@ -0,0 +1,322 @@ +import { randomUUID } from 'node:crypto' + +import type { + BookingCapacitySettings, + BookingInventorySummary, + BookingMode, + BookingStatus, + PublicBooking, + TicketType +} from '~~/shared/booking' + +import { calculateBookingInventorySummary, isBookingStatus } from '~~/shared/booking' + +import { randomToken, toIsoString } from './base64url' +import { ensureDatabaseReady } from './db-init' +import { getSqlClient } from './postgres' + +type DbBookingRow = { + id: string + confirmation_token: string + customer_name: string + customer_phone: string + booking_mode: BookingMode + quantity: number | string + seat_count: number | string + ticket_type: TicketType + unit_price: number | string + total_price: number | string + person_in_charge_id: string + person_in_charge_name: string + person_in_charge_phone_number: string + status: BookingStatus | string + created_at: Date | string + confirmed_at: Date | string | null +} + +type DbBookingSettingsRow = { + total_tables: number | string | null + updated_at: Date | string +} + +function parseInteger(value: number | string) { + return typeof value === 'number' ? value : Number.parseInt(value, 10) +} + +function mapBooking(row: DbBookingRow): PublicBooking { + return { + id: row.id, + confirmationToken: row.confirmation_token, + customerName: row.customer_name, + customerPhone: row.customer_phone, + bookingMode: row.booking_mode, + quantity: parseInteger(row.quantity), + seatCount: parseInteger(row.seat_count), + ticketType: row.ticket_type, + unitPrice: parseInteger(row.unit_price), + totalPrice: parseInteger(row.total_price), + personInChargeId: row.person_in_charge_id, + personInChargeName: row.person_in_charge_name, + personInChargePhoneNumber: row.person_in_charge_phone_number, + status: isBookingStatus(row.status) ? row.status : 'pending', + createdAt: toIsoString(row.created_at) ?? new Date().toISOString(), + confirmedAt: toIsoString(row.confirmed_at) + } +} + +function mapBookingCapacitySettings(row: DbBookingSettingsRow | undefined): BookingCapacitySettings { + if (!row) { + return { + totalTables: null, + totalSeats: null, + updatedAt: null + } + } + + return { + totalTables: row.total_tables === null ? null : parseInteger(row.total_tables), + updatedAt: toIsoString(row.updated_at) + } +} + +export async function createBooking(input: { + customerName: string + customerPhone: string + bookingMode: BookingMode + quantity: number + seatCount: number + ticketType: TicketType + unitPrice: number + totalPrice: number + personInChargeId: string + personInChargeName: string + personInChargePhoneNumber: string +}) { + await ensureDatabaseReady() + const sql = getSqlClient() + const confirmationToken = randomToken(24) + + const [row] = await sql` + insert into bookings ( + id, + confirmation_token, + customer_name, + customer_phone, + booking_mode, + quantity, + seat_count, + ticket_type, + unit_price, + total_price, + person_in_charge_id, + person_in_charge_name, + person_in_charge_phone_number, + status + ) + values ( + ${randomUUID()}, + ${confirmationToken}, + ${input.customerName}, + ${input.customerPhone}, + ${input.bookingMode}, + ${input.quantity}, + ${input.seatCount}, + ${input.ticketType}, + ${input.unitPrice}, + ${input.totalPrice}, + ${input.personInChargeId}, + ${input.personInChargeName}, + ${input.personInChargePhoneNumber}, + 'pending' + ) + returning + id, + confirmation_token, + customer_name, + customer_phone, + booking_mode, + quantity, + seat_count, + ticket_type, + unit_price, + total_price, + person_in_charge_id, + person_in_charge_name, + person_in_charge_phone_number, + status, + created_at, + confirmed_at + ` + + return { + booking: mapBooking(row), + confirmationToken + } +} + +export async function getBookingByConfirmationToken(confirmationToken: string): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + select + id, + confirmation_token, + customer_name, + customer_phone, + booking_mode, + quantity, + seat_count, + ticket_type, + unit_price, + total_price, + person_in_charge_id, + person_in_charge_name, + person_in_charge_phone_number, + status, + created_at, + confirmed_at + from bookings + where confirmation_token = ${confirmationToken} + limit 1 + ` + + return row ? mapBooking(row) : null +} + +export async function listBookings(options?: { + personInChargeId?: string +}): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const rows = options?.personInChargeId + ? await sql` + select + id, + confirmation_token, + customer_name, + customer_phone, + booking_mode, + quantity, + seat_count, + ticket_type, + unit_price, + total_price, + person_in_charge_id, + person_in_charge_name, + person_in_charge_phone_number, + status, + created_at, + confirmed_at + from bookings + where person_in_charge_id = ${options.personInChargeId} + order by created_at desc + ` + : await sql` + select + id, + confirmation_token, + customer_name, + customer_phone, + booking_mode, + quantity, + seat_count, + ticket_type, + unit_price, + total_price, + person_in_charge_id, + person_in_charge_name, + person_in_charge_phone_number, + status, + created_at, + confirmed_at + from bookings + order by created_at desc + ` + + return rows.map(mapBooking) +} + +export async function getBookingCapacitySettings(): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + select + total_tables, + updated_at + from booking_settings + where id = 'default' + limit 1 + ` + + return mapBookingCapacitySettings(row) +} + +export async function updateBookingCapacitySettings(input: { + totalTables: number | null +}): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + update booking_settings + set + total_tables = ${input.totalTables}, + updated_at = now() + where id = 'default' + returning + total_tables, + updated_at + ` + + return mapBookingCapacitySettings(row) +} + +export async function getBookingInventorySummary(): Promise { + const [bookings, settings] = await Promise.all([ + listBookings(), + getBookingCapacitySettings() + ]) + + return calculateBookingInventorySummary(bookings, settings) +} + +export async function confirmBookingByConfirmationToken(confirmationToken: string): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + update bookings + set + status = 'confirmed', + confirmed_at = now(), + updated_at = now() + where confirmation_token = ${confirmationToken} + and status = 'pending' + returning + id, + confirmation_token, + customer_name, + customer_phone, + booking_mode, + quantity, + seat_count, + ticket_type, + unit_price, + total_price, + person_in_charge_id, + person_in_charge_name, + person_in_charge_phone_number, + status, + created_at, + confirmed_at + ` + + if (row) { + return mapBooking(row) + } + + return await getBookingByConfirmationToken(confirmationToken) +} diff --git a/server/utils/bookings.ts b/server/utils/bookings.ts new file mode 100644 index 0000000..4abcc8c --- /dev/null +++ b/server/utils/bookings.ts @@ -0,0 +1,90 @@ +import type { BookingCapacitySettings, BookingMode, PublicBooking, TicketType } from '~~/shared/booking' + +import { + formatBookingCurrency, + getBookingModeLabel, + getTicketCatalogItem, + isBookingMode, + isTicketType +} from '~~/shared/booking' +import { hasValidFullName, isValidPhoneNumber, normalizeFullName, normalizePhoneNumber } from '~~/shared/auth' + +import { assertBadRequest } from './http' + +export function parseCreateBookingInput(body: { + customerName?: string + customerPhone?: string + bookingMode?: BookingMode + quantity?: number + ticketType?: TicketType + personInChargeId?: string +}) { + const customerName = normalizeFullName(body.customerName || '') + const customerPhone = normalizePhoneNumber(body.customerPhone || '') + const bookingMode = body.bookingMode + const ticketType = body.ticketType + const quantity = Number(body.quantity) + const personInChargeId = (body.personInChargeId || '').trim() + + assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters') + assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must contain 8 to 15 digits') + assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid') + assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1') + assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid') + assertBadRequest(personInChargeId, 'Person in charge is required') + + return { + customerName, + customerPhone, + bookingMode, + quantity, + ticketType, + personInChargeId + } +} + +export function buildBookingMessage(booking: PublicBooking, confirmationUrl: string) { + const ticket = getTicketCatalogItem(booking.ticketType) + const ticketLabel = ticket?.label || booking.ticketType.toUpperCase() + + return [ + "I'd like to book tickets for the DAP Johor 60th Anniversary Celebration.", + '', + `Name: ${booking.customerName}`, + `Phone Number: ${booking.customerPhone}`, + `Booking Mode: ${getBookingModeLabel(booking.bookingMode)}`, + `Quantity: ${booking.quantity}`, + `Ticket Category: ${ticketLabel}`, + `Seats Covered: ${booking.seatCount}`, + `Total Price: ${formatBookingCurrency(booking.totalPrice)}`, + '', + 'PIC confirmation link:', + confirmationUrl + ].join('\n') +} + +export function parseBookingCapacityInput(body: { + totalTables?: number | string | null +}): Pick { + const totalTables = parseOptionalInteger(body.totalTables) + + assertBadRequest(totalTables === null || totalTables >= 0, 'Total tables must be 0 or greater') + + return { + totalTables + } +} + +function parseOptionalInteger(value: number | string | null | undefined) { + if (value === null || value === undefined || value === '') { + return null + } + + const parsed = typeof value === 'number' + ? value + : Number.parseInt(String(value), 10) + + assertBadRequest(Number.isInteger(parsed), 'Capacity values must be whole numbers') + + return parsed +} diff --git a/server/utils/db-init.ts b/server/utils/db-init.ts index 9e49b6a..de7917b 100644 --- a/server/utils/db-init.ts +++ b/server/utils/db-init.ts @@ -61,6 +61,43 @@ async function initializeDatabase() { on user_passkeys (user_id) ` + await sql` + create table if not exists bookings ( + id text primary key, + confirmation_token text not null unique, + customer_name text not null, + customer_phone text not null, + booking_mode text not null check (booking_mode in ('table', 'pax')), + quantity integer not null check (quantity >= 1), + seat_count integer not null check (seat_count >= 1), + ticket_type text not null check (ticket_type in ('vip', 'supporter')), + unit_price integer not null check (unit_price >= 0), + total_price integer not null check (total_price >= 0), + person_in_charge_id text not null references users(id) on delete restrict, + person_in_charge_name text not null, + person_in_charge_phone_number text not null, + status text not null check (status in ('pending', 'confirmed')) default 'pending', + confirmed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ) + ` + + await sql` + create table if not exists booking_settings ( + id text primary key, + total_tables integer, + total_seats integer, + updated_at timestamptz not null default now() + ) + ` + + await sql` + insert into booking_settings (id) + values ('default') + on conflict (id) do nothing + ` + const [existingSuperAdmin] = await sql<{ id: string }[]>` select id from users diff --git a/server/utils/user-repository.ts b/server/utils/user-repository.ts index e3b61c2..2dfa52f 100644 --- a/server/utils/user-repository.ts +++ b/server/utils/user-repository.ts @@ -256,6 +256,36 @@ export async function listPublicContacts(): Promise { })) } +export async function getPublicContactById(contactId: string): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql[]>` + select + users.id, + users.full_name, + users.phone_number, + users.role + from users + where users.id = ${contactId} + and users.is_active = true + and users.phone_number is not null + and users.phone_number <> '' + limit 1 + ` + + if (!row) { + return null + } + + return { + id: row.id, + fullName: row.full_name, + phoneNumber: row.phone_number || '', + role: row.role + } +} + export async function createUser(input: { username: string fullName: string diff --git a/server/utils/webauthn.ts b/server/utils/webauthn.ts index 7ee4a2d..db6d3fd 100644 --- a/server/utils/webauthn.ts +++ b/server/utils/webauthn.ts @@ -2,25 +2,13 @@ import type { H3Event } from 'h3' import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server' -import { getRequestURL } from 'h3' - import { decodeBase64Url, randomToken } from './base64url' +import { getAppOrigin } from './app-url' import { getRedisClient } from './redis' import type { PasskeyRecord } from './user-repository' const CHALLENGE_TTL_SECONDS = 60 * 5 -function getAppOrigin(event: H3Event) { - const config = useRuntimeConfig() - - if (config.public.appUrl) { - return new URL(config.public.appUrl).origin - } - - const url = getRequestURL(event) - return `${url.protocol}//${url.host}` -} - export function getWebAuthnConfig(event: H3Event) { const config = useRuntimeConfig() const origin = getAppOrigin(event) diff --git a/shared/auth.ts b/shared/auth.ts index d07977a..b9c4eee 100644 --- a/shared/auth.ts +++ b/shared/auth.ts @@ -90,5 +90,5 @@ export function getDefaultAuthenticatedPath( return '/security' } - return user.role === 'super_admin' ? '/management/users' : '/security' + return user.role === 'super_admin' ? '/management/users' : '/bookings' } diff --git a/shared/booking.ts b/shared/booking.ts new file mode 100644 index 0000000..e0a1383 --- /dev/null +++ b/shared/booking.ts @@ -0,0 +1,150 @@ +export type BookingMode = 'table' | 'pax' +export type TicketType = 'vip' | 'supporter' +export type BookingStatus = 'pending' | 'confirmed' + +export const BOOKING_MODE_OPTIONS = [ + { + value: 'table', + label: 'Table (10 pax)' + }, + { + value: 'pax', + label: 'Person' + } +] satisfies Array<{ value: BookingMode, label: string }> + +export const BOOKING_TICKET_CATALOG = [ + { + value: 'vip', + label: 'VIP', + description: 'RM150 / pax', + price: 150 + }, + { + value: 'supporter', + label: 'Supporter', + description: 'RM60 / pax', + price: 60 + } +] satisfies Array<{ value: TicketType, label: string, description: string, price: number }> + +export interface PublicBooking { + id: string + confirmationToken: string + customerName: string + customerPhone: string + bookingMode: BookingMode + quantity: number + seatCount: number + ticketType: TicketType + unitPrice: number + totalPrice: number + personInChargeId: string + personInChargeName: string + personInChargePhoneNumber: string + status: BookingStatus + createdAt: string + confirmedAt: string | null +} + +export interface BookingCapacitySettings { + totalTables: number | null + updatedAt: string | null +} + +export interface BookingInventorySummary { + totalTables: number | null + totalCapacitySeats: number | null + soldTables: number + pendingTables: number + soldSeats: number + pendingSeats: number + soldCapacitySeats: number + pendingCapacitySeats: number + leftTables: number | null + leftSeats: number | null + leftCapacitySeats: number | null +} + +export interface CreateBookingResponse { + booking: PublicBooking + confirmationUrl: string + whatsappUrl: string +} + +export function isBookingMode(value: string | null | undefined): value is BookingMode { + return value === 'table' || value === 'pax' +} + +export function isTicketType(value: string | null | undefined): value is TicketType { + return value === 'vip' || value === 'supporter' +} + +export function isBookingStatus(value: string | null | undefined): value is BookingStatus { + return value === 'pending' || value === 'confirmed' +} + +export function getBookingModeLabel(value: BookingMode) { + return value === 'table' ? 'Table (10 pax each)' : 'Per person' +} + +export function getBookingStatusLabel(value: BookingStatus) { + return value === 'confirmed' ? 'Confirmed' : 'Pending PIC confirmation' +} + +export function getSeatCount(bookingMode: BookingMode, quantity: number) { + return bookingMode === 'table' ? quantity * 10 : quantity +} + +export function getTicketCatalogItem(ticketType: TicketType) { + return BOOKING_TICKET_CATALOG.find((ticket) => ticket.value === ticketType) ?? null +} + +export function formatBookingCurrency(value: number) { + return new Intl.NumberFormat('en-MY', { + style: 'currency', + currency: 'MYR', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(value) +} + +export function calculateBookingInventorySummary( + bookings: Pick[], + settings: BookingCapacitySettings +): BookingInventorySummary { + const soldTables = bookings + .filter((booking) => booking.status === 'confirmed' && booking.bookingMode === 'table') + .reduce((total, booking) => total + booking.quantity, 0) + + const pendingTables = bookings + .filter((booking) => booking.status === 'pending' && booking.bookingMode === 'table') + .reduce((total, booking) => total + booking.quantity, 0) + + const soldSeats = bookings + .filter((booking) => booking.status === 'confirmed' && booking.bookingMode === 'pax') + .reduce((total, booking) => total + booking.seatCount, 0) + + const pendingSeats = bookings + .filter((booking) => booking.status === 'pending' && booking.bookingMode === 'pax') + .reduce((total, booking) => total + booking.seatCount, 0) + + const totalCapacitySeats = settings.totalTables === null ? null : settings.totalTables * 10 + const soldCapacitySeats = (soldTables * 10) + soldSeats + const pendingCapacitySeats = (pendingTables * 10) + pendingSeats + const leftCapacitySeats = totalCapacitySeats === null ? null : Math.max(totalCapacitySeats - soldCapacitySeats, 0) + + return { + totalTables: settings.totalTables, + totalCapacitySeats, + soldTables, + pendingTables, + soldSeats, + pendingSeats, + soldCapacitySeats, + pendingCapacitySeats, + leftTables: leftCapacitySeats === null ? null : Math.floor(leftCapacitySeats / 10), + leftSeats: leftCapacitySeats === null ? null : leftCapacitySeats % 10, + leftCapacitySeats + } +}