264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
/**
|
||
* Рабочие места и сопряжение агентов
|
||
* Routes:
|
||
* GET /api/hotels/:slug/workstations
|
||
* POST /api/hotels/:slug/workstations
|
||
* PATCH /api/hotels/:slug/workstations/:id
|
||
* DELETE /api/hotels/:slug/workstations/:id
|
||
* POST /api/hotels/:slug/workstations/:id/pair-code
|
||
* GET /api/hotels/:slug/workstations/:id/devices
|
||
* POST /api/hotels/:slug/workstations/:id/devices
|
||
* PATCH /api/hotels/:slug/workstations/:id/devices/:deviceId
|
||
* DELETE /api/hotels/:slug/workstations/:id/devices/:deviceId
|
||
*
|
||
* POST /api/agent/pair (публичный — без auth)
|
||
*/
|
||
import { FastifyPluginAsync } from 'fastify'
|
||
import { db } from '../db'
|
||
import crypto from 'crypto'
|
||
|
||
type SlugParam = { Params: { slug: string } }
|
||
type WsParam = { Params: { slug: string; id: string } }
|
||
type DevParam = { Params: { slug: string; id: string; deviceId: string } }
|
||
|
||
const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
||
const getHotelId = async (slug: string): Promise<string | undefined> => {
|
||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||
return rows[0]?.id
|
||
}
|
||
|
||
const canManage = (userSlug: string | null, role: string, slug: string) =>
|
||
role === 'super_admin' || role === 'hotel_admin' || userSlug === slug
|
||
|
||
// ── GET /api/hotels/:slug/workstations ──────────────────────────────────────
|
||
fastify.get<SlugParam>('/api/hotels/:slug/workstations', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||
const { slug } = req.params
|
||
if (!canManage(req.user.hotelSlug, req.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 w.*,
|
||
json_agg(d ORDER BY d.type, d.name) FILTER (WHERE d.id IS NOT NULL) AS devices
|
||
FROM workstations w
|
||
LEFT JOIN workstation_devices d ON d.workstation_id = w.id
|
||
WHERE w.hotel_id = $1
|
||
GROUP BY w.id
|
||
ORDER BY w.created_at`,
|
||
[hotelId],
|
||
)
|
||
return rows
|
||
})
|
||
|
||
// ── POST /api/hotels/:slug/workstations ─────────────────────────────────────
|
||
fastify.post<SlugParam & { Body: { name: string } }>(
|
||
'/api/hotels/:slug/workstations',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
const { slug } = req.params
|
||
if (!canManage(req.user.hotelSlug, req.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 { name } = req.body
|
||
if (!name?.trim()) return reply.code(400).send({ error: 'name required' })
|
||
|
||
const { rows } = await db.query(
|
||
`INSERT INTO workstations (hotel_id, name) VALUES ($1, $2) RETURNING *`,
|
||
[hotelId, name.trim()],
|
||
)
|
||
return reply.code(201).send(rows[0])
|
||
},
|
||
)
|
||
|
||
// ── PATCH /api/hotels/:slug/workstations/:id ────────────────────────────────
|
||
fastify.patch<WsParam & { Body: { name?: string } }>(
|
||
'/api/hotels/:slug/workstations/:id',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
const { slug, id } = req.params
|
||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||
return reply.code(403).send({ error: 'Forbidden' })
|
||
|
||
const { name } = req.body
|
||
if (!name?.trim()) return reply.code(400).send({ error: 'name required' })
|
||
|
||
const { rows } = await db.query(
|
||
`UPDATE workstations SET name = $1 WHERE id = $2 RETURNING *`,
|
||
[name.trim(), id],
|
||
)
|
||
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
||
return rows[0]
|
||
},
|
||
)
|
||
|
||
// ── DELETE /api/hotels/:slug/workstations/:id ───────────────────────────────
|
||
fastify.delete<WsParam>(
|
||
'/api/hotels/:slug/workstations/:id',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
const { slug, id } = req.params
|
||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||
return reply.code(403).send({ error: 'Forbidden' })
|
||
|
||
await db.query('DELETE FROM workstations WHERE id = $1', [id])
|
||
return reply.code(204).send()
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/workstations/:id/pair-code ───────────────────────
|
||
fastify.post<WsParam>(
|
||
'/api/hotels/:slug/workstations/:id/pair-code',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
const { slug, id } = req.params
|
||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||
return reply.code(403).send({ error: 'Forbidden' })
|
||
|
||
// Удаляем старые неиспользованные коды для этого рабочего места
|
||
await db.query(
|
||
`DELETE FROM agent_pair_codes WHERE workstation_id = $1`,
|
||
[id],
|
||
)
|
||
|
||
// Генерируем 6-значный код
|
||
const code = String(crypto.randomInt(100000, 999999))
|
||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000) // 10 минут
|
||
|
||
await db.query(
|
||
`INSERT INTO agent_pair_codes (code, workstation_id, expires_at) VALUES ($1, $2, $3)`,
|
||
[code, id, expiresAt],
|
||
)
|
||
|
||
return { code, expires_at: expiresAt }
|
||
},
|
||
)
|
||
|
||
// ── POST /api/agent/pair (без auth — вызывается агентом) ────────────────────
|
||
fastify.post<{ Body: { code: string; agent_id: string; hostname?: string } }>(
|
||
'/api/agent/pair',
|
||
async (req, reply) => {
|
||
const { code, agent_id, hostname } = req.body
|
||
if (!code || !agent_id) return reply.code(400).send({ error: 'code and agent_id required' })
|
||
|
||
const { rows } = await db.query(
|
||
`SELECT pc.*, w.hotel_id, w.name AS workstation_name, h.slug
|
||
FROM agent_pair_codes pc
|
||
JOIN workstations w ON w.id = pc.workstation_id
|
||
JOIN hotels h ON h.id = w.hotel_id
|
||
WHERE pc.code = $1
|
||
AND pc.used = false
|
||
AND pc.expires_at > NOW()`,
|
||
[code],
|
||
)
|
||
|
||
if (!rows[0]) {
|
||
return reply.code(400).send({ error: 'Код недействителен или истёк' })
|
||
}
|
||
|
||
const row = rows[0]
|
||
|
||
// Помечаем код как использованный
|
||
await db.query('UPDATE agent_pair_codes SET used = true WHERE code = $1', [code])
|
||
|
||
// Привязываем agent_id к рабочему месту
|
||
await db.query(
|
||
`UPDATE workstations SET agent_id = $1, hostname = $2, last_seen = NOW(), is_online = false
|
||
WHERE id = $3`,
|
||
[agent_id, hostname ?? null, row.workstation_id],
|
||
)
|
||
|
||
// Выдаём токен агенту (подписываем как обычный JWT с role=agent)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const token = (fastify.jwt.sign as any)(
|
||
{
|
||
sub: agent_id,
|
||
role: 'agent',
|
||
workstation_id: row.workstation_id,
|
||
hotel_slug: row.slug,
|
||
},
|
||
{ expiresIn: '365d' },
|
||
)
|
||
|
||
return {
|
||
token,
|
||
workstation_id: row.workstation_id,
|
||
workstation_name: row.workstation_name,
|
||
hotel_slug: row.slug,
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── GET /api/hotels/:slug/workstations/:id/devices ──────────────────────────
|
||
fastify.get<WsParam>('/api/hotels/:slug/workstations/:id/devices', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||
const { slug, id } = req.params
|
||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||
return reply.code(403).send({ error: 'Forbidden' })
|
||
|
||
const { rows } = await db.query(
|
||
'SELECT * FROM workstation_devices WHERE workstation_id = $1 ORDER BY type, name',
|
||
[id],
|
||
)
|
||
return rows
|
||
})
|
||
|
||
// ── POST /api/hotels/:slug/workstations/:id/devices ─────────────────────────
|
||
fastify.post<WsParam & { Body: {
|
||
type: string; name: string; connection: string
|
||
network_host?: string; network_port?: number; purpose?: string; config?: object
|
||
} }>(
|
||
'/api/hotels/:slug/workstations/:id/devices',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
const { id } = req.params
|
||
const { type, name, connection, network_host, network_port, purpose, config } = req.body
|
||
const { rows } = await db.query(
|
||
`INSERT INTO workstation_devices (workstation_id, type, name, connection, network_host, network_port, purpose, config)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||
[id, type, name, connection, network_host ?? null, network_port ?? null, purpose ?? 'other', config ?? {}],
|
||
)
|
||
return reply.code(201).send(rows[0])
|
||
},
|
||
)
|
||
|
||
// ── PATCH /api/hotels/:slug/workstations/:id/devices/:deviceId ──────────────
|
||
fastify.patch<DevParam & { Body: object }>(
|
||
'/api/hotels/:slug/workstations/:id/devices/:deviceId',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
const { deviceId } = req.params
|
||
const body = req.body as Record<string, unknown>
|
||
const fields = ['name', 'connection', 'network_host', 'network_port', 'purpose', 'config']
|
||
const sets: string[] = []
|
||
const vals: unknown[] = []
|
||
fields.forEach(f => {
|
||
if (f in body) { sets.push(`${f} = $${vals.length + 1}`); vals.push(body[f]) }
|
||
})
|
||
if (!sets.length) return reply.code(400).send({ error: 'Nothing to update' })
|
||
vals.push(deviceId)
|
||
const { rows } = await db.query(
|
||
`UPDATE workstation_devices SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`,
|
||
vals,
|
||
)
|
||
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
||
return rows[0]
|
||
},
|
||
)
|
||
|
||
// ── DELETE /api/hotels/:slug/workstations/:id/devices/:deviceId ─────────────
|
||
fastify.delete<DevParam>(
|
||
'/api/hotels/:slug/workstations/:id/devices/:deviceId',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
await db.query('DELETE FROM workstation_devices WHERE id = $1', [req.params.deviceId])
|
||
return reply.code(204).send()
|
||
},
|
||
)
|
||
}
|
||
|
||
export default workstationRoutes
|