Add Diagnostics tab to TV Welcome: view NetUP poll requests in UI

- Rename pull endpoint /travelline → /netup-pms (cleaner branding)
- Add GET/DELETE /api/hotels/:slug/netup/log endpoints
- TvWelcomePage: new 'Диагностика' tab shows incoming NetUP requests
  with method, URL, timestamp list + detail panel (headers, query, body)
- Remove 'TravelLine' wording from UI, replace with 'Pull-интеграция'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 19:08:57 +03:00
parent 8350309645
commit 142be49211
4 changed files with 254 additions and 140 deletions

View File

@@ -1,21 +1,21 @@
/**
* TravelLine WebPMS compatibility layer for NetUP IPTV integration.
* NetUP PMS pull-integration endpoint.
*
* Phase 1 — request capture: log everything NetUP sends so we can reverse-engineer the format.
* Phase 2 — real implementation: respond with actual reservation/room data.
* NetUP polls this URL every N minutes (configured as "TravelLine" integration type in NetUP).
* We log every request so we can see exactly what NetUP sends, and respond with active bookings.
*
* Configure in NetUP:
* Integration type: TravelLine
* API URL: https://api.hotelsync.ru/travelline
* Token: <any token stored in netup_settings for the hotel>
* API URL: https://api.hotelsync.ru/netup-pms
* Token: <tl_token from netup_settings>
*/
import type { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
// In-memory ring buffer — last 100 captured requests
// In-memory ring buffer — last 100 captured requests (shared with netup.ts via export)
const MAX_CAPTURE = 100
const captured: {
export const captured: {
ts: string
method: string
url: string
@@ -24,126 +24,62 @@ const captured: {
body: unknown
}[] = []
const travelline: FastifyPluginAsync = async (fastify) => {
function recordRequest(request: {
method: string
url: string
headers: Record<string, string | string[] | undefined>
query: unknown
body: unknown
}) {
captured.push({
ts: new Date().toISOString(),
method: request.method,
url: request.url,
headers: request.headers,
query: request.query as Record<string, unknown>,
body: request.body,
})
if (captured.length > MAX_CAPTURE) captured.shift()
}
// ── GET /travelline/_log — view captured requests (auth required) ──────────
fastify.get(
'/travelline/_log',
{ onRequest: [fastify.authenticate] },
async () => ({ count: captured.length, requests: captured }),
)
const netupPms: FastifyPluginAsync = async (fastify) => {
// ── DELETE /travelline/_log — clear capture buffer ────────────────────────
fastify.delete(
'/travelline/_log',
{ onRequest: [fastify.authenticate] },
async () => { captured.length = 0; return { ok: true } },
)
// ── Catch-all: log every request NetUP makes ──────────────────────────────
// NetUP polls: GET /travelline (or subpath) with token in header/query
fastify.all(
'/travelline',
{ config: { rawBody: false } },
async (request, reply) => {
const entry = {
ts: new Date().toISOString(),
method: request.method,
url: request.url,
headers: request.headers as Record<string, string | string[] | undefined>,
query: request.query as Record<string, unknown>,
body: request.body,
}
captured.push(entry)
if (captured.length > MAX_CAPTURE) captured.shift()
fastify.log.info({ netup_capture: entry }, 'NetUP TravelLine poll')
// Find hotel by token
const token = extractToken(request)
if (!token) {
return reply.code(401).send({ error: 'Unauthorized' })
}
const { rows: [row] } = await db.query(
`SELECT hs.hotel_id, h.slug
FROM netup_settings hs
JOIN hotels h ON h.id = hs.hotel_id
WHERE hs.tl_token = $1`,
[token],
).catch(() => ({ rows: [] as { hotel_id: string; slug: string }[] }))
if (!row) {
// Token not found — still respond with empty data so NetUP logs the attempt
return reply.send(buildEmptyResponse())
}
// Build response with current hotel reservations
return reply.send(await buildResponse(row.hotel_id))
},
)
// Also catch paths like /travelline/something
fastify.all(
'/travelline/*',
{ config: { rawBody: false } },
async (request, reply) => {
const entry = {
ts: new Date().toISOString(),
method: request.method,
url: request.url,
headers: request.headers as Record<string, string | string[] | undefined>,
query: request.query as Record<string, unknown>,
body: request.body,
}
captured.push(entry)
if (captured.length > MAX_CAPTURE) captured.shift()
fastify.log.info({ netup_capture: entry }, 'NetUP TravelLine poll (subpath)')
// Catch /netup-pms and /netup-pms/* — everything NetUP might call
for (const pattern of ['/netup-pms', '/netup-pms/*']) {
fastify.all(pattern, async (request, reply) => {
recordRequest(request as Parameters<typeof recordRequest>[0])
fastify.log.info({ method: request.method, url: request.url }, 'NetUP PMS poll')
const token = extractToken(request)
if (!token) return reply.code(401).send({ error: 'Unauthorized' })
const { rows: [row] } = await db.query(
`SELECT hs.hotel_id, h.slug
FROM netup_settings hs
JOIN hotels h ON h.id = hs.hotel_id
WHERE hs.tl_token = $1`,
`SELECT hs.hotel_id FROM netup_settings hs WHERE hs.tl_token = $1`,
[token],
).catch(() => ({ rows: [] as { hotel_id: string; slug: string }[] }))
).catch(() => ({ rows: [] as { hotel_id: string }[] }))
if (!row) return reply.send(buildEmptyResponse())
return reply.send(await buildResponse(row.hotel_id))
},
)
})
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function extractToken(request: { headers: Record<string, string | string[] | undefined>; query: unknown }): string | null {
// Try Authorization: Bearer <token>
const auth = request.headers['authorization'] as string | undefined
if (auth?.startsWith('Bearer ')) return auth.slice(7)
// Try Authorization: Token <token>
if (auth?.startsWith('Token ')) return auth.slice(6)
// Try query param ?token=... or ?api_key=...
if (auth?.startsWith('Token ')) return auth.slice(6)
const q = request.query as Record<string, string>
return q?.token ?? q?.api_key ?? null
}
function buildEmptyResponse() {
// Return a structure that looks like a valid TravelLine/PMS response with zero data
// so NetUP won't crash. We'll update this once we see what format NetUP actually expects.
return {
success: true,
reservations: [],
rooms: [],
}
return { success: true, reservations: [], rooms: [] }
}
async function buildResponse(hotelId: string) {
// Fetch active bookings
const { rows: bookings } = await db.query(
`SELECT b.id, b.guest_name, b.guest_email, b.guest_phone,
b.check_in, b.check_out, b.status, b.adults, b.children,
@@ -159,21 +95,21 @@ async function buildResponse(hotelId: string) {
[hotelId],
)
const reservations = bookings.map(b => ({
id: b.id,
status: b.status === 'checked_in' ? 'CheckedIn' : 'Confirmed',
guestName: b.guest_name,
guestEmail: b.guest_email ?? '',
guestPhone: b.guest_phone ?? '',
roomNumber: b.netup_room_number ?? b.room_number,
pmsRoomId: b.room_id,
checkIn: b.check_in,
checkOut: b.check_out,
adults: b.adults,
children: b.children ?? 0,
const reservations = bookings.map((b: Record<string, unknown>) => ({
id: b.id,
status: b.status === 'checked_in' ? 'CheckedIn' : 'Confirmed',
guestName: b.guest_name,
guestEmail: b.guest_email ?? '',
guestPhone: b.guest_phone ?? '',
roomNumber: b.netup_room_number ?? b.room_number,
pmsRoomId: b.room_id,
checkIn: b.check_in,
checkOut: b.check_out,
adults: b.adults,
children: b.children ?? 0,
}))
return { success: true, reservations }
}
export default travelline
export default netupPms