/** * Рабочие места и сопряжение агентов * 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 => { 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('/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( '/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( '/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( '/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( '/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('/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( '/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( '/api/hotels/:slug/workstations/:id/devices/:deviceId', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { deviceId } = req.params const body = req.body as Record 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( '/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