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:
@@ -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 {
|
||||
|
||||
@@ -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 = '')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
111
server/utils/whatsapp.ts
Normal 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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user