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:
3
backend/migrations/007_netup_tl_token.sql
Normal file
3
backend/migrations/007_netup_tl_token.sql
Normal file
@@ -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 != '';
|
||||||
@@ -16,6 +16,7 @@ import housekeepingRoutes from './routes/housekeeping'
|
|||||||
import channelsRoutes from './routes/channels'
|
import channelsRoutes from './routes/channels'
|
||||||
import usersRoutes from './routes/users'
|
import usersRoutes from './routes/users'
|
||||||
import netupRoutes from './routes/netup'
|
import netupRoutes from './routes/netup'
|
||||||
|
import travellineRoutes from './routes/travelline'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -73,6 +74,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(channelsRoutes)
|
await fastify.register(channelsRoutes)
|
||||||
await fastify.register(usersRoutes)
|
await fastify.register(usersRoutes)
|
||||||
await fastify.register(netupRoutes)
|
await fastify.register(netupRoutes)
|
||||||
|
await fastify.register(travellineRoutes)
|
||||||
|
|
||||||
return fastify
|
return fastify
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,15 +55,15 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
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],
|
[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 ───────────────────────────────
|
// ── 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',
|
'/api/hotels/:slug/netup/settings',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -75,19 +75,20 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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(
|
await db.query(
|
||||||
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, updated_at)
|
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||||
server_url = EXCLUDED.server_url,
|
server_url = EXCLUDED.server_url,
|
||||||
username = EXCLUDED.username,
|
username = EXCLUDED.username,
|
||||||
password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END,
|
password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END,
|
||||||
default_language = EXCLUDED.default_language,
|
default_language = EXCLUDED.default_language,
|
||||||
enabled = EXCLUDED.enabled,
|
enabled = EXCLUDED.enabled,
|
||||||
|
tl_token = EXCLUDED.tl_token,
|
||||||
updated_at = NOW()`,
|
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 }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
|
|||||||
179
backend/src/routes/travelline.ts
Normal file
179
backend/src/routes/travelline.ts
Normal 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
|
||||||
@@ -269,10 +269,10 @@ export const api = {
|
|||||||
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
||||||
netup: {
|
netup: {
|
||||||
getSettings: (slug: string) =>
|
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`),
|
'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),
|
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data),
|
||||||
|
|
||||||
testConnection: (slug: string) =>
|
testConnection: (slug: string) =>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { cn } from '../lib/utils'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
@@ -30,7 +30,9 @@ export function TvWelcomePage() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [language, setLanguage] = useState('ru_RU')
|
const [language, setLanguage] = useState('ru_RU')
|
||||||
const [enabled, setEnabled] = useState(true)
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [tlToken, setTlToken] = useState('')
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
// Room mappings
|
// Room mappings
|
||||||
const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([])
|
const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([])
|
||||||
@@ -46,6 +48,7 @@ export function TvWelcomePage() {
|
|||||||
setUsername(s.username)
|
setUsername(s.username)
|
||||||
setLanguage(s.defaultLanguage)
|
setLanguage(s.defaultLanguage)
|
||||||
setEnabled(s.enabled)
|
setEnabled(s.enabled)
|
||||||
|
setTlToken(s.tlToken ?? '')
|
||||||
setSettingsLoaded(true)
|
setSettingsLoaded(true)
|
||||||
})
|
})
|
||||||
.catch(() => setSettingsLoaded(true))
|
.catch(() => setSettingsLoaded(true))
|
||||||
@@ -69,6 +72,7 @@ export function TvWelcomePage() {
|
|||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
default_language: language,
|
default_language: language,
|
||||||
enabled,
|
enabled,
|
||||||
|
tl_token: tlToken,
|
||||||
})
|
})
|
||||||
setPassword('')
|
setPassword('')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,6 +90,7 @@ export function TvWelcomePage() {
|
|||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
default_language: language,
|
default_language: language,
|
||||||
enabled,
|
enabled,
|
||||||
|
tl_token: tlToken,
|
||||||
})
|
})
|
||||||
const res = await api.netup.testConnection(slug)
|
const res = await api.netup.testConnection(slug)
|
||||||
setTestResult({ ok: true, msg: res.message })
|
setTestResult({ ok: true, msg: res.message })
|
||||||
@@ -244,6 +249,65 @@ export function TvWelcomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-slate-100 dark:border-slate-700" />
|
||||||
|
|
||||||
|
{/* TravelLine / Pull integration */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">
|
||||||
|
Интеграция через TravelLine (pull-режим)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
Альтернативный метод: NetUP сам опрашивает наш сервер. Настройте в NetUP тип интеграции «TravelLine» и укажите адрес ниже.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API URL (read-only) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
|
||||||
|
<Link2 size={13} />
|
||||||
|
API URL для NetUP
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value="https://api.hotelsync.ru/travelline"
|
||||||
|
className="input w-full font-mono text-xs bg-slate-50 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText('https://api.hotelsync.ru/travelline')
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}}
|
||||||
|
className="btn-secondary px-3 shrink-0 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{copied ? <CheckCircle2 size={13} className="text-emerald-500" /> : <Copy size={13} />}
|
||||||
|
{copied ? 'Скопировано' : 'Копировать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
Токен авторизации
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tlToken}
|
||||||
|
onChange={e => setTlToken(e.target.value)}
|
||||||
|
placeholder="Придумайте любой секретный токен"
|
||||||
|
className="input w-full font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
Введите этот же токен в поле «Токен» настроек TravelLine в NetUP
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test result */}
|
{/* Test result */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|||||||
Reference in New Issue
Block a user