Remove pull integration; persist push log to DB (last 50 per hotel)
- Remove TravelLine/netup-pms pull route (travelline.ts unused) - Remove pull integration section from TvWelcomePage (token field, API URL, etc.) - Migration 008: netup_push_log table (hotel_id, action, status, request/response body) - recordPush() now writes to DB in addition to in-memory buffer - getLog reads from DB so logs survive server restarts - clearLog deletes from DB - Limit enforced: 50 rows per hotel (oldest auto-deleted) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
backend/migrations/008_netup_push_log.sql
Normal file
16
backend/migrations/008_netup_push_log.sql
Normal file
@@ -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);
|
||||||
@@ -16,7 +16,6 @@ import housekeepingRoutes from './routes/housekeeping'
|
|||||||
import channelsRoutes from './routes/channels'
|
import channelsRoutes from './routes/channels'
|
||||||
import usersRoutes from './routes/users'
|
import usersRoutes from './routes/users'
|
||||||
import netupRoutes from './routes/netup'
|
import netupRoutes from './routes/netup'
|
||||||
import travellineRoutes from './routes/travelline'
|
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -74,7 +73,6 @@ export async function buildApp() {
|
|||||||
await fastify.register(channelsRoutes)
|
await fastify.register(channelsRoutes)
|
||||||
await fastify.register(usersRoutes)
|
await fastify.register(usersRoutes)
|
||||||
await fastify.register(netupRoutes)
|
await fastify.register(netupRoutes)
|
||||||
await fastify.register(travellineRoutes)
|
|
||||||
|
|
||||||
return fastify
|
return fastify
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { captured } from './travelline'
|
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||||
@@ -10,21 +9,39 @@ export type PushEvent = {
|
|||||||
ts: string
|
ts: string
|
||||||
action: 'check-in' | 'check-out' | 'message'
|
action: 'check-in' | 'check-out' | 'message'
|
||||||
hotelId: string
|
hotelId: string
|
||||||
roomNumber: string // PMS room number
|
roomNumber: string
|
||||||
netupRoom: string // NetUP room number
|
netupRoom: string
|
||||||
url: string
|
url: string
|
||||||
requestBody?: unknown // what we sent
|
requestBody?: unknown
|
||||||
status: 'ok' | 'error' | 'skipped'
|
status: 'ok' | 'error' | 'skipped'
|
||||||
httpStatus?: number
|
httpStatus?: number
|
||||||
responseBody?: string // what NetUP replied
|
responseBody?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
const MAX_PUSH = 100
|
// In-memory fallback (used before DB write completes; also keeps last 50)
|
||||||
export const pushLog: PushEvent[] = []
|
export const pushLog: PushEvent[] = []
|
||||||
|
|
||||||
function recordPush(e: PushEvent) {
|
function recordPush(e: PushEvent) {
|
||||||
pushLog.push(e)
|
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) => {
|
const netup: FastifyPluginAsync = async (fastify) => {
|
||||||
@@ -270,13 +287,25 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(403).send({ error: 'Forbidden' })
|
return reply.code(403).send({ error: 'Forbidden' })
|
||||||
}
|
}
|
||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
// Push events filtered to this hotel
|
const { rows } = await db.query(
|
||||||
const pushEvents = [...pushLog]
|
`SELECT ts, action, room_number, netup_room, url, request_body,
|
||||||
.filter(e => e.hotelId === hotelId)
|
status, http_status, response_body, error
|
||||||
.reverse()
|
FROM netup_push_log WHERE hotel_id = $1 ORDER BY ts DESC LIMIT 50`,
|
||||||
// Pull requests (from NetUP TravelLine polls) — all, no hotel filter possible
|
[hotelId],
|
||||||
const pullRequests = [...captured].reverse()
|
)
|
||||||
return { pushEvents, pullRequests }
|
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: [] }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -289,7 +318,8 @@ const netup: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||||
return reply.code(403).send({ error: 'Forbidden' })
|
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
|
pushLog.length = 0
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
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 { cn } from '../lib/utils'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
@@ -14,17 +14,6 @@ const LANGUAGES = [
|
|||||||
{ value: 'ar_AE', label: 'العربية' },
|
{ value: 'ar_AE', label: 'العربية' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PMS_API_URL = 'https://api.hotelsync.ru/netup-pms'
|
|
||||||
|
|
||||||
type PullEntry = {
|
|
||||||
ts: string
|
|
||||||
method: string
|
|
||||||
url: string
|
|
||||||
headers: Record<string, unknown>
|
|
||||||
query: Record<string, unknown>
|
|
||||||
body: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
type PushEvent = {
|
type PushEvent = {
|
||||||
ts: string
|
ts: string
|
||||||
action: string
|
action: string
|
||||||
@@ -54,22 +43,17 @@ export function TvWelcomePage() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [language, setLanguage] = useState('ru_RU')
|
const [language, setLanguage] = useState('ru_RU')
|
||||||
const [enabled, setEnabled] = useState(true)
|
const [enabled, setEnabled] = useState(true)
|
||||||
const [tlToken, setTlToken] = useState('')
|
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
// Room mappings
|
// Room mappings
|
||||||
const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([])
|
const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([])
|
||||||
const [roomsSaving, setRoomsSaving] = useState(false)
|
const [roomsSaving, setRoomsSaving] = useState(false)
|
||||||
const [roomsSaved, setRoomsSaved] = useState(false)
|
const [roomsSaved, setRoomsSaved] = useState(false)
|
||||||
|
|
||||||
// Log
|
// Diagnostics
|
||||||
const [pushEvents, setPushEvents] = useState<PushEvent[]>([])
|
const [pushEvents, setPushEvents] = useState<PushEvent[]>([])
|
||||||
const [pullEntries, setPullEntries] = useState<PullEntry[]>([])
|
|
||||||
const [logLoading, setLogLoading] = useState(false)
|
const [logLoading, setLogLoading] = useState(false)
|
||||||
const [selectedPull, setSelectedPull] = useState<PullEntry | null>(null)
|
|
||||||
const [selectedPush, setSelectedPush] = useState<PushEvent | null>(null)
|
const [selectedPush, setSelectedPush] = useState<PushEvent | null>(null)
|
||||||
// Test check-in
|
|
||||||
const [testingCheckin, setTestingCheckin] = useState(false)
|
const [testingCheckin, setTestingCheckin] = useState(false)
|
||||||
const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||||
|
|
||||||
@@ -82,7 +66,6 @@ export function TvWelcomePage() {
|
|||||||
setUsername(s.username)
|
setUsername(s.username)
|
||||||
setLanguage(s.defaultLanguage)
|
setLanguage(s.defaultLanguage)
|
||||||
setEnabled(s.enabled)
|
setEnabled(s.enabled)
|
||||||
setTlToken(s.tlToken ?? '')
|
|
||||||
setSettingsLoaded(true)
|
setSettingsLoaded(true)
|
||||||
})
|
})
|
||||||
.catch(() => 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)
|
// Load room mappings when tab switches (also needed on 'log' for test button)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((tab !== 'rooms' && tab !== 'log') || !slug) return
|
if ((tab !== 'rooms' && tab !== 'log') || !slug) return
|
||||||
if (rooms.length > 0) return // already loaded
|
if (rooms.length > 0) return
|
||||||
api.netup.getRoomMappings(slug)
|
api.netup.getRoomMappings(slug).then(setRooms).catch(() => {})
|
||||||
.then(setRooms)
|
|
||||||
.catch(() => {})
|
|
||||||
}, [tab, slug, rooms.length])
|
}, [tab, slug, rooms.length])
|
||||||
|
|
||||||
// Load log when tab switches
|
// Load push log when diagnostics tab opens
|
||||||
const loadLog = useCallback(() => {
|
const loadLog = useCallback(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
setLogLoading(true)
|
setLogLoading(true)
|
||||||
api.netup.getLog(slug)
|
api.netup.getLog(slug)
|
||||||
.then(r => {
|
.then(r => setPushEvents(r.pushEvents))
|
||||||
setPushEvents(r.pushEvents)
|
|
||||||
setPullEntries(r.pullRequests)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLogLoading(false))
|
.finally(() => setLogLoading(false))
|
||||||
}, [slug])
|
}, [slug])
|
||||||
@@ -120,12 +98,10 @@ export function TvWelcomePage() {
|
|||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
try {
|
try {
|
||||||
await api.netup.saveSettings(slug, {
|
await api.netup.saveSettings(slug, {
|
||||||
server_url: serverUrl,
|
server_url: serverUrl, username,
|
||||||
username,
|
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
default_language: language,
|
default_language: language, enabled,
|
||||||
enabled,
|
tl_token: '',
|
||||||
tl_token: tlToken,
|
|
||||||
})
|
})
|
||||||
setPassword('')
|
setPassword('')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -138,18 +114,15 @@ export function TvWelcomePage() {
|
|||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
try {
|
try {
|
||||||
await api.netup.saveSettings(slug, {
|
await api.netup.saveSettings(slug, {
|
||||||
server_url: serverUrl,
|
server_url: serverUrl, username,
|
||||||
username,
|
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
default_language: language,
|
default_language: language, enabled,
|
||||||
enabled,
|
tl_token: '',
|
||||||
tl_token: tlToken,
|
|
||||||
})
|
})
|
||||||
const res = await api.netup.testConnection(slug)
|
const res = await api.netup.testConnection(slug)
|
||||||
setTestResult({ ok: true, msg: res.message })
|
setTestResult({ ok: true, msg: res.message })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Ошибка подключения'
|
setTestResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка подключения' })
|
||||||
setTestResult({ ok: false, msg })
|
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
@@ -160,8 +133,7 @@ export function TvWelcomePage() {
|
|||||||
setRoomsSaved(false)
|
setRoomsSaved(false)
|
||||||
try {
|
try {
|
||||||
await api.netup.saveRoomMappings(slug, rooms.map(r => ({
|
await api.netup.saveRoomMappings(slug, rooms.map(r => ({
|
||||||
pms_room_id: r.id,
|
pms_room_id: r.id, netup_room_number: r.netupRoomNumber,
|
||||||
netup_room_number: r.netupRoomNumber,
|
|
||||||
})))
|
})))
|
||||||
setRoomsSaved(true)
|
setRoomsSaved(true)
|
||||||
setTimeout(() => setRoomsSaved(false), 2500)
|
setTimeout(() => setRoomsSaved(false), 2500)
|
||||||
@@ -173,13 +145,11 @@ export function TvWelcomePage() {
|
|||||||
const handleClearLog = async () => {
|
const handleClearLog = async () => {
|
||||||
await api.netup.clearLog(slug).catch(() => {})
|
await api.netup.clearLog(slug).catch(() => {})
|
||||||
setPushEvents([])
|
setPushEvents([])
|
||||||
setPullEntries([])
|
setSelectedPush(null)
|
||||||
setSelectedPull(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTestCheckin = async () => {
|
const handleTestCheckin = async () => {
|
||||||
if (!rooms.length) return
|
if (!rooms.length) return
|
||||||
// pick first room that has a netup mapping
|
|
||||||
const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0]
|
const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0]
|
||||||
setTestingCheckin(true)
|
setTestingCheckin(true)
|
||||||
setTestCheckinResult(null)
|
setTestCheckinResult(null)
|
||||||
@@ -187,11 +157,9 @@ export function TvWelcomePage() {
|
|||||||
const res = await api.netup.testCheckin(slug, room.id)
|
const res = await api.netup.testCheckin(slug, room.id)
|
||||||
setTestCheckinResult({ ok: true, msg: res.message })
|
setTestCheckinResult({ ok: true, msg: res.message })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Ошибка'
|
setTestCheckinResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка' })
|
||||||
setTestCheckinResult({ ok: false, msg })
|
|
||||||
} finally {
|
} finally {
|
||||||
setTestingCheckin(false)
|
setTestingCheckin(false)
|
||||||
// Refresh log after test
|
|
||||||
setTimeout(loadLog, 500)
|
setTimeout(loadLog, 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,15 +218,9 @@ export function TvWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEnabled(v => !v)}
|
onClick={() => setEnabled(v => !v)}
|
||||||
className={cn(
|
className={cn('relative w-12 h-6 rounded-full transition-colors', enabled ? 'bg-violet-500' : 'bg-slate-300 dark:bg-slate-600')}
|
||||||
'relative w-12 h-6 rounded-full transition-colors',
|
|
||||||
enabled ? 'bg-violet-500' : 'bg-slate-300 dark:bg-slate-600',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn('absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform', enabled ? 'translate-x-6' : 'translate-x-0.5')} />
|
||||||
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
|
|
||||||
enabled ? 'translate-x-6' : 'translate-x-0.5',
|
|
||||||
)} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,13 +228,9 @@ export function TvWelcomePage() {
|
|||||||
|
|
||||||
{/* Server URL */}
|
{/* Server URL */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Адрес сервера NetUP</label>
|
||||||
Адрес сервера NetUP
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text" value={serverUrl} onChange={e => setServerUrl(e.target.value)}
|
||||||
value={serverUrl}
|
|
||||||
onChange={e => setServerUrl(e.target.value)}
|
|
||||||
placeholder="http://192.168.1.100:8880"
|
placeholder="http://192.168.1.100:8880"
|
||||||
className="input w-full font-mono text-sm"
|
className="input w-full font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -285,26 +243,18 @@ export function TvWelcomePage() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Логин</label>
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Логин</label>
|
||||||
<input
|
<input type="text" value={username} onChange={e => setUsername(e.target.value)} className="input w-full" />
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={e => setUsername(e.target.value)}
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Пароль</label>
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Пароль</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPass ? 'text' : 'password'}
|
type={showPass ? 'text' : 'password'} value={password}
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
placeholder="Не изменять"
|
placeholder="Не изменять" className="input w-full pr-9"
|
||||||
className="input w-full pr-9"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button" onClick={() => setShowPass(v => !v)}
|
||||||
onClick={() => setShowPass(v => !v)}
|
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
>
|
>
|
||||||
{showPass ? <EyeOff size={15} /> : <Eye size={15} />}
|
{showPass ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
@@ -316,79 +266,15 @@ export function TvWelcomePage() {
|
|||||||
|
|
||||||
{/* Default language */}
|
{/* Default language */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Язык по умолчанию</label>
|
||||||
Язык по умолчанию
|
|
||||||
</label>
|
|
||||||
<select value={language} onChange={e => setLanguage(e.target.value)} className="input w-full">
|
<select value={language} onChange={e => setLanguage(e.target.value)} className="input w-full">
|
||||||
{LANGUAGES.map(l => (
|
{LANGUAGES.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||||
<option key={l.value} value={l.value}>{l.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
Язык меню на TV, если для гостя не указан язык
|
Язык меню на TV, если для гостя не указан язык
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="border-slate-100 dark:border-slate-700" />
|
|
||||||
|
|
||||||
{/* Pull integration */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">
|
|
||||||
Pull-интеграция (NetUP опрашивает наш сервер)
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
|
||||||
Настройте в NetUP тип интеграции «TravelLine» — укажите адрес ниже и токен.
|
|
||||||
NetUP будет сам забирать данные о бронях каждые 30–60 секунд.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API URL (read-only) */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
|
|
||||||
<Link2 size={13} />
|
|
||||||
API URL (вставить в NetUP)
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readOnly
|
|
||||||
value={PMS_API_URL}
|
|
||||||
className="input w-full font-mono text-xs bg-slate-50 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(PMS_API_URL)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}}
|
|
||||||
className="btn-secondary px-3 shrink-0 flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
{copied ? <CheckCircle2 size={13} className="text-emerald-500" /> : <Copy size={13} />}
|
|
||||||
{copied ? 'Скопировано' : 'Копировать'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Token */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
||||||
Токен авторизации
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tlToken}
|
|
||||||
onChange={e => setTlToken(e.target.value)}
|
|
||||||
placeholder="Придумайте любой секретный токен"
|
|
||||||
className="input w-full font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
|
||||||
Введите этот же токен в поле «Токен» настроек TravelLine в NetUP
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test result */}
|
{/* Test result */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -397,28 +283,18 @@ export function TvWelcomePage() {
|
|||||||
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800'
|
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800'
|
||||||
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800',
|
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800',
|
||||||
)}>
|
)}>
|
||||||
{testResult.ok
|
{testResult.ok ? <CheckCircle2 size={15} className="shrink-0" /> : <XCircle size={15} className="shrink-0" />}
|
||||||
? <CheckCircle2 size={15} className="shrink-0" />
|
|
||||||
: <XCircle size={15} className="shrink-0" />}
|
|
||||||
{testResult.msg}
|
{testResult.msg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-3 pt-1">
|
<div className="flex gap-3 pt-1">
|
||||||
<button
|
<button onClick={handleTest} disabled={testing || !serverUrl} className="btn-secondary flex items-center gap-2">
|
||||||
onClick={handleTest}
|
|
||||||
disabled={testing || !serverUrl}
|
|
||||||
className="btn-secondary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />}
|
{testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />}
|
||||||
Проверить подключение
|
Проверить подключение
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleSaveConnection} disabled={saving || !settingsLoaded} className="btn-primary flex items-center gap-2">
|
||||||
onClick={handleSaveConnection}
|
|
||||||
disabled={saving || !settingsLoaded}
|
|
||||||
className="btn-primary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
@@ -451,22 +327,14 @@ export function TvWelcomePage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rooms.map((room, i) => (
|
{rooms.map((room, i) => (
|
||||||
<tr
|
<tr key={room.id} className={cn('border-b border-slate-50 dark:border-slate-700/50 last:border-0', i % 2 !== 0 && 'bg-slate-50/50 dark:bg-slate-700/20')}>
|
||||||
key={room.id}
|
|
||||||
className={cn(
|
|
||||||
'border-b border-slate-50 dark:border-slate-700/50 last:border-0',
|
|
||||||
i % 2 === 0 ? '' : 'bg-slate-50/50 dark:bg-slate-700/20',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="px-5 py-3 font-medium text-slate-900 dark:text-slate-100">{room.number}</td>
|
<td className="px-5 py-3 font-medium text-slate-900 dark:text-slate-100">{room.number}</td>
|
||||||
<td className="px-5 py-3 text-slate-500 dark:text-slate-400">{room.type}</td>
|
<td className="px-5 py-3 text-slate-500 dark:text-slate-400">{room.type}</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text" value={room.netupRoomNumber}
|
||||||
value={room.netupRoomNumber}
|
|
||||||
onChange={e => updateRoomNetup(room.id, e.target.value)}
|
onChange={e => updateRoomNetup(room.id, e.target.value)}
|
||||||
placeholder="Не задан"
|
placeholder="Не задан" className="input py-1.5 w-36 text-sm"
|
||||||
className="input py-1.5 w-36 text-sm"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -476,11 +344,7 @@ export function TvWelcomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-slate-100 dark:border-slate-700 flex items-center gap-3">
|
<div className="px-5 py-4 border-t border-slate-100 dark:border-slate-700 flex items-center gap-3">
|
||||||
<button
|
<button onClick={handleSaveRooms} disabled={roomsSaving || rooms.length === 0} className="btn-primary flex items-center gap-2">
|
||||||
onClick={handleSaveRooms}
|
|
||||||
disabled={roomsSaving || rooms.length === 0}
|
|
||||||
className="btn-primary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{roomsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
{roomsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
Сохранить сопоставление
|
Сохранить сопоставление
|
||||||
</button>
|
</button>
|
||||||
@@ -493,20 +357,14 @@ export function TvWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Tab: Diagnostics / Log ───────────────────────────────────────────── */}
|
{/* ── Tab: Diagnostics ─────────────────────────────────────────────────── */}
|
||||||
{tab === 'log' && (
|
{tab === 'log' && (
|
||||||
<div className="space-y-4">
|
|
||||||
|
|
||||||
{/* ── Push events (наш сервер → NetUP) ── */}
|
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5">
|
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
<p className="font-medium text-slate-900 dark:text-slate-100">Вызовы в NetUP</p>
|
||||||
<ArrowUpRight size={15} className="text-violet-500" />
|
|
||||||
Исходящие вызовы в NetUP (push)
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
Заселения и выезды, которые мы отправляли в NetUP
|
Заселения и выезды, отправленные в NetUP. Нажмите на строку для деталей.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -514,7 +372,7 @@ export function TvWelcomePage() {
|
|||||||
{logLoading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
{logLoading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||||
Обновить
|
Обновить
|
||||||
</button>
|
</button>
|
||||||
{(pushEvents.length > 0 || pullEntries.length > 0) && (
|
{pushEvents.length > 0 && (
|
||||||
<button onClick={handleClearLog} className="btn-secondary flex items-center gap-1.5 text-sm text-red-500 dark:text-red-400">
|
<button onClick={handleClearLog} className="btn-secondary flex items-center gap-1.5 text-sm text-red-500 dark:text-red-400">
|
||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
Очистить
|
Очистить
|
||||||
@@ -523,7 +381,7 @@ export function TvWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test check-in button */}
|
{/* Test check-in */}
|
||||||
<div className="mb-4 pb-4 border-b border-slate-100 dark:border-slate-700 flex items-center gap-3 flex-wrap">
|
<div className="mb-4 pb-4 border-b border-slate-100 dark:border-slate-700 flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={handleTestCheckin}
|
onClick={handleTestCheckin}
|
||||||
@@ -533,9 +391,7 @@ export function TvWelcomePage() {
|
|||||||
{testingCheckin ? <Loader2 size={13} className="animate-spin" /> : <Plug size={13} />}
|
{testingCheckin ? <Loader2 size={13} className="animate-spin" /> : <Plug size={13} />}
|
||||||
Тест: отправить заселение в NetUP
|
Тест: отправить заселение в NetUP
|
||||||
</button>
|
</button>
|
||||||
{rooms.length === 0 && (
|
{rooms.length === 0 && <p className="text-xs text-slate-400">Сначала настройте сопоставление номеров</p>}
|
||||||
<p className="text-xs text-slate-400">Сначала настройте сопоставление номеров</p>
|
|
||||||
)}
|
|
||||||
{testCheckinResult && (
|
{testCheckinResult && (
|
||||||
<span className={cn('flex items-center gap-1.5 text-sm', testCheckinResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
|
<span className={cn('flex items-center gap-1.5 text-sm', testCheckinResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
|
||||||
{testCheckinResult.ok ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
|
{testCheckinResult.ok ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
|
||||||
@@ -544,10 +400,15 @@ export function TvWelcomePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Events list */}
|
||||||
{pushEvents.length === 0 ? (
|
{pushEvents.length === 0 ? (
|
||||||
<p className="text-sm text-slate-400 dark:text-slate-500 py-4 text-center">
|
<div className="py-8 text-center">
|
||||||
Исходящих вызовов ещё не было. Измените статус брони на «Заселён» или нажмите «Тест» выше.
|
<Activity size={32} className="mx-auto mb-3 text-slate-300 dark:text-slate-600" />
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Вызовов ещё не было</p>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||||
|
Измените статус брони на «Заселён» или нажмите «Тест» выше
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
{pushEvents.map((e, i) => (
|
{pushEvents.map((e, i) => (
|
||||||
@@ -559,9 +420,8 @@ export function TvWelcomePage() {
|
|||||||
selectedPush === e ? 'bg-slate-50 dark:bg-slate-700/40' : 'hover:bg-slate-50/60 dark:hover:bg-slate-700/20',
|
selectedPush === e ? 'bg-slate-50 dark:bg-slate-700/40' : 'hover:bg-slate-50/60 dark:hover:bg-slate-700/20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn('w-2 h-2 rounded-full shrink-0',
|
||||||
'w-2 h-2 rounded-full shrink-0',
|
e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500'
|
||||||
e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500',
|
|
||||||
)} />
|
)} />
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0">
|
<span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0">
|
||||||
{e.action === 'check-in' ? 'Заселение' : 'Выезд'}
|
{e.action === 'check-in' ? 'Заселение' : 'Выезд'}
|
||||||
@@ -572,10 +432,9 @@ export function TvWelcomePage() {
|
|||||||
</span>
|
</span>
|
||||||
{e.error && <span className="text-red-500 dark:text-red-400 text-xs flex-1 truncate text-left">{e.error}</span>}
|
{e.error && <span className="text-red-500 dark:text-red-400 text-xs flex-1 truncate text-left">{e.error}</span>}
|
||||||
{e.httpStatus && e.status === 'ok' && <span className="text-xs text-emerald-600 dark:text-emerald-400">HTTP {e.httpStatus}</span>}
|
{e.httpStatus && e.status === 'ok' && <span className="text-xs text-emerald-600 dark:text-emerald-400">HTTP {e.httpStatus}</span>}
|
||||||
<span className="ml-auto text-xs text-slate-400 shrink-0">
|
<span className="ml-auto text-xs text-slate-400 shrink-0">{new Date(e.ts).toLocaleTimeString('ru-RU')}</span>
|
||||||
{new Date(e.ts).toLocaleTimeString('ru-RU')}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{selectedPush === e && (
|
{selectedPush === e && (
|
||||||
<div className="mx-1 mb-2 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono">
|
<div className="mx-1 mb-2 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono">
|
||||||
<div>
|
<div>
|
||||||
@@ -584,10 +443,8 @@ export function TvWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
{e.requestBody != null && (
|
{e.requestBody != null && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 font-sans font-semibold mb-1">Тело запроса (отправили в NetUP)</p>
|
<p className="text-slate-400 font-sans font-semibold mb-1">Тело запроса</p>
|
||||||
<pre className="text-emerald-300 whitespace-pre-wrap break-all">
|
<pre className="text-emerald-300 whitespace-pre-wrap break-all">{JSON.stringify(e.requestBody, null, 2)}</pre>
|
||||||
{JSON.stringify(e.requestBody, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{e.responseBody && (
|
{e.responseBody && (
|
||||||
@@ -603,70 +460,6 @@ export function TvWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Pull requests (NetUP → наш сервер) ── */}
|
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5">
|
|
||||||
<p className="font-medium text-slate-900 dark:text-slate-100 flex items-center gap-2 mb-1">
|
|
||||||
<ArrowDownLeft size={15} className="text-blue-500" />
|
|
||||||
Входящие запросы от NetUP (pull)
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-3">
|
|
||||||
Запросы, которые NetUP отправлял на <span className="font-mono">{PMS_API_URL}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{pullEntries.length === 0 ? (
|
|
||||||
<p className="text-sm text-slate-400 dark:text-slate-500 py-4 text-center">
|
|
||||||
Входящих запросов не было
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
|
||||||
{pullEntries.map((entry, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setSelectedPull(selectedPull === entry ? null : entry)}
|
|
||||||
className={cn(
|
|
||||||
'w-full text-left px-2 py-2.5 rounded-lg transition-colors text-sm',
|
|
||||||
selectedPull === entry ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/40',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="font-mono text-xs font-bold text-blue-600 dark:text-blue-400 w-10 shrink-0">{entry.method}</span>
|
|
||||||
<span className="font-mono text-xs text-slate-600 dark:text-slate-300 truncate flex-1">{entry.url}</span>
|
|
||||||
<span className="text-xs text-slate-400 shrink-0">{new Date(entry.ts).toLocaleTimeString('ru-RU')}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedPull && (
|
|
||||||
<div className="mt-3 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono">
|
|
||||||
<p className="text-emerald-400">{selectedPull.method} {selectedPull.url}</p>
|
|
||||||
{Object.keys(selectedPull.query ?? {}).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Query</p>
|
|
||||||
<pre className="text-slate-200 whitespace-pre-wrap break-all">{JSON.stringify(selectedPull.query, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Заголовки</p>
|
|
||||||
<pre className="text-slate-300 whitespace-pre-wrap break-all">
|
|
||||||
{JSON.stringify(
|
|
||||||
Object.fromEntries(Object.entries(selectedPull.headers).filter(([k]) => !['host','connection','accept-encoding'].includes(k))),
|
|
||||||
null, 2
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
{selectedPull.body != null && (
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-400 font-sans text-xs font-semibold mb-1">Body</p>
|
|
||||||
<pre className="text-slate-200 whitespace-pre-wrap break-all">{JSON.stringify(selectedPull.body, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user