Add NetUP IPTV integration module (TV Welcome settings page)
- DB: migrations/002_netup.sql — netup_settings + netup_room_mapping tables - Backend: routes/netup.ts — GET/PATCH settings, POST test, GET/POST room mappings, POST send TV message; notifyNetupCheckin/Checkout helpers - Backend: bookings PATCH — fire-and-forget NetUP check-in/out on status change - Frontend: TvWelcomePage — connection settings tab + room mapping tab - Frontend: sidebarItem added to tv-welcome module (shows in sidebar menu) - Frontend: ModulesPage Settings button navigates to /tv-welcome - Frontend: BookingModal — "Сообщение гостю на TV" panel for existing bookings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import bookingsRoutes from './routes/bookings'
|
||||
import housekeepingRoutes from './routes/housekeeping'
|
||||
import channelsRoutes from './routes/channels'
|
||||
import usersRoutes from './routes/users'
|
||||
import netupRoutes from './routes/netup'
|
||||
|
||||
export async function buildApp() {
|
||||
const fastify = Fastify({
|
||||
@@ -32,7 +33,7 @@ export async function buildApp() {
|
||||
await fastify.register(cors, {
|
||||
origin: config.cors.origins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
})
|
||||
|
||||
await fastify.register(rateLimit, {
|
||||
@@ -71,6 +72,7 @@ export async function buildApp() {
|
||||
await fastify.register(housekeepingRoutes)
|
||||
await fastify.register(channelsRoutes)
|
||||
await fastify.register(usersRoutes)
|
||||
await fastify.register(netupRoutes)
|
||||
|
||||
return fastify
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { notifyNetupCheckin, notifyNetupCheckout } from './netup'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
@@ -167,7 +168,16 @@ const bookings: FastifyPluginAsync = async (fastify) => {
|
||||
values,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
||||
return rows[0]
|
||||
|
||||
// NetUP IPTV: fire-and-forget при заселении/выселении
|
||||
const updated = rows[0]
|
||||
if (request.body.status === 'checked_in') {
|
||||
notifyNetupCheckin(hotelId, updated.room_id, updated.guest_name, updated.id).catch(() => {})
|
||||
} else if (request.body.status === 'checked_out') {
|
||||
notifyNetupCheckout(hotelId, updated.room_id).catch(() => {})
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
301
backend/src/routes/netup.ts
Normal file
301
backend/src/routes/netup.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
|
||||
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 netupReq(
|
||||
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/a${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 }
|
||||
}
|
||||
|
||||
// ── 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 FROM netup_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false }
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/netup/settings ───────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean } }>(
|
||||
'/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 } = 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())
|
||||
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,
|
||||
updated_at = NOW()`,
|
||||
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true],
|
||||
)
|
||||
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 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.server_url, cfg.username, cfg.password, '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 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.server_url, cfg.username, cfg.password,
|
||||
'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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── Внутренний хелпер: вызывается из bookings route при смене статуса ───
|
||||
// Экспортируем для использования в bookings.ts
|
||||
}
|
||||
|
||||
// ── Отдельная функция для check-in/check-out из bookings.ts ──────────────
|
||||
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (!mapping) return
|
||||
|
||||
try {
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/a/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reservation_id: reservationId,
|
||||
name: guestName,
|
||||
language: language ?? cfg.default_language,
|
||||
}),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget: не прерываем заселение если NetUP недоступен
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyNetupCheckout(hotelId: string, roomId: string) {
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (!mapping) return
|
||||
|
||||
try {
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/a/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}` },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget
|
||||
}
|
||||
}
|
||||
|
||||
export default netup
|
||||
Reference in New Issue
Block a user