- 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>
515 lines
21 KiB
TypeScript
515 lines
21 KiB
TypeScript
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
|