From 83503096456a39ad2c0d1f2013f9284e240a279f Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 18 Mar 2026 18:57:10 +0300 Subject: [PATCH] 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 = Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/007_netup_tl_token.sql | 3 + backend/src/app.ts | 2 + backend/src/routes/netup.ts | 15 +- backend/src/routes/travelline.ts | 179 ++++++++++++++++++++++ src/lib/api.ts | 4 +- src/pages/TvWelcomePage.tsx | 66 +++++++- 6 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/007_netup_tl_token.sql create mode 100644 backend/src/routes/travelline.ts diff --git a/backend/migrations/007_netup_tl_token.sql b/backend/migrations/007_netup_tl_token.sql new file mode 100644 index 0000000..5b4a350 --- /dev/null +++ b/backend/migrations/007_netup_tl_token.sql @@ -0,0 +1,3 @@ +-- Add TravelLine integration token to netup_settings +ALTER TABLE netup_settings ADD COLUMN IF NOT EXISTS tl_token TEXT NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS idx_netup_settings_tl_token ON netup_settings(tl_token) WHERE tl_token != ''; diff --git a/backend/src/app.ts b/backend/src/app.ts index c00a750..d86c1d0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 } diff --git a/backend/src/routes/netup.ts b/backend/src/routes/netup.ts index 59618ac..1d0c62a 100644 --- a/backend/src/routes/netup.ts +++ b/backend/src/routes/netup.ts @@ -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( + fastify.patch( '/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 } }, diff --git a/backend/src/routes/travelline.ts b/backend/src/routes/travelline.ts new file mode 100644 index 0000000..ae68ba3 --- /dev/null +++ b/backend/src/routes/travelline.ts @@ -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: + */ + +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 + query: Record + 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, + query: request.query as Record, + 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, + query: request.query as Record, + 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; query: unknown }): string | null { + // Try Authorization: Bearer + const auth = request.headers['authorization'] as string | undefined + if (auth?.startsWith('Bearer ')) return auth.slice(7) + // Try Authorization: Token + if (auth?.startsWith('Token ')) return auth.slice(6) + // Try query param ?token=... or ?api_key=... + const q = request.query as Record + 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 diff --git a/src/lib/api.ts b/src/lib/api.ts index e522d1a..073b59f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -269,10 +269,10 @@ export const api = { // ── NetUP IPTV ──────────────────────────────────────────────────────────── netup: { getSettings: (slug: string) => - req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean }>( + req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string }>( 'GET', `/api/hotels/${slug}/netup/settings`), - saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean }) => + saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string }) => req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data), testConnection: (slug: string) => diff --git a/src/pages/TvWelcomePage.tsx b/src/pages/TvWelcomePage.tsx index 6b0832e..4c7219e 100644 --- a/src/pages/TvWelcomePage.tsx +++ b/src/pages/TvWelcomePage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Tv2, Plug, Map, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff } from 'lucide-react' +import { Tv2, Plug, Map, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2 } from 'lucide-react' import { cn } from '../lib/utils' import { api } from '../lib/api' import { useAuth } from '../contexts/AuthContext' @@ -30,7 +30,9 @@ export function TvWelcomePage() { const [password, setPassword] = useState('') const [language, setLanguage] = useState('ru_RU') const [enabled, setEnabled] = useState(true) + const [tlToken, setTlToken] = useState('') const [settingsLoaded, setSettingsLoaded] = useState(false) + const [copied, setCopied] = useState(false) // Room mappings const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([]) @@ -46,6 +48,7 @@ export function TvWelcomePage() { setUsername(s.username) setLanguage(s.defaultLanguage) setEnabled(s.enabled) + setTlToken(s.tlToken ?? '') setSettingsLoaded(true) }) .catch(() => setSettingsLoaded(true)) @@ -69,6 +72,7 @@ export function TvWelcomePage() { password: password || undefined, default_language: language, enabled, + tl_token: tlToken, }) setPassword('') } finally { @@ -86,6 +90,7 @@ export function TvWelcomePage() { password: password || undefined, default_language: language, enabled, + tl_token: tlToken, }) const res = await api.netup.testConnection(slug) setTestResult({ ok: true, msg: res.message }) @@ -244,6 +249,65 @@ export function TvWelcomePage() {

+
+ + {/* TravelLine / Pull integration */} +
+
+

+ Интеграция через TravelLine (pull-режим) +

+

+ Альтернативный метод: NetUP сам опрашивает наш сервер. Настройте в NetUP тип интеграции «TravelLine» и укажите адрес ниже. +

+
+ + {/* API URL (read-only) */} +
+ +
+ + +
+
+ + {/* Token */} +
+ + setTlToken(e.target.value)} + placeholder="Придумайте любой секретный токен" + className="input w-full font-mono text-sm" + /> +

+ Введите этот же токен в поле «Токен» настроек TravelLine в NetUP +

+
+
+ {/* Test result */} {testResult && (