feat: send ticket receipts via WhatsApp and normalize phone numbers

Add WhatsApp API integration for automated receipt delivery
Enforce country codes for all phone number inputs (defaults to +60)
This commit is contained in:
2026-04-27 13:12:25 +08:00
parent faa998c7e1
commit c214d643dd
18 changed files with 208 additions and 28 deletions

View File

@@ -1,6 +1,9 @@
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
NUXT_REDIS_URL=redis://127.0.0.1:6379
NUXT_SESSION_COOKIE_NAME=dinner_ticket_session
NUXT_WHATSAPP_ACCESS_TOKEN=
NUXT_WHATSAPP_PHONE_NUMBER_ID=
NUXT_WHATSAPP_API_VERSION=v23.0
# Use your deployed HTTPS origin in production so WebAuthn/passkeys validate correctly.
NUXT_PUBLIC_APP_URL=http://localhost:20013

View File

@@ -17,10 +17,14 @@ Create `.env` from `.env.example` and set:
```bash
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
NUXT_REDIS_URL=redis://127.0.0.1:6379
NUXT_WHATSAPP_ACCESS_TOKEN=
NUXT_WHATSAPP_PHONE_NUMBER_ID=
NUXT_WHATSAPP_API_VERSION=v23.0
NUXT_PUBLIC_APP_URL=http://localhost:20013
```
`NUXT_PUBLIC_APP_URL` should be your final HTTPS origin in production. Passkeys rely on the RP origin being stable and correct.
Set the WhatsApp variables to enable automatic ticket receipt delivery after PIC confirmation. Without them, confirmation still succeeds and the UI reports that WhatsApp delivery was skipped.
## Setup

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { PublicBooking } from '~~/shared/booking'
import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
import {
formatBookingCurrency,
@@ -85,7 +85,7 @@ async function confirmBooking() {
confirming.value = true
try {
const response = await apiClient<{ booking: PublicBooking, alreadyConfirmed: boolean }>(
const response = await apiClient<ConfirmBookingResponse>(
`/api/public/bookings/${token}/confirm`,
{
method: 'POST'
@@ -98,8 +98,10 @@ async function confirmBooking() {
title: response.alreadyConfirmed ? 'Booking already confirmed' : 'Booking confirmed',
description: response.alreadyConfirmed
? 'This booking had already been confirmed earlier.'
: 'The booking details have been confirmed successfully.',
color: 'success',
: response.ticketReceiptWhatsApp.sent
? `Ticket receipt was sent to ${response.ticketReceiptWhatsApp.recipientPhone}.`
: `Booking confirmed, but the ticket receipt WhatsApp was not sent: ${response.ticketReceiptWhatsApp.error}`,
color: response.alreadyConfirmed || response.ticketReceiptWhatsApp.sent ? 'success' : 'warning',
icon: 'i-lucide-check-circle-2'
})
} catch (error) {

View File

@@ -1,7 +1,12 @@
<script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
import {
DEFAULT_PHONE_COUNTRY_CODE,
isValidPhoneNumber,
normalizePhoneNumber,
type PublicContact
} from '~~/shared/auth'
import type { CreateBookingResponse } from '~~/shared/booking'
import {
BOOKING_MODE_OPTIONS,
@@ -51,7 +56,7 @@ const personInCharge = computed(() => {
const form = reactive({
name: '',
phone: '',
phone: DEFAULT_PHONE_COUNTRY_CODE,
bookingMode: 'table' as BookingMode,
quantity: 1,
ticketType: 'vip' as TicketType
@@ -85,7 +90,7 @@ function validateBooking(state: typeof form): FormError[] {
if (!state.phone.trim()) {
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
} else if (!isValidPhoneNumber(state.phone.trim())) {
errors.push({ name: 'phone', message: 'Use a valid phone number with 8 to 15 digits.' })
errors.push({ name: 'phone', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
}
if (state.quantity < 1) {
@@ -117,7 +122,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
method: 'POST',
body: {
customerName: form.name.trim(),
customerPhone: form.phone.trim(),
customerPhone: normalizePhoneNumber(form.phone),
bookingMode: form.bookingMode,
quantity: form.quantity,
ticketType: form.ticketType,
@@ -181,7 +186,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
</UFormField>
<UFormField name="phone" label="Phone Number" required>
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. 0123456789" />
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. +60123456789" />
</UFormField>
</div>

View File

@@ -166,7 +166,7 @@
size="lg"
type="tel"
class="w-full"
placeholder="e.g. 0123456789"
placeholder="e.g. +60123456789"
/>
</UFormField>
@@ -208,6 +208,7 @@
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import {
DEFAULT_PHONE_COUNTRY_CODE,
hasValidFullName,
isValidPhoneNumber,
isValidUsername,
@@ -242,7 +243,7 @@ const editingUserId = ref<string | null>(null)
const userForm = reactive({
fullName: '',
username: '',
phoneNumber: '',
phoneNumber: DEFAULT_PHONE_COUNTRY_CODE,
role: 'staff' as UserRole
})
@@ -287,7 +288,7 @@ await refreshUsers()
function resetUserForm() {
userForm.fullName = ''
userForm.username = ''
userForm.phoneNumber = ''
userForm.phoneNumber = DEFAULT_PHONE_COUNTRY_CODE
userForm.role = 'staff'
editingUserId.value = null
}
@@ -303,7 +304,7 @@ function openEditModal(user: ManagedUser) {
editingUserId.value = user.id
userForm.fullName = user.fullName
userForm.username = user.username
userForm.phoneNumber = user.phoneNumber || ''
userForm.phoneNumber = user.phoneNumber ? normalizePhoneNumber(user.phoneNumber) : DEFAULT_PHONE_COUNTRY_CODE
userForm.role = user.role
editorOpen.value = true
}
@@ -329,7 +330,7 @@ function validateUserForm(state: typeof userForm): FormError[] {
}
if (!isValidPhoneNumber(state.phoneNumber)) {
errors.push({ name: 'phoneNumber', message: 'Use a valid phone number with 8 to 15 digits.' })
errors.push({ name: 'phoneNumber', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
}
return errors

View File

@@ -607,7 +607,7 @@ async function openBatchShare() {
<UInput
v-model="shareForm.recipientName"
class="w-full"
placeholder="Optional"
placeholder="Optional, e.g. +60123456789"
/>
</UFormField>

View File

@@ -15,6 +15,9 @@ services:
NUXT_DATABASE_URL: ${NUXT_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/dinner_ticket_system}
NUXT_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379}
NUXT_SESSION_COOKIE_NAME: ${NUXT_SESSION_COOKIE_NAME:-dinner_ticket_session}
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
NUXT_PUBLIC_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013}
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
volumes:

View File

@@ -48,6 +48,9 @@ services:
NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system
NUXT_REDIS_URL: redis://redis:6379
NUXT_SESSION_COOKIE_NAME: dinner_ticket_session
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
NUXT_PUBLIC_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013}
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
ports:

View File

@@ -10,6 +10,9 @@ export default defineNuxtConfig({
databaseUrl: '',
redisUrl: '',
sessionCookieName: 'dinner_ticket_session',
whatsappAccessToken: '',
whatsappPhoneNumberId: '',
whatsappApiVersion: 'v23.0',
public: {
appUrl: '',
rpName: 'Dinner Ticket System'

View File

@@ -7,6 +7,7 @@ import { createBooking } from '../../utils/booking-repository'
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
import { assertBadRequest } from '../../utils/http'
import { getPublicContactById } from '../../utils/user-repository'
import { buildWhatsAppDeepLink } from '../../utils/whatsapp'
export default defineEventHandler(async (event): Promise<CreateBookingResponse> => {
const body = await readBody<{
@@ -46,7 +47,7 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
const whatsappMessage = buildBookingMessage(booking, confirmationUrl)
const whatsappUrl = `https://wa.me/${booking.personInChargePhoneNumber}?text=${encodeURIComponent(whatsappMessage)}`
const whatsappUrl = buildWhatsAppDeepLink(booking.personInChargePhoneNumber, whatsappMessage)
return {
booking,

View File

@@ -1,7 +1,10 @@
import type { ConfirmBookingResponse } from '~~/shared/booking'
import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
import { sendBookingTicketReceiptViaWhatsApp } from '../../../../utils/whatsapp'
export default defineEventHandler(async (event) => {
export default defineEventHandler(async (event): Promise<ConfirmBookingResponse> => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const existingBooking = await getBookingByConfirmationToken(token)
@@ -12,7 +15,14 @@ export default defineEventHandler(async (event) => {
if (existingBooking.status === 'confirmed') {
return {
booking: existingBooking,
alreadyConfirmed: true
alreadyConfirmed: true,
ticketReceiptWhatsApp: {
sent: false,
skipped: true,
recipientPhone: existingBooking.customerPhone,
apiRecipientPhone: existingBooking.customerPhone.replace(/\D/g, ''),
error: 'Booking was already confirmed earlier.'
}
}
}
@@ -28,8 +38,11 @@ export default defineEventHandler(async (event) => {
httpError(404, 'Booking not found')
}
const ticketReceiptWhatsApp = await sendBookingTicketReceiptViaWhatsApp(event, booking)
return {
booking,
alreadyConfirmed: false
alreadyConfirmed: false,
ticketReceiptWhatsApp
}
})

View File

@@ -26,7 +26,7 @@ export function parseCreateBookingInput(body: {
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(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
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')
@@ -73,7 +73,7 @@ export function parseSeatShareInput(body: {
if (shared) {
assertBadRequest(!recipientName || hasValidFullName(recipientName), 'Recipient name must be at least 2 characters')
assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must contain 8 to 15 digits')
assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must include a country code, e.g. +60123456789')
}
return {

View File

@@ -279,7 +279,7 @@ async function initializeDatabase() {
await sql`
update users
set
phone_number = '601157753558',
phone_number = '+601157753558',
updated_at = now()
where username = 'xiaomai'
and (phone_number is null or phone_number = '')

View File

@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server'
import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth'
import { normalizePhoneNumber } from '~~/shared/auth'
import { encodeBase64Url, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init'
@@ -80,7 +81,7 @@ function mapAuthUser(row: DbUserRow): AuthUser {
id: row.id,
username: row.username,
fullName: row.full_name,
phoneNumber: row.phone_number,
phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
role: row.role,
isActive: row.is_active,
mustChangePassword: row.must_change_password,
@@ -251,7 +252,7 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
return rows.map((row) => ({
id: row.id,
fullName: row.full_name,
phoneNumber: row.phone_number || '',
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
role: row.role
}))
}
@@ -281,7 +282,7 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
return {
id: row.id,
fullName: row.full_name,
phoneNumber: row.phone_number || '',
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
role: row.role
}
}

View File

@@ -44,7 +44,7 @@ export function parseCreateUserInput(body: {
'Username must be 3 to 32 characters using lowercase letters, numbers, dot, dash, or underscore'
)
assertBadRequest(hasValidFullName(fullName), 'Full name must be at least 2 characters')
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must contain 8 to 15 digits')
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must include a country code, e.g. +60123456789')
return {
username,
@@ -64,7 +64,7 @@ export function parseUserProfileInput(body: {
const role = body.role
assertBadRequest(hasValidFullName(fullName), 'Display name must be at least 2 characters')
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must contain 8 to 15 digits')
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must include a country code, e.g. +60123456789')
assertBadRequest(isUserRole(role), 'Role is invalid')
return {

111
server/utils/whatsapp.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { H3Event } from 'h3'
import type { PublicBooking, WhatsAppDeliveryResult } from '~~/shared/booking'
import {
DINNER_EVENT_DATE_LABEL,
DINNER_EVENT_TIME_LABEL,
DINNER_EVENT_TITLE,
DINNER_EVENT_VENUE,
formatBookingCurrency,
getTicketCatalogItem
} from '~~/shared/booking'
import { normalizePhoneNumber } from '~~/shared/auth'
import { buildAppUrl } from './app-url'
type WhatsAppMessagesResponse = {
messages?: Array<{
id?: string
}>
}
export function toWhatsAppPhoneNumber(phoneNumber: string) {
return normalizePhoneNumber(phoneNumber).replace(/\D/g, '')
}
export function buildWhatsAppDeepLink(phoneNumber: string, message: string) {
return `https://wa.me/${toWhatsAppPhoneNumber(phoneNumber)}?text=${encodeURIComponent(message)}`
}
export function buildBookingTicketReceiptMessage(event: H3Event, booking: PublicBooking) {
const ticket = getTicketCatalogItem(booking.ticketType)
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
const receiptUrl = buildAppUrl(event, `/receipt/${booking.receiptToken}`)
return [
DINNER_EVENT_TITLE,
'',
`Hi ${booking.customerName}, your ticket receipt has been confirmed.`,
'',
`Receipt: ${receiptUrl}`,
`Seats: ${booking.seatCount}`,
`Ticket Category: ${ticketLabel}`,
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
`Date: ${DINNER_EVENT_DATE_LABEL}`,
`Time: ${DINNER_EVENT_TIME_LABEL}`,
`Venue: ${DINNER_EVENT_VENUE}`,
'',
'Please present the QR code from the receipt at the event.'
].join('\n')
}
export async function sendBookingTicketReceiptViaWhatsApp(
event: H3Event,
booking: PublicBooking
): Promise<WhatsAppDeliveryResult> {
const recipientPhone = normalizePhoneNumber(booking.customerPhone)
const to = toWhatsAppPhoneNumber(recipientPhone)
const config = useRuntimeConfig()
const accessToken = String(config.whatsappAccessToken || '')
const phoneNumberId = String(config.whatsappPhoneNumberId || '')
const apiVersion = String(config.whatsappApiVersion || 'v23.0')
if (!accessToken || !phoneNumberId) {
return {
sent: false,
skipped: true,
recipientPhone,
apiRecipientPhone: to,
error: 'WhatsApp API credentials are not configured.'
}
}
try {
const response = await $fetch<WhatsAppMessagesResponse>(
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`
},
body: {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: {
preview_url: true,
body: buildBookingTicketReceiptMessage(event, booking)
}
}
}
)
return {
sent: true,
skipped: false,
recipientPhone,
apiRecipientPhone: to,
messageId: response.messages?.[0]?.id
}
} catch (error: any) {
return {
sent: false,
skipped: false,
recipientPhone,
apiRecipientPhone: to,
error: error?.data?.error?.message || error?.message || 'WhatsApp API request failed.'
}
}
}

View File

@@ -3,6 +3,7 @@ export const MIN_PASSWORD_LENGTH = 8
export const MIN_FULL_NAME_LENGTH = 2
export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/
export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/
export const DEFAULT_PHONE_COUNTRY_CODE = '+60'
export type UserRole = 'super_admin' | 'staff'
@@ -24,7 +25,21 @@ export function normalizePhoneNumber(value: string) {
const hasPlusPrefix = trimmed.startsWith('+')
const digitsOnly = trimmed.replace(/\D/g, '')
return hasPlusPrefix ? `+${digitsOnly}` : digitsOnly
if (!digitsOnly) {
return ''
}
if (hasPlusPrefix) {
return `+${digitsOnly}`
}
const defaultCountryDigits = DEFAULT_PHONE_COUNTRY_CODE.replace(/\D/g, '')
if (digitsOnly.startsWith(defaultCountryDigits)) {
return `+${digitsOnly}`
}
return `+${defaultCountryDigits}${digitsOnly.replace(/^0+/, '')}`
}
export function isValidPhoneNumber(value: string) {

View File

@@ -116,6 +116,21 @@ export interface CreateBookingResponse {
whatsappUrl: string
}
export interface WhatsAppDeliveryResult {
sent: boolean
skipped: boolean
recipientPhone: string
apiRecipientPhone: string
messageId?: string
error?: string
}
export interface ConfirmBookingResponse {
booking: PublicBooking
alreadyConfirmed: boolean
ticketReceiptWhatsApp: WhatsAppDeliveryResult
}
export function isBookingMode(value: string | null | undefined): value is BookingMode {
return value === 'table' || value === 'seat'
}