diff --git a/backend/src/routes/netup.ts b/backend/src/routes/netup.ts index 1d0c62a..da9e761 100644 --- a/backend/src/routes/netup.ts +++ b/backend/src/routes/netup.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { captured } from './travelline' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } @@ -237,8 +238,32 @@ const netup: FastifyPluginAsync = async (fastify) => { }, ) - // ── Внутренний хелпер: вызывается из bookings route при смене статуса ─── - // Экспортируем для использования в bookings.ts + // ── GET /api/hotels/:slug/netup/log — просмотр запросов от NetUP ────────── + fastify.get( + '/api/hotels/:slug/netup/log', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + return { count: captured.length, requests: [...captured].reverse() } + }, + ) + + // ── DELETE /api/hotels/:slug/netup/log — очистить лог ──────────────────── + fastify.delete( + '/api/hotels/:slug/netup/log', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + captured.length = 0 + return { ok: true } + }, + ) } // ── Отдельная функция для check-in/check-out из bookings.ts ────────────── diff --git a/backend/src/routes/travelline.ts b/backend/src/routes/travelline.ts index ae68ba3..218cd5c 100644 --- a/backend/src/routes/travelline.ts +++ b/backend/src/routes/travelline.ts @@ -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: + * API URL: https://api.hotelsync.ru/netup-pms + * Token: */ 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 + query: unknown + body: unknown +}) { + captured.push({ + ts: new Date().toISOString(), + method: request.method, + url: request.url, + headers: request.headers, + query: request.query as Record, + 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, - 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)') + // 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[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; 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=... + if (auth?.startsWith('Token ')) return auth.slice(6) 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: [], - } + 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) => ({ + 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 diff --git a/src/lib/api.ts b/src/lib/api.ts index 073b59f..14e69a5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -287,6 +287,13 @@ export const api = { sendMessage: (slug: string, roomId: string, message: string, guestName?: string) => req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/message`, { room_id: roomId, message, guest_name: guestName }), + + getLog: (slug: string) => + req<{ count: number; requests: { ts: string; method: string; url: string; headers: Record; query: Record; body: unknown }[] }>( + 'GET', `/api/hotels/${slug}/netup/log`), + + clearLog: (slug: string) => + req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/netup/log`), }, } diff --git a/src/pages/TvWelcomePage.tsx b/src/pages/TvWelcomePage.tsx index 4c7219e..faef72b 100644 --- a/src/pages/TvWelcomePage.tsx +++ b/src/pages/TvWelcomePage.tsx @@ -1,10 +1,10 @@ -import { useState, useEffect } from 'react' -import { Tv2, Plug, Map, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2 } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2, RefreshCw, Trash2 } from 'lucide-react' import { cn } from '../lib/utils' import { api } from '../lib/api' import { useAuth } from '../contexts/AuthContext' -type Tab = 'connection' | 'rooms' +type Tab = 'connection' | 'rooms' | 'log' const LANGUAGES = [ { value: 'ru_RU', label: 'Русский' }, @@ -14,6 +14,17 @@ const LANGUAGES = [ { value: 'ar_AE', label: 'العربية' }, ] +const PMS_API_URL = 'https://api.hotelsync.ru/netup-pms' + +type LogEntry = { + ts: string + method: string + url: string + headers: Record + query: Record + body: unknown +} + export function TvWelcomePage() { const { user } = useAuth() const slug = user?.hotelSlug ?? '' @@ -39,6 +50,11 @@ export function TvWelcomePage() { const [roomsSaving, setRoomsSaving] = useState(false) const [roomsSaved, setRoomsSaved] = useState(false) + // Log + const [logEntries, setLogEntries] = useState([]) + const [logLoading, setLogLoading] = useState(false) + const [selectedEntry, setSelectedEntry] = useState(null) + // Load settings useEffect(() => { if (!slug) return @@ -62,6 +78,21 @@ export function TvWelcomePage() { .catch(() => {}) }, [tab, slug]) + // Load log when tab switches + const loadLog = useCallback(() => { + if (!slug) return + setLogLoading(true) + api.netup.getLog(slug) + .then(r => setLogEntries(r.requests)) + .catch(() => {}) + .finally(() => setLogLoading(false)) + }, [slug]) + + useEffect(() => { + if (tab !== 'log') return + loadLog() + }, [tab, loadLog]) + const handleSaveConnection = async () => { setSaving(true) setTestResult(null) @@ -117,6 +148,12 @@ export function TvWelcomePage() { } } + const handleClearLog = async () => { + await api.netup.clearLog(slug).catch(() => {}) + setLogEntries([]) + setSelectedEntry(null) + } + const updateRoomNetup = (id: string, val: string) => setRooms(prev => prev.map(r => r.id === id ? { ...r, netupRoomNumber: val } : r)) @@ -137,8 +174,9 @@ export function TvWelcomePage() { {/* Tabs */}
{([ - { id: 'connection', label: 'Подключение', icon: Plug }, + { id: 'connection', label: 'Подключение', icon: Plug }, { id: 'rooms', label: 'Сопоставление номеров', icon: Map }, + { id: 'log', label: 'Диагностика', icon: Activity }, ] as const).map(t => (