feat: auto-create guest in CRM on booking creation
- Add upsertGuestFromBooking() service — creates or updates guest record when a booking is made (matches by email/phone, creates if not found) - Integrated into bookings.ts (manual PMS bookings) and publicWidget.ts (online widget bookings) - Name parsed as Russian format: "Фамилия Имя [Отчество]" - Non-throwing: booking creation never fails due to guest upsert errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { db } from '../db'
|
|||||||
import { notifyNetupCheckin, notifyNetupCheckout } from './netup'
|
import { notifyNetupCheckin, notifyNetupCheckout } from './netup'
|
||||||
import { getHkSettings } from './housekeeping-settings'
|
import { getHkSettings } from './housekeeping-settings'
|
||||||
import { broadcast } from './ws'
|
import { broadcast } from './ws'
|
||||||
|
import { upsertGuestFromBooking } from '../services/guestUpsert'
|
||||||
import { transporter } from '../email'
|
import { transporter } from '../email'
|
||||||
import { createHold } from '../services/yookassa'
|
import { createHold } from '../services/yookassa'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
@@ -108,6 +109,14 @@ const bookings: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
const booking = rows[0]
|
const booking = rows[0]
|
||||||
|
|
||||||
|
// Auto-upsert guest record in CRM
|
||||||
|
upsertGuestFromBooking({
|
||||||
|
hotelId,
|
||||||
|
guestName: guest_name,
|
||||||
|
guestEmail: guest_email,
|
||||||
|
guestPhone: guest_phone,
|
||||||
|
})
|
||||||
|
|
||||||
// Auto-create YooKassa hold + send deposit email for online bookings with email
|
// Auto-create YooKassa hold + send deposit email for online bookings with email
|
||||||
if (source === 'online' && guest_email) {
|
if (source === 'online' && guest_email) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FastifyPluginAsync } from 'fastify'
|
|||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { getGatewayForModule } from './paymentGateways'
|
import { getGatewayForModule } from './paymentGateways'
|
||||||
import { createCharge } from '../services/yookassa'
|
import { createCharge } from '../services/yookassa'
|
||||||
|
import { upsertGuestFromBooking } from '../services/guestUpsert'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type SlugIdParam = { Params: { slug: string; bookingId: string } }
|
type SlugIdParam = { Params: { slug: string; bookingId: string } }
|
||||||
@@ -456,6 +457,14 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
await db.query('UPDATE online_bookings SET booking_id = $1 WHERE id = $2',
|
await db.query('UPDATE online_bookings SET booking_id = $1 WHERE id = $2',
|
||||||
[bookingId, onlineBookingId])
|
[bookingId, onlineBookingId])
|
||||||
|
|
||||||
|
// Auto-create/update guest in CRM
|
||||||
|
upsertGuestFromBooking({
|
||||||
|
hotelId: hotel.id,
|
||||||
|
guestName,
|
||||||
|
guestEmail,
|
||||||
|
guestPhone,
|
||||||
|
})
|
||||||
|
|
||||||
if (paymentMethod === 'yookassa') {
|
if (paymentMethod === 'yookassa') {
|
||||||
// Create YooKassa payment
|
// Create YooKassa payment
|
||||||
try {
|
try {
|
||||||
|
|||||||
105
backend/src/services/guestUpsert.ts
Normal file
105
backend/src/services/guestUpsert.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { db } from '../db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a full name string (Russian format: "Фамилия Имя [Отчество]")
|
||||||
|
* into first_name, last_name, middle_name parts.
|
||||||
|
*/
|
||||||
|
function parseName(fullName: string): { firstName: string; lastName: string; middleName: string | null } {
|
||||||
|
const parts = fullName.trim().split(/\s+/)
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { firstName: parts[0], lastName: '', middleName: null }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lastName: parts[0],
|
||||||
|
firstName: parts[1],
|
||||||
|
middleName: parts[2] ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a guest record based on booking data.
|
||||||
|
* - Matches existing guest by email OR phone (same hotel)
|
||||||
|
* - If found: updates name/phone/email if they were empty
|
||||||
|
* - If not found: creates new guest record
|
||||||
|
* Non-throwing: errors are swallowed so booking creation is never blocked.
|
||||||
|
*/
|
||||||
|
export async function upsertGuestFromBooking(params: {
|
||||||
|
hotelId: string
|
||||||
|
guestName: string
|
||||||
|
guestEmail?: string | null
|
||||||
|
guestPhone?: string | null
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { hotelId, guestName, guestEmail, guestPhone } = params
|
||||||
|
|
||||||
|
if (!guestName?.trim()) return
|
||||||
|
|
||||||
|
// Build match conditions
|
||||||
|
const conditions: string[] = []
|
||||||
|
const matchParams: unknown[] = [hotelId]
|
||||||
|
if (guestEmail?.trim()) {
|
||||||
|
matchParams.push(guestEmail.toLowerCase().trim())
|
||||||
|
conditions.push(`LOWER(email) = $${matchParams.length}`)
|
||||||
|
}
|
||||||
|
if (guestPhone?.trim()) {
|
||||||
|
const cleanPhone = guestPhone.replace(/\D/g, '')
|
||||||
|
if (cleanPhone.length >= 7) {
|
||||||
|
matchParams.push(cleanPhone)
|
||||||
|
conditions.push(`REGEXP_REPLACE(phone, '[^0-9]', '', 'g') = $${matchParams.length}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
// Check if guest already exists
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT id, first_name, last_name, email, phone
|
||||||
|
FROM guests
|
||||||
|
WHERE hotel_id = $1 AND (${conditions.join(' OR ')})
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
matchParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows[0]) {
|
||||||
|
// Guest exists — fill in any blank fields
|
||||||
|
const updates: string[] = []
|
||||||
|
const upParams: unknown[] = []
|
||||||
|
const g = rows[0]
|
||||||
|
|
||||||
|
if ((!g.email || g.email === '') && guestEmail?.trim()) {
|
||||||
|
upParams.push(guestEmail.toLowerCase().trim())
|
||||||
|
updates.push(`email = $${upParams.length}`)
|
||||||
|
}
|
||||||
|
if ((!g.phone || g.phone === '') && guestPhone?.trim()) {
|
||||||
|
upParams.push(guestPhone.trim())
|
||||||
|
updates.push(`phone = $${upParams.length}`)
|
||||||
|
}
|
||||||
|
if (updates.length > 0) {
|
||||||
|
upParams.push(rows[0].id)
|
||||||
|
await db.query(
|
||||||
|
`UPDATE guests SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${upParams.length}`,
|
||||||
|
upParams,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No existing guest found — create new
|
||||||
|
const { firstName, lastName, middleName } = parseName(guestName)
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO guests (hotel_id, first_name, last_name, middle_name, email, phone)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
hotelId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
middleName,
|
||||||
|
guestEmail?.toLowerCase().trim() ?? null,
|
||||||
|
guestPhone?.trim() ?? null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Non-critical — never block booking creation
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user