Files
hotelsync/backend/src/routes/travelline.ts
HotelSync 142be49211 Add Diagnostics tab to TV Welcome: view NetUP poll requests in UI
- Rename pull endpoint /travelline → /netup-pms (cleaner branding)
- Add GET/DELETE /api/hotels/:slug/netup/log endpoints
- TvWelcomePage: new 'Диагностика' tab shows incoming NetUP requests
  with method, URL, timestamp list + detail panel (headers, query, body)
- Remove 'TravelLine' wording from UI, replace with 'Pull-интеграция'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 19:08:57 +03:00

116 lines
3.8 KiB
TypeScript

/**
* NetUP PMS pull-integration endpoint.
*
* 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/netup-pms
* Token: <tl_token from netup_settings>
*/
import type { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
// In-memory ring buffer — last 100 captured requests (shared with netup.ts via export)
const MAX_CAPTURE = 100
export const captured: {
ts: string
method: string
url: string
headers: Record<string, string | string[] | undefined>
query: Record<string, unknown>
body: unknown
}[] = []
function recordRequest(request: {
method: string
url: string
headers: Record<string, string | string[] | undefined>
query: unknown
body: unknown
}) {
captured.push({
ts: new Date().toISOString(),
method: request.method,
url: request.url,
headers: request.headers,
query: request.query as Record<string, unknown>,
body: request.body,
})
if (captured.length > MAX_CAPTURE) captured.shift()
}
const netupPms: FastifyPluginAsync = async (fastify) => {
// 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<typeof recordRequest>[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 FROM netup_settings hs WHERE hs.tl_token = $1`,
[token],
).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<string, string | string[] | undefined>; query: unknown }): string | null {
const auth = request.headers['authorization'] as string | undefined
if (auth?.startsWith('Bearer ')) return auth.slice(7)
if (auth?.startsWith('Token ')) return auth.slice(6)
const q = request.query as Record<string, string>
return q?.token ?? q?.api_key ?? null
}
function buildEmptyResponse() {
return { success: true, reservations: [], rooms: [] }
}
async function buildResponse(hotelId: string) {
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: Record<string, unknown>) => ({
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 netupPms