feat: blacklist, guest lookup, bank transfer payment page

- Add is_blacklisted column to guests (migration 068)
- POST /widget/bookings: block blacklisted guests (403, generic message)
- GET /widget/:slug/guests/lookup: find existing guest by email/phone for auto-fill
- Widget form: debounce guest lookup on email/phone input, show "Заполнить" suggestion
- Widget payment screen: replace fake card form with proper YooKassa redirect screen or bank transfer details page
- Add bank transfer details field to widget settings (saved as widget_bank_details)
- GuestsPage: blacklist toggle button in edit modal + 🚫 ЧС badge in table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 01:31:10 +03:00
parent 08d6629ae8
commit 2b20fc18b3
7 changed files with 277 additions and 90 deletions

View File

@@ -0,0 +1,8 @@
-- Migration 068 — Add is_blacklisted flag to guests
ALTER TABLE guests
ADD COLUMN IF NOT EXISTS is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_guests_blacklisted
ON guests(hotel_id, is_blacklisted)
WHERE is_blacklisted = TRUE;

View File

@@ -10,7 +10,7 @@ const GUEST_FIELDS = `
g.passport, g.passport_series, g.passport_number,
g.passport_issued_by, g.passport_issue_date,
g.birth_date, g.nationality, g.gender, g.city, g.notes, g.tags,
g.loyalty_tier, g.loyalty_points, g.rating,
g.loyalty_tier, g.loyalty_points, g.rating, g.is_blacklisted,
g.created_at, g.updated_at`
const STATS_JOIN = `
@@ -212,7 +212,7 @@ const guests: FastifyPluginAsync = async (fastify) => {
passport: string; passport_series: string; passport_number: string
passport_issued_by: string; passport_issue_date: string
birth_date: string; nationality: string; gender: string; city: string
notes: string; tags: string[]; rating: number
notes: string; tags: string[]; rating: number; is_blacklisted?: boolean
}> }>(
'/api/hotels/:slug/guests/:id',
{ onRequest: [fastify.authenticate] },
@@ -242,8 +242,9 @@ const guests: FastifyPluginAsync = async (fastify) => {
add('gender', b.gender)
add('city', b.city)
add('notes', b.notes)
add('tags', b.tags)
add('rating', b.rating)
add('tags', b.tags)
add('rating', b.rating)
add('is_blacklisted', b.is_blacklisted)
// Шифруем чувствительные поля
if (b.passport !== undefined) add('passport', encryptField(b.passport))
if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series))

View File

@@ -42,12 +42,32 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
// Check if YooKassa gateway is configured for booking-widget
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
// Load saved widget settings
const { rows: wsRows } = await db.query(
`SELECT key, value FROM hotel_settings WHERE hotel_id = $1 AND key LIKE 'widget_%'`,
[hotel.id],
)
const ws: Record<string, unknown> = {}
for (const row of wsRows) ws[row.key] = row.value
return {
hotelId: hotel.id,
hotelName: hotel.name,
slug: req.params.slug,
paymentEnabled: !!(gateway?.shop_id && gateway?.secret_key),
currency: gateway?.currency ?? 'RUB',
widgetSettings: {
primaryColor: (ws.widget_color as string) ?? '#4F46E5',
language: (ws.widget_lang as string) ?? 'ru',
roomDisplayMode: (ws.widget_room_mode as string) ?? 'rooms',
minNights: (ws.widget_min_nights as number) ?? 1,
showRental: (ws.widget_show_rental as boolean) ?? false,
showPromo: (ws.widget_show_promo as boolean) ?? true,
allowExtraBeds: (ws.widget_extra_beds as boolean) ?? true,
allowChildren: (ws.widget_children as boolean) ?? true,
hotelName: (ws.widget_hotel_name as string) ?? hotel.name,
bankDetails: (ws.widget_bank_details as string) ?? '',
},
rooms: rooms.map(r => ({
id: r.id,
number: r.number,
@@ -75,6 +95,43 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
}
})
// ── GET /api/widget/:slug/guests/lookup ───────────────────────────────────
// Lookup existing guest by email or phone (for auto-fill, no auth)
fastify.get<SlugParam & { Querystring: { email?: string; phone?: string } }>(
'/api/widget/:slug/guests/lookup', async (req, reply) => {
const hotel = await getHotelId(req.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { email, phone } = req.query
if (!email && !phone) return reply.code(400).send({ error: 'email or phone required' })
const conditions: string[] = []
const params: unknown[] = [hotel.id]
if (email) {
params.push(email.toLowerCase().trim())
conditions.push(`LOWER(g.email) = $${params.length}`)
}
if (phone) {
const cleanPhone = phone.replace(/\D/g, '')
params.push(cleanPhone)
conditions.push(`REGEXP_REPLACE(g.phone, '\\D', '', 'g') = $${params.length}`)
}
const { rows } = await db.query(
`SELECT g.first_name, g.last_name, g.middle_name, g.email, g.phone, g.is_blacklisted
FROM guests g
WHERE g.hotel_id = $1 AND (${conditions.join(' OR ')})
ORDER BY g.updated_at DESC
LIMIT 1`,
params,
)
if (!rows[0]) return reply.code(404).send({ error: 'Guest not found' })
return rows[0]
},
)
// ── GET /api/widget/:slug/availability ────────────────────────────────────
// Returns available rooms for given dates
fastify.get<SlugParam & { Querystring: { checkIn: string; checkOut: string } }>(
@@ -221,6 +278,28 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
return reply.code(409).send({ error: 'Room not available for selected dates' })
}
// Blacklist check
if (guestEmail || guestPhone) {
const blConditions: string[] = []
const blParams: unknown[] = [hotel.id]
if (guestEmail) {
blParams.push(guestEmail.toLowerCase().trim())
blConditions.push(`LOWER(email) = $${blParams.length}`)
}
if (guestPhone) {
const cleanPhone = guestPhone.replace(/\D/g, '')
blParams.push(cleanPhone)
blConditions.push(`REGEXP_REPLACE(phone, '\\D', '', 'g') = $${blParams.length}`)
}
const { rows: blRows } = await db.query(
`SELECT id FROM guests WHERE hotel_id = $1 AND is_blacklisted = TRUE AND (${blConditions.join(' OR ')}) LIMIT 1`,
blParams,
)
if (blRows.length > 0) {
return reply.code(403).send({ error: 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.' })
}
}
// Check if gateway configured
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none'