Add TravelLine pull integration for NetUP: capture endpoint + tl_token

- New /travelline route: accepts all NetUP polls, logs them to in-memory
  ring buffer (GET /travelline/_log to inspect), responds with active
  reservations in a generic PMS format
- Migration 007: adds tl_token column to netup_settings
- PATCH netup/settings now saves tl_token; GET returns it
- TvWelcomePage: new TravelLine section with read-only API URL + copy
  button and token input field

Setup in NetUP: Integration type = TravelLine,
API URL = https://api.hotelsync.ru/travelline, Token = <tl_token>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 18:57:10 +03:00
parent 05d0c94a9a
commit 8350309645
6 changed files with 259 additions and 10 deletions

View File

@@ -16,6 +16,7 @@ import housekeepingRoutes from './routes/housekeeping'
import channelsRoutes from './routes/channels'
import usersRoutes from './routes/users'
import netupRoutes from './routes/netup'
import travellineRoutes from './routes/travelline'
export async function buildApp() {
const fastify = Fastify({
@@ -73,6 +74,7 @@ export async function buildApp() {
await fastify.register(channelsRoutes)
await fastify.register(usersRoutes)
await fastify.register(netupRoutes)
await fastify.register(travellineRoutes)
return fastify
}

View File

@@ -55,15 +55,15 @@ const netup: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
'SELECT server_url, username, password, default_language, enabled FROM netup_settings WHERE hotel_id = $1',
'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 }
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 } }>(
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) => {
@@ -75,19 +75,20 @@ const netup: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { server_url, username, password, default_language, enabled } = request.body
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, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`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],
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? ''],
)
return { ok: true }
},

View File

@@ -0,0 +1,179 @@
/**
* TravelLine WebPMS compatibility layer for NetUP IPTV integration.
*
* 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.
*
* Configure in NetUP:
* Integration type: TravelLine
* API URL: https://api.hotelsync.ru/travelline
* Token: <any token stored in netup_settings for the hotel>
*/
import type { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
// In-memory ring buffer — last 100 captured requests
const MAX_CAPTURE = 100
const captured: {
ts: string
method: string
url: string
headers: Record<string, string | string[] | undefined>
query: Record<string, unknown>
body: unknown
}[] = []
const travelline: FastifyPluginAsync = async (fastify) => {
// ── GET /travelline/_log — view captured requests (auth required) ──────────
fastify.get(
'/travelline/_log',
{ onRequest: [fastify.authenticate] },
async () => ({ count: captured.length, requests: captured }),
)
// ── 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)')
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) 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=...
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: [],
}
}
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,
r.number as room_number, r.id as room_id,
m.netup_room_number
FROM bookings b
JOIN rooms r ON r.id = b.room_id
LEFT JOIN netup_room_mapping m ON m.pms_room_id = r.id AND m.hotel_id = $1
WHERE b.hotel_id = $1
AND b.status IN ('confirmed','checked_in')
AND b.check_out >= CURRENT_DATE
ORDER BY b.check_in`,
[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,
}))
return { success: true, reservations }
}
export default travelline