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

@@ -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.'
}
}
}