diff --git a/backend/migrations/008_netup_push_log.sql b/backend/migrations/008_netup_push_log.sql new file mode 100644 index 0000000..444f971 --- /dev/null +++ b/backend/migrations/008_netup_push_log.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS netup_push_log ( + id BIGSERIAL PRIMARY KEY, + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + action TEXT NOT NULL, -- 'check-in' | 'check-out' | 'message' + room_number TEXT NOT NULL DEFAULT '', + netup_room TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + request_body JSONB, + status TEXT NOT NULL, -- 'ok' | 'error' | 'skipped' + http_status INT, + response_body TEXT, + error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_netup_push_log_hotel_ts ON netup_push_log(hotel_id, ts DESC); diff --git a/backend/src/app.ts b/backend/src/app.ts index d86c1d0..c00a750 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -16,7 +16,6 @@ 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({ @@ -74,7 +73,6 @@ 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 3e7115e..ff1b921 100644 --- a/backend/src/routes/netup.ts +++ b/backend/src/routes/netup.ts @@ -1,30 +1,47 @@ 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 } } // ── Push event log (check-in / check-out calls to NetUP) ───────────────────── export type PushEvent = { - ts: string - action: 'check-in' | 'check-out' | 'message' - hotelId: string - roomNumber: string // PMS room number - netupRoom: string // NetUP room number - url: string - requestBody?: unknown // what we sent - status: 'ok' | 'error' | 'skipped' - httpStatus?: number - responseBody?: string // what NetUP replied - error?: string + ts: string + action: 'check-in' | 'check-out' | 'message' + hotelId: string + roomNumber: string + netupRoom: string + url: string + requestBody?: unknown + status: 'ok' | 'error' | 'skipped' + httpStatus?: number + responseBody?: string + error?: string } -const MAX_PUSH = 100 +// In-memory fallback (used before DB write completes; also keeps last 50) export const pushLog: PushEvent[] = [] function recordPush(e: PushEvent) { pushLog.push(e) - if (pushLog.length > MAX_PUSH) pushLog.shift() + if (pushLog.length > 50) pushLog.shift() + + // Persist to DB (fire-and-forget); also enforce 50-row limit per hotel + db.query( + `INSERT INTO netup_push_log + (hotel_id, ts, action, room_number, netup_room, url, request_body, status, http_status, response_body, error) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [e.hotelId, e.ts, e.action, e.roomNumber, e.netupRoom, e.url, + e.requestBody ? JSON.stringify(e.requestBody) : null, + e.status, e.httpStatus ?? null, e.responseBody ?? null, e.error ?? null], + ).then(() => + // Keep only last 50 rows per hotel + db.query( + `DELETE FROM netup_push_log WHERE hotel_id = $1 AND id NOT IN ( + SELECT id FROM netup_push_log WHERE hotel_id = $1 ORDER BY ts DESC LIMIT 50 + )`, + [e.hotelId], + ) + ).catch(() => { /* best-effort */ }) } const netup: FastifyPluginAsync = async (fastify) => { @@ -270,13 +287,25 @@ const netup: FastifyPluginAsync = async (fastify) => { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) - // Push events filtered to this hotel - const pushEvents = [...pushLog] - .filter(e => e.hotelId === hotelId) - .reverse() - // Pull requests (from NetUP TravelLine polls) — all, no hotel filter possible - const pullRequests = [...captured].reverse() - return { pushEvents, pullRequests } + const { rows } = await db.query( + `SELECT ts, action, room_number, netup_room, url, request_body, + status, http_status, response_body, error + FROM netup_push_log WHERE hotel_id = $1 ORDER BY ts DESC LIMIT 50`, + [hotelId], + ) + const pushEvents = rows.map((r: Record) => ({ + ts: (r.ts as Date).toISOString(), + action: r.action, + roomNumber: r.room_number, + netupRoom: r.netup_room, + url: r.url, + requestBody: r.request_body, + status: r.status, + httpStatus: r.http_status, + responseBody: r.response_body, + error: r.error, + })) + return { pushEvents, pullRequests: [] } }, ) @@ -289,7 +318,8 @@ const netup: FastifyPluginAsync = async (fastify) => { if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } - captured.length = 0 + const hotelId2 = await getHotelId(slug) + if (hotelId2) await db.query('DELETE FROM netup_push_log WHERE hotel_id = $1', [hotelId2]) pushLog.length = 0 return { ok: true } }, diff --git a/src/pages/TvWelcomePage.tsx b/src/pages/TvWelcomePage.tsx index d0e8be4..d0b3ada 100644 --- a/src/pages/TvWelcomePage.tsx +++ b/src/pages/TvWelcomePage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2, RefreshCw, Trash2, ArrowUpRight, ArrowDownLeft } from 'lucide-react' +import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, RefreshCw, Trash2 } from 'lucide-react' import { cn } from '../lib/utils' import { api } from '../lib/api' import { useAuth } from '../contexts/AuthContext' @@ -14,17 +14,6 @@ const LANGUAGES = [ { value: 'ar_AE', label: 'العربية' }, ] -const PMS_API_URL = 'https://api.hotelsync.ru/netup-pms' - -type PullEntry = { - ts: string - method: string - url: string - headers: Record - query: Record - body: unknown -} - type PushEvent = { ts: string action: string @@ -49,28 +38,23 @@ export function TvWelcomePage() { const [showPass, setShowPass] = useState(false) // Connection settings - const [serverUrl, setServerUrl] = useState('') - const [username, setUsername] = useState('admin') - const [password, setPassword] = useState('') - const [language, setLanguage] = useState('ru_RU') - const [enabled, setEnabled] = useState(true) - const [tlToken, setTlToken] = useState('') + const [serverUrl, setServerUrl] = useState('') + const [username, setUsername] = useState('admin') + const [password, setPassword] = useState('') + const [language, setLanguage] = useState('ru_RU') + const [enabled, setEnabled] = useState(true) const [settingsLoaded, setSettingsLoaded] = useState(false) - const [copied, setCopied] = useState(false) // Room mappings const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([]) const [roomsSaving, setRoomsSaving] = useState(false) - const [roomsSaved, setRoomsSaved] = useState(false) + const [roomsSaved, setRoomsSaved] = useState(false) - // Log - const [pushEvents, setPushEvents] = useState([]) - const [pullEntries, setPullEntries] = useState([]) - const [logLoading, setLogLoading] = useState(false) - const [selectedPull, setSelectedPull] = useState(null) - const [selectedPush, setSelectedPush] = useState(null) - // Test check-in - const [testingCheckin, setTestingCheckin] = useState(false) + // Diagnostics + const [pushEvents, setPushEvents] = useState([]) + const [logLoading, setLogLoading] = useState(false) + const [selectedPush, setSelectedPush] = useState(null) + const [testingCheckin, setTestingCheckin] = useState(false) const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null) // Load settings @@ -82,7 +66,6 @@ export function TvWelcomePage() { setUsername(s.username) setLanguage(s.defaultLanguage) setEnabled(s.enabled) - setTlToken(s.tlToken ?? '') setSettingsLoaded(true) }) .catch(() => setSettingsLoaded(true)) @@ -91,21 +74,16 @@ export function TvWelcomePage() { // Load room mappings when tab switches (also needed on 'log' for test button) useEffect(() => { if ((tab !== 'rooms' && tab !== 'log') || !slug) return - if (rooms.length > 0) return // already loaded - api.netup.getRoomMappings(slug) - .then(setRooms) - .catch(() => {}) + if (rooms.length > 0) return + api.netup.getRoomMappings(slug).then(setRooms).catch(() => {}) }, [tab, slug, rooms.length]) - // Load log when tab switches + // Load push log when diagnostics tab opens const loadLog = useCallback(() => { if (!slug) return setLogLoading(true) api.netup.getLog(slug) - .then(r => { - setPushEvents(r.pushEvents) - setPullEntries(r.pullRequests) - }) + .then(r => setPushEvents(r.pushEvents)) .catch(() => {}) .finally(() => setLogLoading(false)) }, [slug]) @@ -120,12 +98,10 @@ export function TvWelcomePage() { setTestResult(null) try { await api.netup.saveSettings(slug, { - server_url: serverUrl, - username, + server_url: serverUrl, username, password: password || undefined, - default_language: language, - enabled, - tl_token: tlToken, + default_language: language, enabled, + tl_token: '', }) setPassword('') } finally { @@ -138,18 +114,15 @@ export function TvWelcomePage() { setTestResult(null) try { await api.netup.saveSettings(slug, { - server_url: serverUrl, - username, + server_url: serverUrl, username, password: password || undefined, - default_language: language, - enabled, - tl_token: tlToken, + default_language: language, enabled, + tl_token: '', }) const res = await api.netup.testConnection(slug) setTestResult({ ok: true, msg: res.message }) } catch (err) { - const msg = err instanceof Error ? err.message : 'Ошибка подключения' - setTestResult({ ok: false, msg }) + setTestResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка подключения' }) } finally { setTesting(false) } @@ -160,8 +133,7 @@ export function TvWelcomePage() { setRoomsSaved(false) try { await api.netup.saveRoomMappings(slug, rooms.map(r => ({ - pms_room_id: r.id, - netup_room_number: r.netupRoomNumber, + pms_room_id: r.id, netup_room_number: r.netupRoomNumber, }))) setRoomsSaved(true) setTimeout(() => setRoomsSaved(false), 2500) @@ -173,13 +145,11 @@ export function TvWelcomePage() { const handleClearLog = async () => { await api.netup.clearLog(slug).catch(() => {}) setPushEvents([]) - setPullEntries([]) - setSelectedPull(null) + setSelectedPush(null) } const handleTestCheckin = async () => { if (!rooms.length) return - // pick first room that has a netup mapping const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0] setTestingCheckin(true) setTestCheckinResult(null) @@ -187,11 +157,9 @@ export function TvWelcomePage() { const res = await api.netup.testCheckin(slug, room.id) setTestCheckinResult({ ok: true, msg: res.message }) } catch (err) { - const msg = err instanceof Error ? err.message : 'Ошибка' - setTestCheckinResult({ ok: false, msg }) + setTestCheckinResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка' }) } finally { setTestingCheckin(false) - // Refresh log after test setTimeout(loadLog, 500) } } @@ -250,15 +218,9 @@ export function TvWelcomePage() { @@ -266,13 +228,9 @@ export function TvWelcomePage() { {/* Server URL */}
- + setServerUrl(e.target.value)} + type="text" value={serverUrl} onChange={e => setServerUrl(e.target.value)} placeholder="http://192.168.1.100:8880" className="input w-full font-mono text-sm" /> @@ -285,26 +243,18 @@ export function TvWelcomePage() {
- setUsername(e.target.value)} - className="input w-full" - /> + setUsername(e.target.value)} className="input w-full" />
setPassword(e.target.value)} - placeholder="Не изменять" - className="input w-full pr-9" + placeholder="Не изменять" className="input w-full pr-9" /> -
-
- - {/* Token */} -
- - setTlToken(e.target.value)} - placeholder="Придумайте любой секретный токен" - className="input w-full font-mono text-sm" - /> -

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

-
-
- {/* Test result */} {testResult && (
- {testResult.ok - ? - : } + {testResult.ok ? : } {testResult.msg}
)} {/* Actions */}
- - @@ -451,22 +327,14 @@ export function TvWelcomePage() { {rooms.map((room, i) => ( - + {room.number} {room.type} updateRoomNetup(room.id, e.target.value)} - placeholder="Не задан" - className="input py-1.5 w-36 text-sm" + placeholder="Не задан" className="input py-1.5 w-36 text-sm" /> @@ -476,11 +344,7 @@ export function TvWelcomePage() { )}
- @@ -493,179 +357,108 @@ export function TvWelcomePage() {
)} - {/* ── Tab: Diagnostics / Log ───────────────────────────────────────────── */} + {/* ── Tab: Diagnostics ─────────────────────────────────────────────────── */} {tab === 'log' && ( -
- - {/* ── Push events (наш сервер → NetUP) ── */} -
-
-
-

- - Исходящие вызовы в NetUP (push) -

-

- Заселения и выезды, которые мы отправляли в NetUP -

-
-
- - {(pushEvents.length > 0 || pullEntries.length > 0) && ( - - )} -
-
- - {/* Test check-in button */} -
- - {rooms.length === 0 && ( -

Сначала настройте сопоставление номеров

- )} - {testCheckinResult && ( - - {testCheckinResult.ok ? : } - {testCheckinResult.msg} - - )} -
- - {pushEvents.length === 0 ? ( -

- Исходящих вызовов ещё не было. Измените статус брони на «Заселён» или нажмите «Тест» выше. +

+
+
+

Вызовы в NetUP

+

+ Заселения и выезды, отправленные в NetUP. Нажмите на строку для деталей.

- ) : ( -
- {pushEvents.map((e, i) => ( -
- - {selectedPush === e && ( -
-
-

URL запроса

-

{e.url}

-
- {e.requestBody != null && ( -
-

Тело запроса (отправили в NetUP)

-
-                              {JSON.stringify(e.requestBody, null, 2)}
-                            
-
- )} - {e.responseBody && ( -
-

Ответ NetUP

-
{e.responseBody}
-
- )} -
- )} -
- ))} -
+
+
+ + {pushEvents.length > 0 && ( + + )} +
+
+ + {/* Test check-in */} +
+ + {rooms.length === 0 &&

Сначала настройте сопоставление номеров

} + {testCheckinResult && ( + + {testCheckinResult.ok ? : } + {testCheckinResult.msg} + )}
- {/* ── Pull requests (NetUP → наш сервер) ── */} -
-

- - Входящие запросы от NetUP (pull) -

-

- Запросы, которые NetUP отправлял на {PMS_API_URL} -

- - {pullEntries.length === 0 ? ( -

- Входящих запросов не было + {/* Events list */} + {pushEvents.length === 0 ? ( +

+ +

Вызовов ещё не было

+

+ Измените статус брони на «Заселён» или нажмите «Тест» выше

- ) : ( -
- {pullEntries.map((entry, i) => ( +
+ ) : ( +
+ {pushEvents.map((e, i) => ( +
- ))} -
- )} - {selectedPull && ( -
-

{selectedPull.method} {selectedPull.url}

- {Object.keys(selectedPull.query ?? {}).length > 0 && ( -
-

Query

-
{JSON.stringify(selectedPull.query, null, 2)}
-
- )} -
-

Заголовки

-
-                    {JSON.stringify(
-                      Object.fromEntries(Object.entries(selectedPull.headers).filter(([k]) => !['host','connection','accept-encoding'].includes(k))),
-                      null, 2
-                    )}
-                  
+ {selectedPush === e && ( +
+
+

URL запроса

+

{e.url}

+
+ {e.requestBody != null && ( +
+

Тело запроса

+
{JSON.stringify(e.requestBody, null, 2)}
+
+ )} + {e.responseBody && ( +
+

Ответ NetUP

+
{e.responseBody}
+
+ )} +
+ )}
- {selectedPull.body != null && ( -
-

Body

-
{JSON.stringify(selectedPull.body, null, 2)}
-
- )} -
- )} -
+ ))} +
+ )}
)}