Files
hotelsync/backend/src/routes/netup.ts
HotelSync bf785b763f fix: hide NetUP server URL field in agent mode, derive from device config
- TvWelcomePage: hide server URL input when connection_type=agent
- Test button enabled in agent mode without serverUrl
- Backend test endpoint: derive server_url from workstation netup device
- getNetupCfg: resolve URL from netup device in agent mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:06:33 +03:00

594 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]
// В agent-режиме берём адрес из устройства netup на рабочем месте
let effectiveUrl = cfg?.server_url ?? ''
if (cfg?.connection_type === 'agent' && cfg?.agent_workstation_id) {
const { rows: [dev] } = await db.query(
`SELECT network_host, network_port FROM workstation_devices
WHERE workstation_id = $1 AND type = 'netup' ORDER BY id LIMIT 1`,
[cfg.agent_workstation_id],
)
if (dev?.network_host) {
effectiveUrl = `http://${dev.network_host}:${dev.network_port ?? 80}`
}
}
if (!effectiveUrl) return reply.code(400).send({ error: 'Настройки не заполнены' })
try {
const result = await netupReq({ ...cfg, server_url: effectiveUrl }, '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],
)
if (!cfg) return undefined
// В agent-режиме подставляем адрес из устройства netup на рабочем месте
if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) {
const { rows: [dev] } = await db.query(
`SELECT network_host, network_port FROM workstation_devices
WHERE workstation_id = $1 AND type = 'netup' ORDER BY id LIMIT 1`,
[cfg.agent_workstation_id],
).catch(() => ({ rows: [] }))
if (dev?.network_host) {
cfg.server_url = `http://${dev.network_host}:${dev.network_port ?? 80}`
}
}
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