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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = '')
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user