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_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
NUXT_REDIS_URL=redis://127.0.0.1:6379 NUXT_REDIS_URL=redis://127.0.0.1:6379
NUXT_SESSION_COOKIE_NAME=dinner_ticket_session 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. # Use your deployed HTTPS origin in production so WebAuthn/passkeys validate correctly.
NUXT_PUBLIC_APP_URL=http://localhost:20013 NUXT_PUBLIC_APP_URL=http://localhost:20013

View File

@@ -17,10 +17,14 @@ Create `.env` from `.env.example` and set:
```bash ```bash
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
NUXT_REDIS_URL=redis://127.0.0.1:6379 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=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. `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 ## Setup

View File

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

View File

@@ -1,7 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { FormError, FormSubmitEvent } from '@nuxt/ui' 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 type { CreateBookingResponse } from '~~/shared/booking'
import { import {
BOOKING_MODE_OPTIONS, BOOKING_MODE_OPTIONS,
@@ -51,7 +56,7 @@ const personInCharge = computed(() => {
const form = reactive({ const form = reactive({
name: '', name: '',
phone: '', phone: DEFAULT_PHONE_COUNTRY_CODE,
bookingMode: 'table' as BookingMode, bookingMode: 'table' as BookingMode,
quantity: 1, quantity: 1,
ticketType: 'vip' as TicketType ticketType: 'vip' as TicketType
@@ -85,7 +90,7 @@ function validateBooking(state: typeof form): FormError[] {
if (!state.phone.trim()) { if (!state.phone.trim()) {
errors.push({ name: 'phone', message: 'Please enter a contact number.' }) errors.push({ name: 'phone', message: 'Please enter a contact number.' })
} else if (!isValidPhoneNumber(state.phone.trim())) { } 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) { if (state.quantity < 1) {
@@ -117,7 +122,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
method: 'POST', method: 'POST',
body: { body: {
customerName: form.name.trim(), customerName: form.name.trim(),
customerPhone: form.phone.trim(), customerPhone: normalizePhoneNumber(form.phone),
bookingMode: form.bookingMode, bookingMode: form.bookingMode,
quantity: form.quantity, quantity: form.quantity,
ticketType: form.ticketType, ticketType: form.ticketType,
@@ -181,7 +186,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
</UFormField> </UFormField>
<UFormField name="phone" label="Phone Number" required> <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> </UFormField>
</div> </div>

View File

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

View File

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

View File

@@ -15,6 +15,9 @@ services:
NUXT_DATABASE_URL: ${NUXT_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/dinner_ticket_system} NUXT_DATABASE_URL: ${NUXT_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/dinner_ticket_system}
NUXT_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379} NUXT_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379}
NUXT_SESSION_COOKIE_NAME: ${NUXT_SESSION_COOKIE_NAME:-dinner_ticket_session} 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_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013}
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System} NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
volumes: volumes:

View File

@@ -48,6 +48,9 @@ services:
NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system
NUXT_REDIS_URL: redis://redis:6379 NUXT_REDIS_URL: redis://redis:6379
NUXT_SESSION_COOKIE_NAME: dinner_ticket_session 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_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013}
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System} NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
ports: ports:

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
import type { ConfirmBookingResponse } from '~~/shared/booking'
import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository' import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http' 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 token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const existingBooking = await getBookingByConfirmationToken(token) const existingBooking = await getBookingByConfirmationToken(token)
@@ -12,7 +15,14 @@ export default defineEventHandler(async (event) => {
if (existingBooking.status === 'confirmed') { if (existingBooking.status === 'confirmed') {
return { return {
booking: existingBooking, 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') httpError(404, 'Booking not found')
} }
const ticketReceiptWhatsApp = await sendBookingTicketReceiptViaWhatsApp(event, booking)
return { return {
booking, booking,
alreadyConfirmed: false alreadyConfirmed: false,
ticketReceiptWhatsApp
} }
}) })

View File

@@ -26,7 +26,7 @@ export function parseCreateBookingInput(body: {
const personInChargeId = (body.personInChargeId || '').trim() const personInChargeId = (body.personInChargeId || '').trim()
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters') 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(isBookingMode(bookingMode), 'Booking mode is invalid')
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1') assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid') assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
@@ -73,7 +73,7 @@ export function parseSeatShareInput(body: {
if (shared) { if (shared) {
assertBadRequest(!recipientName || hasValidFullName(recipientName), 'Recipient name must be at least 2 characters') 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 { return {

View File

@@ -279,7 +279,7 @@ async function initializeDatabase() {
await sql` await sql`
update users update users
set set
phone_number = '601157753558', phone_number = '+601157753558',
updated_at = now() updated_at = now()
where username = 'xiaomai' where username = 'xiaomai'
and (phone_number is null or phone_number = '') 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 { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server'
import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth' import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth'
import { normalizePhoneNumber } from '~~/shared/auth'
import { encodeBase64Url, toIsoString } from './base64url' import { encodeBase64Url, toIsoString } from './base64url'
import { ensureDatabaseReady } from './db-init' import { ensureDatabaseReady } from './db-init'
@@ -80,7 +81,7 @@ function mapAuthUser(row: DbUserRow): AuthUser {
id: row.id, id: row.id,
username: row.username, username: row.username,
fullName: row.full_name, fullName: row.full_name,
phoneNumber: row.phone_number, phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
role: row.role, role: row.role,
isActive: row.is_active, isActive: row.is_active,
mustChangePassword: row.must_change_password, mustChangePassword: row.must_change_password,
@@ -251,7 +252,7 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
fullName: row.full_name, fullName: row.full_name,
phoneNumber: row.phone_number || '', phoneNumber: normalizePhoneNumber(row.phone_number || ''),
role: row.role role: row.role
})) }))
} }
@@ -281,7 +282,7 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
return { return {
id: row.id, id: row.id,
fullName: row.full_name, fullName: row.full_name,
phoneNumber: row.phone_number || '', phoneNumber: normalizePhoneNumber(row.phone_number || ''),
role: row.role 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' '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(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 { return {
username, username,
@@ -64,7 +64,7 @@ export function parseUserProfileInput(body: {
const role = body.role const role = body.role
assertBadRequest(hasValidFullName(fullName), 'Display name must be at least 2 characters') 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') assertBadRequest(isUserRole(role), 'Role is invalid')
return { 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 MIN_FULL_NAME_LENGTH = 2
export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/ export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/
export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/ export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/
export const DEFAULT_PHONE_COUNTRY_CODE = '+60'
export type UserRole = 'super_admin' | 'staff' export type UserRole = 'super_admin' | 'staff'
@@ -24,7 +25,21 @@ export function normalizePhoneNumber(value: string) {
const hasPlusPrefix = trimmed.startsWith('+') const hasPlusPrefix = trimmed.startsWith('+')
const digitsOnly = trimmed.replace(/\D/g, '') 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) { export function isValidPhoneNumber(value: string) {

View File

@@ -116,6 +116,21 @@ export interface CreateBookingResponse {
whatsappUrl: string 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 { export function isBookingMode(value: string | null | undefined): value is BookingMode {
return value === 'table' || value === 'seat' return value === 'table' || value === 'seat'
} }