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 && (