Files
hotelsync/backend/src/routes/publicWidget.ts
HotelSync c153e12543 feat: widget additional services — sourced from rental objects module
- Additional services in widget are now derived from rental_objects (not a separate list)
- Settings page shows rental objects with enable/disable toggle; link to rental module
- Saves only {id, enabled}[] to widget_services — rental objects manage name/price/icon
- Standalone widget also derives additionalServices from config.rentalObjects
- Widget config API includes services[] in widgetSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:13:13 +03:00

515 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { getGatewayForModule } from './paymentGateways'
import { createCharge } from '../services/yookassa'
import { upsertGuestFromBooking } from '../services/guestUpsert'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; bookingId: string } }
const publicWidget: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string) => {
const { rows } = await db.query('SELECT id, name FROM hotels WHERE slug = $1', [slug])
return rows[0] ?? null
}
// ── GET /api/widget/:slug/config ──────────────────────────────────────────
// Returns hotel info + widget settings (no auth)
fastify.get<SlugParam>('/api/widget/:slug/config', async (req, reply) => {
const hotel = await getHotelId(req.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rows: rooms } = await db.query(
`SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos,
allow_hourly, hourly_rate, extra_place, child_policy, status, category_id
FROM rooms
WHERE hotel_id = $1 AND status != 'inactive'
ORDER BY sort_order, number`,
[hotel.id],
)
const { rows: categories } = await db.query(
`SELECT rc.id, rc.name, rc.description, rc.amenities, rc.photos,
COALESCE(MIN(r.base_rate), 0) AS min_price,
COALESCE(MAX(r.max_guests), 2) AS max_guests
FROM room_categories rc
LEFT JOIN rooms r ON r.category_id = rc.id AND r.status != 'inactive'
WHERE rc.hotel_id = $1
GROUP BY rc.id, rc.name, rc.description, rc.amenities, rc.photos, rc.sort_order
ORDER BY rc.sort_order, rc.name`,
[hotel.id],
)
// Load rental objects (only if widget_show_rental = true)
const { rows: rentalObjects } = await db.query(
`SELECT id, name, icon, price_per_hour, price_per_day,
open_hour, close_hour, max_hours_per_slot, buffer_minutes, sort_order
FROM rental_objects
WHERE hotel_id = $1
ORDER BY sort_order, name`,
[hotel.id],
)
// 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) ?? '',
services: Array.isArray(ws.widget_services) ? ws.widget_services as { id: string; enabled: boolean }[] : null,
},
rooms: rooms.map(r => ({
id: r.id,
number: r.number,
name: r.name || `Номер ${r.number}`,
type: r.type,
floor: r.floor,
maxGuests: r.max_guests,
baseRate: Number(r.base_rate),
amenities: r.amenities ?? [],
description: r.description ?? '',
photos: r.photos ?? [],
categoryId: r.category_id ?? null,
allowHourly: r.allow_hourly,
hourlyRate: r.hourly_rate ? Number(r.hourly_rate) : null,
})),
categories: categories.map(c => ({
id: c.id,
name: c.name,
description: c.description ?? '',
amenities: c.amenities ?? [],
photos: c.photos ?? [],
minPrice: Number(c.min_price),
maxGuests: Number(c.max_guests),
})),
rentalObjects: rentalObjects.map((o: any) => ({
id: o.id,
name: o.name,
icon: o.icon ?? '🏨',
pricePerHour: Number(o.price_per_hour ?? 0),
pricePerDay: Number(o.price_per_day ?? 0),
openHour: Number(o.open_hour ?? 8),
closeHour: Number(o.close_hour ?? 22),
maxHoursPerSlot: o.max_hours_per_slot ? Number(o.max_hours_per_slot) : null,
bufferMinutes: Number(o.buffer_minutes ?? 0),
})),
}
})
// ── POST /api/widget/:slug/rental-bookings ────────────────────────────────
fastify.post<SlugParam & {
Body: {
objectId: string
date: string
isFullDay?: boolean
startHour?: number
endHour?: number
guestName: string
guestEmail?: string
guestPhone?: string
totalAmount: number
notes?: string
}
}>('/api/widget/:slug/rental-bookings', async (req, reply) => {
const hotel = await getHotelId(req.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { objectId, date, isFullDay, startHour, endHour, guestName, guestEmail, guestPhone, totalAmount, notes } = req.body
if (!objectId || !date || !guestName) {
return reply.code(400).send({ error: 'Missing required fields' })
}
// Check object exists
const { rows: objRows } = await db.query(
`SELECT id FROM rental_objects WHERE id = $1 AND hotel_id = $2`,
[objectId, hotel.id],
)
if (!objRows[0]) return reply.code(404).send({ error: 'Rental object not found' })
// Availability check (hourly bookings)
if (!isFullDay && startHour !== undefined && endHour !== undefined) {
const { rows: conflicts } = await db.query(
`SELECT id FROM rental_bookings
WHERE object_id = $1 AND date = $2 AND status != 'cancelled'
AND NOT (end_hour <= $3 OR start_hour >= $4)`,
[objectId, date, startHour, endHour],
)
if (conflicts.length > 0) {
return reply.code(409).send({ error: 'This time slot is already booked' })
}
}
// Create rental booking
const { rows } = await db.query(
`INSERT INTO rental_bookings
(hotel_id, object_id, date, is_full_day, start_hour, end_hour,
guest_name, guest_phone, total_amount, notes, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'confirmed') RETURNING id`,
[hotel.id, objectId, date, isFullDay ?? false,
startHour ?? 0, endHour ?? 0,
guestName, guestPhone ?? '', Math.round(totalAmount * 100),
notes ?? null],
)
return { bookingId: rows[0].id, status: 'confirmed' }
})
// ── 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, '[^0-9]', '', '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 rows[0]
// Fallback: search in past bookings by guest_email / guest_phone
const bConditions: string[] = []
const bParams: unknown[] = [hotel.id]
if (email) {
bParams.push(email.toLowerCase().trim())
bConditions.push(`LOWER(b.guest_email) = $${bParams.length}`)
}
if (phone) {
const cleanPhone = phone.replace(/\D/g, '')
bParams.push(cleanPhone)
bConditions.push(`REGEXP_REPLACE(b.guest_phone, '[^0-9]', '', 'g') = $${bParams.length}`)
}
const { rows: bRows } = await db.query(
`SELECT b.guest_name, b.guest_email, b.guest_phone
FROM bookings b
WHERE b.hotel_id = $1 AND (${bConditions.join(' OR ')})
ORDER BY b.created_at DESC
LIMIT 1`,
bParams,
)
if (!bRows[0]) return reply.code(404).send({ error: 'Guest not found' })
// Parse guest_name into first/last name (format: "Фамилия Имя Отчество" or "Имя Фамилия")
const nameParts = (bRows[0].guest_name as string || '').trim().split(/\s+/)
return {
first_name: nameParts[1] ?? nameParts[0] ?? '',
last_name: nameParts[0] ?? '',
middle_name: nameParts[2] ?? null,
email: bRows[0].guest_email ?? null,
phone: bRows[0].guest_phone ?? null,
is_blacklisted: false,
}
},
)
// ── GET /api/widget/:slug/availability ────────────────────────────────────
// Returns available rooms for given dates
fastify.get<SlugParam & { Querystring: { checkIn: string; checkOut: string } }>(
'/api/widget/:slug/availability', async (req, reply) => {
const hotel = await getHotelId(req.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { checkIn, checkOut } = req.query
if (!checkIn || !checkOut) return reply.code(400).send({ error: 'checkIn and checkOut required' })
// Rooms occupied during requested dates
const { rows: occupied } = await db.query(
`SELECT DISTINCT room_id FROM bookings
WHERE hotel_id = $1
AND status NOT IN ('cancelled','no_show','checked_out')
AND check_in < $3
AND check_out > $2`,
[hotel.id, checkIn, checkOut],
)
const occupiedIds = new Set(occupied.map((r: any) => r.room_id))
const { rows: rooms } = await db.query(
`SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos, category_id
FROM rooms WHERE hotel_id = $1 AND status = 'available'
ORDER BY sort_order, number`,
[hotel.id],
)
return rooms
.filter((r: any) => !occupiedIds.has(r.id))
.map((r: any) => ({
id: r.id,
number: r.number,
name: r.name || `Номер ${r.number}`,
type: r.type,
floor: r.floor,
maxGuests: r.max_guests,
baseRate: Number(r.base_rate),
amenities: r.amenities ?? [],
description: r.description ?? '',
photos: r.photos ?? [],
categoryId: r.category_id ?? null,
}))
}
)
// ── POST /api/widget/:slug/bookings ───────────────────────────────────────
// Create an online booking (no auth)
fastify.post<SlugParam & {
Body: {
roomId?: string; categoryId?: string; checkIn: string; checkOut: string
guestName: string; guestEmail?: string; guestPhone?: string
adults?: number; children?: number
totalAmount: number; notes?: string
services?: Array<{ name: string; price: number }>
}
}>('/api/widget/:slug/bookings', async (req, reply) => {
const hotel = await getHotelId(req.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
let { roomId, categoryId, checkIn, checkOut, guestName, guestEmail, guestPhone,
adults = 1, children = 0, totalAmount, notes, services } = req.body
if (!checkIn || !checkOut || !guestName || !totalAmount) {
return reply.code(400).send({ error: 'Missing required fields' })
}
if (!roomId && !categoryId) {
return reply.code(400).send({ error: 'roomId or categoryId required' })
}
// If categoryId given, find a room in category using hotel's assignment strategy
if (categoryId && !roomId) {
// Read assignment strategy
const { rows: stratRows } = await db.query(
`SELECT value FROM hotel_settings WHERE hotel_id = $1 AND key = 'assignment_strategy'`,
[hotel.id],
)
const strategy: string = stratRows[0]?.value?.replace(/"/g, '') ?? 'sequential'
// Get all available rooms in category
const { rows: candRows } = await db.query(
`SELECT id, sort_order, number FROM rooms
WHERE hotel_id = $1 AND category_id = $2 AND status = 'available'
AND id NOT IN (
SELECT room_id FROM bookings
WHERE hotel_id = $1
AND status NOT IN ('cancelled','no_show','checked_out')
AND check_in < $4 AND check_out > $3
)
ORDER BY sort_order, number`,
[hotel.id, categoryId, checkIn, checkOut],
)
if (!candRows.length) return reply.code(409).send({ error: 'No rooms available in this category for selected dates' })
let chosenId: string = candRows[0].id
if (strategy === 'spread' || strategy === 'together') {
// Get sort_orders of currently occupied rooms (same dates, any category)
const { rows: occupiedRows } = await db.query(
`SELECT r.sort_order FROM rooms r
JOIN bookings b ON b.room_id = r.id
WHERE b.hotel_id = $1
AND b.status NOT IN ('cancelled','no_show','checked_out')
AND b.check_in < $3 AND b.check_out > $2`,
[hotel.id, checkIn, checkOut],
)
const occupiedOrders = occupiedRows.map((r: any) => Number(r.sort_order))
if (occupiedOrders.length === 0) {
// No occupied rooms — for spread pick the middle one, for together pick first
chosenId = strategy === 'spread'
? candRows[Math.floor(candRows.length / 2)].id
: candRows[0].id
} else {
// Score each candidate by min distance to occupied rooms
const scored = candRows.map((r: any) => {
const order = Number(r.sort_order)
const minDist = Math.min(...occupiedOrders.map((o: number) => Math.abs(o - order)))
return { id: r.id, minDist }
})
if (strategy === 'spread') {
// Pick room furthest from any occupied room
scored.sort((a, b) => b.minDist - a.minDist)
} else {
// together: pick room closest to occupied rooms
scored.sort((a, b) => a.minDist - b.minDist)
}
chosenId = scored[0].id
}
}
// sequential / manual / fallback: already set to candRows[0].id
roomId = chosenId
}
// Check availability for specific room
const { rows: conflict } = await db.query(
`SELECT id FROM bookings
WHERE hotel_id = $1 AND room_id = $2
AND status NOT IN ('cancelled','no_show','checked_out')
AND check_in < $4 AND check_out > $3`,
[hotel.id, roomId, checkIn, checkOut],
)
if (conflict.length > 0) {
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, '[^0-9]', '', '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'
// Read payment timeout setting (in minutes, default 15)
const { rows: tRows } = await db.query(
`SELECT value FROM hotel_settings WHERE hotel_id = $1 AND key = 'widget_payment_timeout'`,
[hotel.id],
)
const paymentTimeoutMin: number = tRows[0]?.value ? Number(tRows[0].value) || 15 : 15
const paymentExpiresAt = paymentMethod === 'yookassa'
? new Date(Date.now() + paymentTimeoutMin * 60_000).toISOString()
: null
// Create online_booking record
const { rows } = await db.query(
`INSERT INTO online_bookings
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
adults, children, total_amount, notes, services, payment_method, payment_expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id`,
[hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null,
checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null,
JSON.stringify(services ?? []), paymentMethod, paymentExpiresAt],
)
const onlineBookingId = rows[0].id
// Create booking in main table
// status='reserved' while awaiting payment → 'confirmed' after webhook; 'confirmed' directly if no payment
const bookingStatus = paymentMethod === 'yookassa' ? 'reserved' : 'confirmed'
const { rows: bRows } = await db.query(
`INSERT INTO bookings
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
adults, children, total_amount, source, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'website',$11,$12) RETURNING id`,
[hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null,
checkIn, checkOut, adults, children, totalAmount.toFixed(2), bookingStatus, notes ?? null],
)
const bookingId = bRows[0].id
// Link online_booking → booking
await db.query('UPDATE online_bookings SET booking_id = $1 WHERE id = $2',
[bookingId, onlineBookingId])
// Auto-create/update guest in CRM
upsertGuestFromBooking({
hotelId: hotel.id,
guestName,
guestEmail,
guestPhone,
})
if (paymentMethod === 'yookassa') {
// Create YooKassa payment
try {
const nights = Math.ceil((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
const result = await createCharge({
shopId: gateway.shop_id,
secretKey: gateway.secret_key,
amount: totalAmount,
description: `Бронирование: ${guestName}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
returnUrl: `https://app.hotelsync.ru/booking-confirm/${onlineBookingId}`,
})
await db.query(
`UPDATE online_bookings SET yookassa_payment_id = $1, yookassa_confirmation_url = $2
WHERE id = $3`,
[result.id, result.confirmation?.confirmation_url ?? null, onlineBookingId],
)
return {
bookingId: onlineBookingId,
status: 'pending_payment',
confirmationUrl: result.confirmation?.confirmation_url,
}
} catch (err) {
// Payment creation failed — still return booking as pending
return { bookingId: onlineBookingId, status: 'pending', confirmationUrl: null }
}
}
return { bookingId: onlineBookingId, status: 'confirmed', confirmationUrl: null }
})
// ── GET /api/widget/:slug/bookings/:bookingId/status ──────────────────────
fastify.get<SlugIdParam>('/api/widget/:slug/bookings/:bookingId/status', async (req, reply) => {
const { slug, bookingId } = req.params
const hotel = await getHotelId(slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
'SELECT status, yookassa_status, yookassa_confirmation_url FROM online_bookings WHERE id = $1 AND hotel_id = $2',
[bookingId, hotel.id],
)
if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' })
return rows[0]
})
}
export default publicWidget