- 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>
435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
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
|