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 + } +}