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('/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 = {} 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('/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( '/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( '/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 } }>('/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('/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