import type { FastifyPluginAsync } from 'fastify' import { db } from '../db' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } // ── Push event log (check-in / check-out calls to NetUP) ───────────────────── export type PushEvent = { ts: string action: 'check-in' | 'check-out' | 'message' hotelId: string roomNumber: string netupRoom: string url: string requestBody?: unknown status: 'ok' | 'error' | 'skipped' httpStatus?: number responseBody?: string error?: string } // In-memory fallback (used before DB write completes; also keeps last 50) export const pushLog: PushEvent[] = [] function recordPush(e: PushEvent) { pushLog.push(e) if (pushLog.length > 50) pushLog.shift() // Persist to DB (fire-and-forget); also enforce 50-row limit per hotel db.query( `INSERT INTO netup_push_log (hotel_id, ts, action, room_number, netup_room, url, request_body, status, http_status, response_body, error) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [e.hotelId, e.ts, e.action, e.roomNumber, e.netupRoom, e.url, e.requestBody ? JSON.stringify(e.requestBody) : null, e.status, e.httpStatus ?? null, e.responseBody ?? null, e.error ?? null], ).then(() => // Keep only last 50 rows per hotel db.query( `DELETE FROM netup_push_log WHERE hotel_id = $1 AND id NOT IN ( SELECT id FROM netup_push_log WHERE hotel_id = $1 ORDER BY ts DESC LIMIT 50 )`, [e.hotelId], ) ).catch(() => { /* best-effort */ }) } const netup: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) return rows[0]?.id ?? null } const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug // ── Вспомогательная функция: HTTP запрос к NetUP MW ────────────────────── async function netupReq( serverUrl: string, username: string, password: string, method: string, path: string, body?: unknown, ): Promise<{ ok: boolean; status: number; data: unknown }> { const base = serverUrl.replace(/\/$/, '') const url = `${base}/mw/api${path}` const cred = Buffer.from(`${username}:${password}`).toString('base64') const res = await fetch(url, { method, headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(8000), }) let data: unknown = null try { data = await res.json() } catch { /* empty response */ } return { ok: res.ok, status: res.status, data } } // ── GET /api/hotels/:slug/netup/settings ──────────────────────────────── fastify.get( '/api/hotels/:slug/netup/settings', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( 'SELECT server_url, username, password, default_language, enabled, tl_token FROM netup_settings WHERE hotel_id = $1', [hotelId], ) return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '' } }, ) // ── PATCH /api/hotels/:slug/netup/settings ─────────────────────────────── fastify.patch( '/api/hotels/:slug/netup/settings', { onRequest: [fastify.authenticate] }, async (request, reply) => { if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' }) const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { server_url, username, password, default_language, enabled, tl_token } = request.body await db.query( `INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (hotel_id) DO UPDATE SET server_url = EXCLUDED.server_url, username = EXCLUDED.username, password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END, default_language = EXCLUDED.default_language, enabled = EXCLUDED.enabled, tl_token = EXCLUDED.tl_token, updated_at = NOW()`, [hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? ''], ) return { ok: true } }, ) // ── POST /api/hotels/:slug/netup/test ──────────────────────────────────── fastify.post( '/api/hotels/:slug/netup/test', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( 'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1', [hotelId], ) const cfg = rows[0] if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' }) try { const result = await netupReq(cfg.server_url, cfg.username, cfg.password, 'GET', '/hotel-room') if (result.ok) return { ok: true, message: 'Подключение успешно' } return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` }) } catch (err) { const msg = err instanceof Error ? err.message : 'Ошибка соединения' return reply.code(502).send({ error: msg }) } }, ) // ── GET /api/hotels/:slug/netup/rooms ──────────────────────────────────── fastify.get( '/api/hotels/:slug/netup/rooms', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( `SELECT r.id, r.number, r.type, COALESCE(m.netup_room_number, '') as netup_room_number FROM rooms r LEFT JOIN netup_room_mapping m ON m.pms_room_id = r.id AND m.hotel_id = $1 WHERE r.hotel_id = $1 ORDER BY r.number`, [hotelId], ) return rows }, ) // ── POST /api/hotels/:slug/netup/rooms ─────────────────────────────────── // Body: { mappings: [{ pms_room_id, netup_room_number }] } fastify.post( '/api/hotels/:slug/netup/rooms', { onRequest: [fastify.authenticate] }, async (request, reply) => { if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' }) const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { mappings } = request.body if (!Array.isArray(mappings)) return reply.code(400).send({ error: 'mappings required' }) // Delete existing and insert fresh await db.query('DELETE FROM netup_room_mapping WHERE hotel_id = $1', [hotelId]) for (const m of mappings) { if (m.netup_room_number?.trim()) { await db.query( `INSERT INTO netup_room_mapping (hotel_id, pms_room_id, netup_room_number) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [hotelId, m.pms_room_id, m.netup_room_number.trim()], ) } } return { ok: true } }, ) // ── POST /api/hotels/:slug/netup/message ───────────────────────────────── // Отправить сообщение на TV в номер (из карточки брони) fastify.post( '/api/hotels/:slug/netup/message', { onRequest: [fastify.authenticate] }, async (request, reply) => { if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' }) const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { room_id, message, guest_name } = request.body if (!room_id || !message?.trim()) return reply.code(400).send({ error: 'room_id and message required' }) // Get NetUP settings const { rows: [cfg] } = await db.query( 'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true', [hotelId], ) if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' }) // Get room mapping const { rows: [mapping] } = await db.query( `SELECT m.netup_room_number, r.number as room_number FROM netup_room_mapping m JOIN rooms r ON r.id = m.pms_room_id WHERE m.hotel_id = $1 AND m.pms_room_id = $2`, [hotelId, room_id], ) if (!mapping) return reply.code(400).send({ error: 'Сопоставление для этого номера не найдено' }) try { const result = await netupReq( cfg.server_url, cfg.username, cfg.password, 'POST', '/system-message', { name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`, recipient_type: 'all', recipient: 0, message, }, ) if (!result.ok) { return reply.code(502).send({ error: `NetUP вернул ошибку: ${result.status}` }) } return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : 'Ошибка соединения' return reply.code(502).send({ error: msg }) } }, ) // ── GET /api/hotels/:slug/netup/log ───────────────────────────────────── fastify.get( '/api/hotels/:slug/netup/log', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) const { rows } = await db.query( `SELECT ts, action, room_number, netup_room, url, request_body, status, http_status, response_body, error FROM netup_push_log WHERE hotel_id = $1 ORDER BY ts DESC LIMIT 50`, [hotelId], ) const pushEvents = rows.map((r: Record) => ({ ts: (r.ts as Date).toISOString(), action: r.action, roomNumber: r.room_number, netupRoom: r.netup_room, url: r.url, requestBody: r.request_body, status: r.status, httpStatus: r.http_status, responseBody: r.response_body, error: r.error, })) return { pushEvents, pullRequests: [] } }, ) // ── DELETE /api/hotels/:slug/netup/log — очистить оба лога ─────────────── fastify.delete( '/api/hotels/:slug/netup/log', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId2 = await getHotelId(slug) if (hotelId2) await db.query('DELETE FROM netup_push_log WHERE hotel_id = $1', [hotelId2]) pushLog.length = 0 return { ok: true } }, ) // ── POST /api/hotels/:slug/netup/test-checkin — ручной тест заселения ──── fastify.post( '/api/hotels/:slug/netup/test-checkin', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { room_id } = request.body if (!room_id) return reply.code(400).send({ error: 'room_id required' }) // Get room info const { rows: [room] } = await db.query( 'SELECT id, number FROM rooms WHERE id = $1 AND hotel_id = $2', [room_id, hotelId], ) if (!room) return reply.code(404).send({ error: 'Room not found' }) const result = await notifyNetupCheckinInternal( hotelId, room_id, room.number, 'Тестовый гость', '99999999', ) if (result.ok) return { ok: true, message: `Check-in отправлен в NetUP (номер ${result.netupRoom})` } return reply.code(502).send({ error: result.error ?? 'Ошибка' }) }, ) } // ── Internal helpers ────────────────────────────────────────────────────────── async function getNetupCfg(hotelId: string, needEnabled = true) { const cond = needEnabled ? 'AND enabled = true' : '' const { rows: [cfg] } = await db.query( `SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 ${cond}`, [hotelId], ) return cfg as { server_url: string; username: string; password: string; default_language: string } | undefined } async function getMapping(hotelId: string, roomId: string) { const { rows: [m] } = await db.query( `SELECT m.netup_room_number, r.number as room_number FROM netup_room_mapping m JOIN rooms r ON r.id = m.pms_room_id WHERE m.hotel_id = $1 AND m.pms_room_id = $2`, [hotelId, roomId], ) return m as { netup_room_number: string; room_number: string } | undefined } async function notifyNetupCheckinInternal( hotelId: string, roomId: string, roomNumber: string, guestName: string, reservationId: string, language?: string, ): Promise<{ ok: boolean; netupRoom?: string; error?: string }> { const cfg = await getNetupCfg(hotelId) if (!cfg?.server_url) { recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'NetUP не настроен или отключён' }) return { ok: false, error: 'NetUP не настроен или отключён' } } const mapping = await getMapping(hotelId, roomId) if (!mapping) { recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' }) return { ok: false, error: 'Сопоставление номера не найдено' } } const base = cfg.server_url.replace(/\/$/, '') const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in` const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') // NetUP expects reservation_id as a numeric string (parses with strconv.Atoi) // Convert UUID to a stable numeric string by taking first 8 hex chars → decimal const resIdStr = String(parseInt(reservationId.replace(/-/g, '').slice(0, 8), 16) % 100000000) const requestBody = { reservation_id: resIdStr, name: guestName, language: language ?? cfg.default_language } try { const res = await fetch(url, { method: 'POST', headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), signal: AbortSignal.timeout(6000), }) const responseText = await res.text().catch(() => '') const event: PushEvent = { ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: res.ok ? 'ok' : 'error', httpStatus: res.status, responseBody: responseText || undefined, } if (!res.ok) event.error = `HTTP ${res.status}` recordPush(event) return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}: ${responseText}` } } catch (err) { const error = err instanceof Error ? err.message : 'Ошибка соединения' recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: 'error', error }) return { ok: false, error } } } // ── Public functions called from bookings.ts ────────────────────────────────── export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) { // Get room number for logging const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] })) await notifyNetupCheckinInternal(hotelId, roomId, r?.number ?? roomId, guestName, reservationId, language) } export async function notifyNetupCheckout(hotelId: string, roomId: string) { const cfg = await getNetupCfg(hotelId) if (!cfg?.server_url) return const mapping = await getMapping(hotelId, roomId) const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] })) const roomNumber = r?.number ?? roomId if (!mapping) { recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' }) return } const base = cfg.server_url.replace(/\/$/, '') const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out` const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') try { const res = await fetch(url, { method: 'POST', headers: { Authorization: `Basic ${cred}` }, signal: AbortSignal.timeout(6000), }) const responseText = await res.text().catch(() => '') recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, status: res.ok ? 'ok' : 'error', httpStatus: res.status, responseBody: responseText || undefined, error: res.ok ? undefined : `HTTP ${res.status}`, }) } catch (err) { const error = err instanceof Error ? err.message : 'Ошибка соединения' recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, status: 'error', error }) } } export default netup