Files
hotelsync/backend/src/routes/netup.ts
HotelSync 44fa9d24f1 Improve NetUP diagnostics: push event log + test check-in button
- notifyNetupCheckin/Checkout now log every attempt (ok/error/skipped)
  with room number, NetUP room, URL, HTTP status, error message
- Push log exposed via GET /netup/log alongside pull request log
- New POST /netup/test-checkin: manually trigger a test check-in from UI
- Diagnostics tab split into two sections:
  - Outgoing (push): table of check-in/check-out events with status dots
  - Incoming (pull): NetUP poll requests (if TravelLine integration works)
- Test button picks first mapped room and fires a real check-in call

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

435 lines
19 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 type { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { captured } from './travelline'
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 // PMS room number
netupRoom: string // NetUP room number
url: string
status: 'ok' | 'error' | 'skipped'
httpStatus?: number
error?: string
}
const MAX_PUSH = 100
export const pushLog: PushEvent[] = []
function recordPush(e: PushEvent) {
pushLog.push(e)
if (pushLog.length > MAX_PUSH) pushLog.shift()
}
const netup: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
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<SlugParam>(
'/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<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string } }>(
'/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<SlugParam>(
'/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<SlugParam>(
'/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<SlugParam & { Body: { mappings: { pms_room_id: string; netup_room_number: string }[] } }>(
'/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<SlugParam & { Body: { room_id: string; message: string; guest_name?: string } }>(
'/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<SlugParam>(
'/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)
// Push events filtered to this hotel
const pushEvents = [...pushLog]
.filter(e => e.hotelId === hotelId)
.reverse()
// Pull requests (from NetUP TravelLine polls) — all, no hotel filter possible
const pullRequests = [...captured].reverse()
return { pushEvents, pullRequests }
},
)
// ── DELETE /api/hotels/:slug/netup/log — очистить оба лога ───────────────
fastify.delete<SlugParam>(
'/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' })
}
captured.length = 0
pushLog.length = 0
return { ok: true }
},
)
// ── POST /api/hotels/:slug/netup/test-checkin — ручной тест заселения ────
fastify.post<SlugParam & { Body: { room_id: string } }>(
'/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, 'Тестовый гость', 'test-' + Date.now(),
)
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')
try {
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ reservation_id: reservationId, name: guestName, language: language ?? cfg.default_language }),
signal: AbortSignal.timeout(6000),
})
const event: PushEvent = {
ts: new Date().toISOString(), action: 'check-in', hotelId,
roomNumber, netupRoom: mapping.netup_room_number, url,
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
}
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}` }
} 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, 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),
})
recordPush({
ts: new Date().toISOString(), action: 'check-out', hotelId,
roomNumber, netupRoom: mapping.netup_room_number, url,
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
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