- Migration 044: add connection_type + agent_workstation_id to netup_settings - Backend: netupReqViaCfg routes requests via agent WebSocket when connection_type=agent - Agent: add netup_request command handler (proxies HTTP to local NetUP server) - TvWelcomePage: add connection mode toggle (direct / via agent) + workstation selector - Hint changes: URL placeholder shows local IP when agent mode selected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
566 lines
25 KiB
TypeScript
566 lines
25 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify'
|
||
import { db } from '../db'
|
||
import { sendCommandAndWait } from '../agent-ws'
|
||
|
||
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
|
||
netupRoom: string
|
||
url: string
|
||
requestBody?: unknown
|
||
status: 'ok' | 'error' | 'skipped'
|
||
httpStatus?: number
|
||
responseBody?: string
|
||
error?: string
|
||
}
|
||
// 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 > 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) => {
|
||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||
return rows[0]?.id ?? null
|
||
}
|
||
|
||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||
role === 'super_admin' || userSlug === slug
|
||
|
||
// ── HTTP запрос к NetUP MW напрямую ──────────────────────────────────────
|
||
async function netupReqDirect(
|
||
serverUrl: string,
|
||
username: string,
|
||
password: string,
|
||
method: string,
|
||
path: string,
|
||
body?: unknown,
|
||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||
const base = serverUrl.replace(/\/$/, '')
|
||
const url = `${base}/mw/api${path}`
|
||
const cred = Buffer.from(`${username}:${password}`).toString('base64')
|
||
|
||
const res = await fetch(url, {
|
||
method,
|
||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
signal: AbortSignal.timeout(8000),
|
||
})
|
||
|
||
let data: unknown = null
|
||
try { data = await res.json() } catch { /* empty response */ }
|
||
return { ok: res.ok, status: res.status, data }
|
||
}
|
||
|
||
// ── HTTP запрос через агент ───────────────────────────────────────────────
|
||
async function netupReqViaAgent(
|
||
workstationId: string,
|
||
serverUrl: string,
|
||
username: string,
|
||
password: string,
|
||
method: string,
|
||
path: string,
|
||
body?: unknown,
|
||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||
const base = serverUrl.replace(/\/$/, '')
|
||
const url = `${base}/mw/api${path}`
|
||
const cred = Buffer.from(`${username}:${password}`).toString('base64')
|
||
|
||
const result = await sendCommandAndWait(workstationId, {
|
||
type: 'netup_request',
|
||
method,
|
||
url,
|
||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||
body: body ?? null,
|
||
}, 10000) as { ok: boolean; status: number; data: unknown }
|
||
return result
|
||
}
|
||
|
||
// ── Роутинг: прямой или через агент ──────────────────────────────────────
|
||
async function netupReq(
|
||
cfg: { server_url: string; username: string; password: string; connection_type?: string; agent_workstation_id?: string | null },
|
||
method: string,
|
||
path: string,
|
||
body?: unknown,
|
||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||
return netupReqViaAgent(cfg.agent_workstation_id, cfg.server_url, cfg.username, cfg.password, method, path, body)
|
||
}
|
||
return netupReqDirect(cfg.server_url, cfg.username, cfg.password, method, path, body)
|
||
}
|
||
|
||
// ── GET /api/hotels/:slug/netup/settings ────────────────────────────────
|
||
fastify.get<SlugParam>(
|
||
'/api/hotels/:slug/netup/settings',
|
||
{ 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' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||
|
||
const { rows } = await db.query(
|
||
'SELECT server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1',
|
||
[hotelId],
|
||
)
|
||
return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '', connection_type: 'direct', agent_workstation_id: null }
|
||
},
|
||
)
|
||
|
||
// ── PATCH /api/hotels/:slug/netup/settings ───────────────────────────────
|
||
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string; connection_type?: string; agent_workstation_id?: string | null } }>(
|
||
'/api/hotels/:slug/netup/settings',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (request, reply) => {
|
||
if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' })
|
||
const { slug } = request.params
|
||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||
return reply.code(403).send({ error: 'Forbidden' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||
|
||
const { server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id } = request.body
|
||
|
||
await db.query(
|
||
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id, updated_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 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,
|
||
connection_type = EXCLUDED.connection_type,
|
||
agent_workstation_id = EXCLUDED.agent_workstation_id,
|
||
updated_at = NOW()`,
|
||
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? '',
|
||
connection_type ?? 'direct', agent_workstation_id ?? null],
|
||
)
|
||
return { ok: true }
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/netup/test ────────────────────────────────────
|
||
fastify.post<SlugParam>(
|
||
'/api/hotels/:slug/netup/test',
|
||
{ 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' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||
|
||
const { rows } = await db.query(
|
||
'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1',
|
||
[hotelId],
|
||
)
|
||
const cfg = rows[0]
|
||
if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' })
|
||
|
||
try {
|
||
const result = await netupReq(cfg, 'GET', '/hotel-room')
|
||
if (result.ok) return { ok: true, message: 'Подключение успешно' }
|
||
return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` })
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Ошибка соединения'
|
||
return reply.code(502).send({ error: msg })
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── GET /api/hotels/:slug/netup/rooms ────────────────────────────────────
|
||
fastify.get<SlugParam>(
|
||
'/api/hotels/:slug/netup/rooms',
|
||
{ 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' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||
|
||
const { rows } = await db.query(
|
||
`SELECT r.id, r.number, r.type, COALESCE(m.netup_room_number, '') as netup_room_number
|
||
FROM rooms r
|
||
LEFT JOIN netup_room_mapping m ON m.pms_room_id = r.id AND m.hotel_id = $1
|
||
WHERE r.hotel_id = $1
|
||
ORDER BY r.number`,
|
||
[hotelId],
|
||
)
|
||
return rows
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/netup/rooms ───────────────────────────────────
|
||
// Body: { mappings: [{ pms_room_id, netup_room_number }] }
|
||
fastify.post<SlugParam & { Body: { mappings: { pms_room_id: string; netup_room_number: string }[] } }>(
|
||
'/api/hotels/:slug/netup/rooms',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (request, reply) => {
|
||
if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' })
|
||
const { slug } = request.params
|
||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||
return reply.code(403).send({ error: 'Forbidden' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||
|
||
const { mappings } = request.body
|
||
if (!Array.isArray(mappings)) return reply.code(400).send({ error: 'mappings required' })
|
||
|
||
// Delete existing and insert fresh
|
||
await db.query('DELETE FROM netup_room_mapping WHERE hotel_id = $1', [hotelId])
|
||
|
||
for (const m of mappings) {
|
||
if (m.netup_room_number?.trim()) {
|
||
await db.query(
|
||
`INSERT INTO netup_room_mapping (hotel_id, pms_room_id, netup_room_number)
|
||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||
[hotelId, m.pms_room_id, m.netup_room_number.trim()],
|
||
)
|
||
}
|
||
}
|
||
return { ok: true }
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/netup/message ─────────────────────────────────
|
||
// Отправить сообщение на TV в номер (из карточки брони)
|
||
fastify.post<SlugParam & { Body: { room_id: string; message: string; guest_name?: string } }>(
|
||
'/api/hotels/:slug/netup/message',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (request, reply) => {
|
||
if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' })
|
||
const { slug } = request.params
|
||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||
return reply.code(403).send({ error: 'Forbidden' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||
|
||
const { room_id, message, guest_name } = request.body
|
||
if (!room_id || !message?.trim()) return reply.code(400).send({ error: 'room_id and message required' })
|
||
|
||
// Get NetUP settings
|
||
const { rows: [cfg] } = await db.query(
|
||
'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||
[hotelId],
|
||
)
|
||
if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' })
|
||
|
||
// Get room mapping
|
||
const { rows: [mapping] } = await db.query(
|
||
`SELECT m.netup_room_number, r.number as room_number
|
||
FROM netup_room_mapping m
|
||
JOIN rooms r ON r.id = m.pms_room_id
|
||
WHERE m.hotel_id = $1 AND m.pms_room_id = $2`,
|
||
[hotelId, room_id],
|
||
)
|
||
if (!mapping) return reply.code(400).send({ error: 'Сопоставление для этого номера не найдено' })
|
||
|
||
try {
|
||
const result = await netupReq(
|
||
cfg,
|
||
'POST', '/system-message',
|
||
{
|
||
name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`,
|
||
recipient_type: 'all',
|
||
recipient: 0,
|
||
message,
|
||
},
|
||
)
|
||
if (!result.ok) {
|
||
return reply.code(502).send({ error: `NetUP вернул ошибку: ${result.status}` })
|
||
}
|
||
return { ok: true }
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Ошибка соединения'
|
||
return reply.code(502).send({ error: msg })
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── GET /api/hotels/:slug/netup/log ─────────────────────────────────────
|
||
fastify.get<SlugParam>(
|
||
'/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' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
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<string, unknown>) => ({
|
||
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: [] }
|
||
},
|
||
)
|
||
|
||
// ── DELETE /api/hotels/:slug/netup/log — очистить оба лога ───────────────
|
||
fastify.delete<SlugParam>(
|
||
'/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' })
|
||
}
|
||
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 }
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/netup/test-checkin — ручной тест заселения ────
|
||
fastify.post<SlugParam & { Body: { room_id: string } }>(
|
||
'/api/hotels/:slug/netup/test-checkin',
|
||
{ 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' })
|
||
}
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||
|
||
const { room_id } = request.body
|
||
if (!room_id) return reply.code(400).send({ error: 'room_id required' })
|
||
|
||
// Get room info
|
||
const { rows: [room] } = await db.query(
|
||
'SELECT id, number FROM rooms WHERE id = $1 AND hotel_id = $2',
|
||
[room_id, hotelId],
|
||
)
|
||
if (!room) return reply.code(404).send({ error: 'Room not found' })
|
||
|
||
const result = await notifyNetupCheckinInternal(
|
||
hotelId, room_id, room.number, 'Тестовый гость', '99999999',
|
||
)
|
||
if (result.ok) return { ok: true, message: `Check-in отправлен в NetUP (номер ${result.netupRoom})` }
|
||
return reply.code(502).send({ error: result.error ?? 'Ошибка' })
|
||
},
|
||
)
|
||
}
|
||
|
||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||
|
||
async function netupReqViaCfg(
|
||
cfg: { server_url: string; username: string; password: string; connection_type: string; agent_workstation_id: string | null },
|
||
method: string,
|
||
path: string,
|
||
body?: unknown,
|
||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||
const base = cfg.server_url.replace(/\/$/, '')
|
||
const url = `${base}/mw/api${path}`
|
||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||
return sendCommandAndWait(cfg.agent_workstation_id, {
|
||
type: 'netup_request',
|
||
method,
|
||
url,
|
||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||
body: body ?? null,
|
||
}, 10000) as Promise<{ ok: boolean; status: number; data: unknown }>
|
||
}
|
||
const base = cfg.server_url.replace(/\/$/, '')
|
||
const url = `${base}/mw/api${path}`
|
||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||
const res = await fetch(url, {
|
||
method,
|
||
headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
signal: AbortSignal.timeout(8000),
|
||
})
|
||
let data: unknown = null
|
||
try { data = await res.json() } catch { /* empty */ }
|
||
return { ok: res.ok, status: res.status, data }
|
||
}
|
||
|
||
async function getNetupCfg(hotelId: string, needEnabled = true) {
|
||
const cond = needEnabled ? 'AND enabled = true' : ''
|
||
const { rows: [cfg] } = await db.query(
|
||
`SELECT server_url, username, password, default_language, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 ${cond}`,
|
||
[hotelId],
|
||
)
|
||
return cfg as { server_url: string; username: string; password: string; default_language: string; connection_type: string; agent_workstation_id: string | null } | undefined
|
||
}
|
||
|
||
async function getMapping(hotelId: string, roomId: string) {
|
||
const { rows: [m] } = await db.query(
|
||
`SELECT m.netup_room_number, r.number as room_number
|
||
FROM netup_room_mapping m
|
||
JOIN rooms r ON r.id = m.pms_room_id
|
||
WHERE m.hotel_id = $1 AND m.pms_room_id = $2`,
|
||
[hotelId, roomId],
|
||
)
|
||
return m as { netup_room_number: string; room_number: string } | undefined
|
||
}
|
||
|
||
async function notifyNetupCheckinInternal(
|
||
hotelId: string, roomId: string, roomNumber: string,
|
||
guestName: string, reservationId: string, language?: string,
|
||
): Promise<{ ok: boolean; netupRoom?: string; error?: string }> {
|
||
const cfg = await getNetupCfg(hotelId)
|
||
if (!cfg?.server_url) {
|
||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'NetUP не настроен или отключён' })
|
||
return { ok: false, error: 'NetUP не настроен или отключён' }
|
||
}
|
||
|
||
const mapping = await getMapping(hotelId, roomId)
|
||
if (!mapping) {
|
||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' })
|
||
return { ok: false, error: 'Сопоставление номера не найдено' }
|
||
}
|
||
|
||
const base = cfg.server_url.replace(/\/$/, '')
|
||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`
|
||
|
||
// NetUP expects reservation_id as a numeric string (parses with strconv.Atoi)
|
||
const resIdStr = String(parseInt(reservationId.replace(/-/g, '').slice(0, 8), 16) % 100000000)
|
||
const requestBody = { reservation_id: resIdStr, name: guestName, language: language ?? cfg.default_language }
|
||
|
||
try {
|
||
let ok: boolean, status: number
|
||
let responseText = ''
|
||
|
||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||
const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, requestBody)
|
||
ok = result.ok; status = result.status
|
||
responseText = result.data ? JSON.stringify(result.data) : ''
|
||
} else {
|
||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(requestBody),
|
||
signal: AbortSignal.timeout(6000),
|
||
})
|
||
ok = res.ok; status = res.status
|
||
responseText = await res.text().catch(() => '')
|
||
}
|
||
|
||
const event: PushEvent = {
|
||
ts: new Date().toISOString(), action: 'check-in', hotelId,
|
||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||
requestBody,
|
||
status: ok ? 'ok' : 'error', httpStatus: status,
|
||
responseBody: responseText || undefined,
|
||
}
|
||
if (!ok) event.error = `HTTP ${status}`
|
||
recordPush(event)
|
||
return ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${status}: ${responseText}` }
|
||
} catch (err) {
|
||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: 'error', error })
|
||
return { ok: false, error }
|
||
}
|
||
}
|
||
|
||
// ── Public functions called from bookings.ts ──────────────────────────────────
|
||
|
||
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
|
||
// Get room number for logging
|
||
const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] }))
|
||
await notifyNetupCheckinInternal(hotelId, roomId, r?.number ?? roomId, guestName, reservationId, language)
|
||
}
|
||
|
||
export async function notifyNetupCheckout(hotelId: string, roomId: string) {
|
||
const cfg = await getNetupCfg(hotelId)
|
||
if (!cfg?.server_url) return
|
||
|
||
const mapping = await getMapping(hotelId, roomId)
|
||
const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] }))
|
||
const roomNumber = r?.number ?? roomId
|
||
|
||
if (!mapping) {
|
||
recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' })
|
||
return
|
||
}
|
||
|
||
const base = cfg.server_url.replace(/\/$/, '')
|
||
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`
|
||
|
||
try {
|
||
let ok: boolean, status: number, responseText = ''
|
||
|
||
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
|
||
const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`)
|
||
ok = result.ok; status = result.status
|
||
responseText = result.data ? JSON.stringify(result.data) : ''
|
||
} else {
|
||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Basic ${cred}` },
|
||
signal: AbortSignal.timeout(6000),
|
||
})
|
||
ok = res.ok; status = res.status
|
||
responseText = await res.text().catch(() => '')
|
||
}
|
||
|
||
recordPush({
|
||
ts: new Date().toISOString(), action: 'check-out', hotelId,
|
||
roomNumber, netupRoom: mapping.netup_room_number, url,
|
||
status: ok ? 'ok' : 'error', httpStatus: status,
|
||
responseBody: responseText || undefined,
|
||
error: ok ? undefined : `HTTP ${status}`,
|
||
})
|
||
} catch (err) {
|
||
const error = err instanceof Error ? err.message : 'Ошибка соединения'
|
||
recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, status: 'error', error })
|
||
}
|
||
}
|
||
|
||
export default netup
|