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>
This commit is contained in:
@@ -5,6 +5,26 @@ 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])
|
||||
@@ -238,7 +258,7 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/netup/log — просмотр запросов от NetUP ──────────
|
||||
// ── GET /api/hotels/:slug/netup/log ─────────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/netup/log',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -247,11 +267,18 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
return { count: captured.length, requests: [...captured].reverse() }
|
||||
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 — очистить лог ────────────────────
|
||||
// ── DELETE /api/hotels/:slug/netup/log — очистить оба лога ───────────────
|
||||
fastify.delete<SlugParam>(
|
||||
'/api/hotels/:slug/netup/log',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -261,66 +288,146 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
||||
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 ?? 'Ошибка' })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Отдельная функция для check-in/check-out из bookings.ts ──────────────
|
||||
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
|
||||
// ── 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 AND enabled = true',
|
||||
`SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 ${cond}`,
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return
|
||||
return cfg as { server_url: string; username: string; password: string; default_language: string } | undefined
|
||||
}
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
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],
|
||||
)
|
||||
if (!mapping) return
|
||||
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 base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, {
|
||||
method: 'POST',
|
||||
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),
|
||||
body: JSON.stringify({ reservation_id: reservationId, name: guestName, language: language ?? cfg.default_language }),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget: не прерываем заселение если NetUP недоступен
|
||||
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 { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
const cfg = await getNetupCfg(hotelId)
|
||||
if (!cfg?.server_url) return
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (!mapping) 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 base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`, {
|
||||
method: 'POST',
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}` },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user