Files
hotelsync/backend/src/routes/netup.ts
HotelSync ed397dbcd9 Remove pull integration; persist push log to DB (last 50 per hotel)
- Remove TravelLine/netup-pms pull route (travelline.ts unused)
- Remove pull integration section from TvWelcomePage (token field, API URL, etc.)
- Migration 008: netup_push_log table (hotel_id, action, status, request/response body)
- recordPush() now writes to DB in addition to in-memory buffer
- getLog reads from DB so logs survive server restarts
- clearLog deletes from DB
- Limit enforced: 50 rows per hotel (oldest auto-deleted)

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

477 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 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<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)
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<string, unknown>) => ({
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<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 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<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, 'Тестовый гость', '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