Per Sciener docs: OAuth requires TTHotel account credentials (Учетная запись + Пароль) separate from developer app client_id/client_secret. - Migration 055: add ttlock_username, ttlock_password columns - Backend: store and pass new fields to agent - UI: add input fields matching TTHotel PMS integration page - Agent: getAccessToken() uses ttlockUsername/ttlockPassword when provided Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
479 lines
22 KiB
TypeScript
479 lines
22 KiB
TypeScript
/**
|
||
* TTLock / TTHotel integration
|
||
*
|
||
* Архитектура:
|
||
* PMS backend → AgentWS → Windows Agent → Card Encoder DLL → USB Encoder (COM-порт) → Mifare-карта → Замок
|
||
*
|
||
* Credentials: client_id + client_secret из TTHotel → Настройки → Интеграции
|
||
* Lock id: MAC-адрес замка из lock.ttlock.com (без двоеточий, напр. "42A6BBF5ECE5")
|
||
* COM-порт: из Диспетчера устройств → Порты
|
||
* Секторы: из TTHotel (напр. "1,2,3,4,5,6,7,8,9,10")
|
||
*
|
||
* Routes:
|
||
* GET /api/hotels/:slug/ttlock/config
|
||
* PATCH /api/hotels/:slug/ttlock/config
|
||
* POST /api/hotels/:slug/ttlock/test — проверка подключения агента и COM-порта
|
||
* GET /api/hotels/:slug/ttlock/room-locks — список привязок номер → замок
|
||
* POST /api/hotels/:slug/ttlock/room-locks — привязать номер к замку
|
||
* DELETE /api/hotels/:slug/ttlock/room-locks/:roomId
|
||
* POST /api/hotels/:slug/bookings/:bookingId/issue-card
|
||
* GET /api/hotels/:slug/bookings/:bookingId/cards
|
||
*/
|
||
import { FastifyPluginAsync } from 'fastify'
|
||
import { WebSocket } from 'ws'
|
||
import { db } from '../db'
|
||
import { sendCommandAndWait, getAgentSocket } from '../agent-ws'
|
||
|
||
type SlugParam = { Params: { slug: string } }
|
||
type RoomParam = { Params: { slug: string; roomId: string } }
|
||
type BookingParam = { Params: { slug: string; bookingId: string } }
|
||
|
||
async function getHotelId(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
|
||
|
||
const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
||
|
||
// ── GET /api/hotels/:slug/ttlock/config ─────────────────────────────────────
|
||
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/config', { 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 is_enabled, client_id, card_sectors, api_server, ttlock_username FROM hotel_ttlock_config WHERE hotel_id = $1`,
|
||
[hotelId],
|
||
)
|
||
if (!rows[0]) {
|
||
return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', apiServer: 'https://euapi.ttlock.com', ttlockUsername: '' }
|
||
}
|
||
const r = rows[0]
|
||
return {
|
||
isEnabled: r.is_enabled,
|
||
clientId: r.client_id,
|
||
cardSectors: r.card_sectors,
|
||
apiServer: r.api_server ?? 'https://euapi.ttlock.com',
|
||
ttlockUsername: r.ttlock_username ?? '',
|
||
}
|
||
})
|
||
|
||
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
|
||
fastify.patch<SlugParam & {
|
||
Body: {
|
||
is_enabled?: boolean
|
||
client_id?: string
|
||
client_secret?: string
|
||
card_sectors?: string
|
||
api_server?: string
|
||
ttlock_username?: string
|
||
ttlock_password?: string
|
||
}
|
||
}>('/api/hotels/:slug/ttlock/config', { 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 {
|
||
is_enabled: isEnabled, client_id: clientId, client_secret: clientSecret,
|
||
card_sectors: cardSectors, api_server: apiServer,
|
||
ttlock_username: ttlockUsername, ttlock_password: ttlockPassword,
|
||
} = req.body
|
||
|
||
await db.query(
|
||
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server, ttlock_username, ttlock_password)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||
ON CONFLICT (hotel_id) DO UPDATE SET
|
||
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
|
||
client_id = COALESCE($3, hotel_ttlock_config.client_id),
|
||
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
|
||
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
|
||
api_server = COALESCE($6, hotel_ttlock_config.api_server),
|
||
ttlock_username = COALESCE(NULLIF($7, ''), hotel_ttlock_config.ttlock_username),
|
||
ttlock_password = COALESCE(NULLIF($8, ''), hotel_ttlock_config.ttlock_password),
|
||
updated_at = NOW()`,
|
||
[
|
||
hotelId,
|
||
isEnabled ?? null,
|
||
clientId ?? null,
|
||
clientSecret ?? '',
|
||
cardSectors ?? null,
|
||
apiServer ?? null,
|
||
ttlockUsername ?? '',
|
||
ttlockPassword ?? '',
|
||
],
|
||
)
|
||
return { ok: true }
|
||
})
|
||
|
||
// ── GET /api/hotels/:slug/ttlock/dll-info?workstation_id=... ────────────────
|
||
// Читаем index.js из TTHotel чтобы узнать реальные имена функций DLL
|
||
fastify.get<SlugParam & { Querystring: { workstation_id: string } }>(
|
||
'/api/hotels/:slug/ttlock/dll-info',
|
||
{ 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 { workstation_id } = req.query
|
||
if (!workstation_id) return reply.code(400).send({ error: 'workstation_id required' })
|
||
|
||
try {
|
||
const result = await sendCommandAndWait(workstation_id, {
|
||
type: 'ttlock:read_index_js',
|
||
}, 8000) as { ok?: boolean; content?: string; path?: string; error?: string }
|
||
|
||
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка чтения файла' })
|
||
return { ok: true, content: result.content, path: result.path }
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||
return reply.code(502).send({ error: msg })
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/ttlock/find-api-config ───────────────────────────
|
||
// Ищет в исходниках TTHotel вызовы CE_ConfigServer и API-сервер
|
||
fastify.post<SlugParam & { Body: { workstation_id: string } }>(
|
||
'/api/hotels/:slug/ttlock/find-api-config',
|
||
{ 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 { workstation_id } = req.body
|
||
if (!workstation_id) return reply.code(400).send({ error: 'workstation_id required' })
|
||
|
||
try {
|
||
const result = await sendCommandAndWait(workstation_id, {
|
||
type: 'ttlock:find_api_config',
|
||
}, 20_000) as { ok?: boolean; appDir?: string; results?: { path: string; matches: string[] }[]; error?: string }
|
||
|
||
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ничего не найдено', appDir: result.appDir })
|
||
return { ok: true, appDir: result.appDir, results: result.results }
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||
return reply.code(502).send({ error: msg })
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/ttlock/test-api ──────────────────────────────────
|
||
// Проверяет подключение к TTLock Cloud API (OAuth + список замков)
|
||
fastify.post<SlugParam & { Body: { workstation_id: string } }>(
|
||
'/api/hotels/:slug/ttlock/test-api',
|
||
{ 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: cfgRows } = await db.query(
|
||
'SELECT client_id, client_secret, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1',
|
||
[hotelId],
|
||
)
|
||
const cfg = cfgRows[0]
|
||
if (!cfg?.client_id) return reply.code(400).send({ error: 'client_id не заполнен' })
|
||
|
||
const workstationId = req.body.workstation_id
|
||
if (!workstationId) return reply.code(400).send({ error: 'workstation_id required' })
|
||
|
||
try {
|
||
const result = await sendCommandAndWait(workstationId, {
|
||
type: 'ttlock:test_api',
|
||
clientId: cfg.client_id,
|
||
clientSecret: cfg.client_secret,
|
||
ttlockUsername: cfg.ttlock_username ?? '',
|
||
ttlockPassword: cfg.ttlock_password ?? '',
|
||
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
||
}, 15_000) as { ok?: boolean; lockCount?: number; error?: string }
|
||
|
||
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' })
|
||
return { ok: true, lockCount: result.lockCount }
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||
return reply.code(502).send({ error: msg })
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/ttlock/test ──────────────────────────────────────
|
||
// Проверяем: агент онлайн, энкодер отвечает на COM-порту
|
||
fastify.post<SlugParam & { Body: { workstation_id: string } }>(
|
||
'/api/hotels/:slug/ttlock/test',
|
||
{ 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 workstationId = req.body.workstation_id
|
||
if (!workstationId) return reply.code(400).send({ error: 'workstation_id required' })
|
||
|
||
const { rows: wsRows } = await db.query(
|
||
'SELECT ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2',
|
||
[workstationId, hotelId],
|
||
)
|
||
const ws = wsRows[0]
|
||
if (!ws) return reply.code(400).send({ error: 'Рабочее место не найдено' })
|
||
if (!ws.ttlock_com_port) return reply.code(400).send({ error: 'COM-порт не указан для этого рабочего места' })
|
||
|
||
try {
|
||
const result = await sendCommandAndWait(workstationId, {
|
||
type: 'ttlock:ping_encoder',
|
||
comPort: ws.ttlock_com_port,
|
||
}, 10_000) as { ok?: boolean; error?: string }
|
||
|
||
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Энкодер не ответил' })
|
||
return { ok: true }
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||
return reply.code(502).send({ error: msg })
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── GET /api/hotels/:slug/ttlock/room-locks ──────────────────────────────────
|
||
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/room-locks', { 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 m.room_id, m.lock_mac, m.lock_name,
|
||
r.name AS room_name, r.number AS room_number
|
||
FROM room_lock_mappings m
|
||
LEFT JOIN rooms r ON r.id = m.room_id
|
||
WHERE m.hotel_id = $1
|
||
ORDER BY r.number NULLS LAST, m.lock_name`,
|
||
[hotelId],
|
||
)
|
||
return rows.map(r => ({
|
||
roomId: r.room_id,
|
||
roomName: r.room_name,
|
||
roomNumber: r.room_number,
|
||
lockMac: r.lock_mac,
|
||
lockName: r.lock_name,
|
||
}))
|
||
})
|
||
|
||
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
|
||
fastify.post<SlugParam & { Body: { room_id?: string; lock_mac: string; lock_name?: string } }>(
|
||
'/api/hotels/:slug/ttlock/room-locks',
|
||
{ 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 { room_id: roomId, lock_mac: lockMac, lock_name: lockName } = req.body
|
||
const mac = (lockMac ?? '').replace(/:/g, '').toUpperCase()
|
||
if (!/^[0-9A-F]{12}$/.test(mac)) {
|
||
return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' })
|
||
}
|
||
|
||
await db.query(
|
||
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name)
|
||
VALUES ($1, $2, $3, $4)
|
||
ON CONFLICT (hotel_id, lock_mac) DO UPDATE SET room_id = $2, lock_name = $4`,
|
||
[hotelId, roomId ?? null, mac, lockName ?? null],
|
||
)
|
||
return { ok: true }
|
||
},
|
||
)
|
||
|
||
// ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ───────────────────────
|
||
fastify.delete<RoomParam>('/api/hotels/:slug/ttlock/room-locks/:roomId', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||
const { slug, roomId } = 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' })
|
||
|
||
// roomId может быть UUID (привязан к комнате) или MAC-адрес (без комнаты)
|
||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(roomId)
|
||
if (isUuid) {
|
||
await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, roomId])
|
||
} else {
|
||
const mac = roomId.replace(/:/g, '').toUpperCase()
|
||
await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND lock_mac = $2', [hotelId, mac])
|
||
}
|
||
return { ok: true }
|
||
})
|
||
|
||
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ────────────────────
|
||
fastify.post<BookingParam & { Body: { workstation_id?: string } }>(
|
||
'/api/hotels/:slug/bookings/:bookingId/issue-card',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (req, reply) => {
|
||
const { slug, bookingId } = 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: bookingRows } = await db.query(
|
||
`SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name, r.number AS room_number
|
||
FROM bookings b JOIN rooms r ON r.id = b.room_id
|
||
WHERE b.id = $1 AND b.hotel_id = $2`,
|
||
[bookingId, hotelId],
|
||
)
|
||
const booking = bookingRows[0]
|
||
if (!booking) return reply.code(404).send({ error: 'Бронирование не найдено' })
|
||
|
||
// MAC замка для этого номера
|
||
const { rows: lockRows } = await db.query(
|
||
'SELECT lock_mac, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
|
||
[hotelId, booking.room_id],
|
||
)
|
||
const lock = lockRows[0]
|
||
if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` })
|
||
|
||
// Конфигурация TTHotel (client_id, card_sectors)
|
||
const { rows: cfgRows } = await db.query(
|
||
'SELECT client_id, client_secret, card_sectors, is_enabled, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1',
|
||
[hotelId],
|
||
)
|
||
const cfg = cfgRows[0]
|
||
if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
|
||
if (!cfg.client_id) return reply.code(400).send({ error: 'client_id не заполнен' })
|
||
|
||
// Определяем рабочее место и COM-порт
|
||
let workstationId: string
|
||
let comPort: string
|
||
|
||
if (req.body?.workstation_id) {
|
||
// Явно указано рабочее место
|
||
const { rows: wsRows } = await db.query(
|
||
'SELECT id, ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2',
|
||
[req.body.workstation_id, hotelId],
|
||
)
|
||
const ws = wsRows[0]
|
||
if (!ws || !ws.ttlock_com_port) {
|
||
return reply.code(400).send({ error: 'Указанное рабочее место не найдено или не имеет COM-порта' })
|
||
}
|
||
workstationId = ws.id
|
||
comPort = ws.ttlock_com_port
|
||
} else {
|
||
// Авто-выбор: первое онлайн-рабочее место с настроенным энкодером
|
||
const { rows: wsRows } = await db.query(
|
||
`SELECT id, ttlock_com_port FROM workstations
|
||
WHERE hotel_id = $1 AND ttlock_com_port IS NOT NULL AND ttlock_com_port != ''`,
|
||
[hotelId],
|
||
)
|
||
const available = wsRows.filter(ws => {
|
||
const sock = getAgentSocket(ws.id)
|
||
return sock?.readyState === WebSocket.OPEN
|
||
})
|
||
if (available.length === 0) {
|
||
return reply.code(400).send({ error: 'Нет онлайн-рабочих мест с настроенным энкодером' })
|
||
}
|
||
workstationId = available[0].id
|
||
comPort = available[0].ttlock_com_port
|
||
}
|
||
|
||
const checkIn = new Date(booking.check_in).getTime()
|
||
const checkOut = new Date(booking.check_out).getTime()
|
||
|
||
// Отправляем команду агенту — он делает всё остальное через DLL
|
||
let result: unknown
|
||
try {
|
||
result = await sendCommandAndWait(workstationId, {
|
||
type: 'ttlock:write_card',
|
||
lockMac: lock.lock_mac,
|
||
comPort: comPort,
|
||
cardSectors: cfg.card_sectors,
|
||
clientId: cfg.client_id,
|
||
clientSecret: cfg.client_secret,
|
||
ttlockUsername: cfg.ttlock_username ?? '',
|
||
ttlockPassword: cfg.ttlock_password ?? '',
|
||
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
||
startDate: checkIn,
|
||
endDate: checkOut,
|
||
}, 30_000)
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||
return reply.code(502).send({ error: msg })
|
||
}
|
||
|
||
const r = result as { ok?: boolean; error?: string; cardNumber?: string }
|
||
if (!r.ok) return reply.code(502).send({ error: r.error ?? 'Ошибка записи карты' })
|
||
|
||
// Записываем в лог
|
||
const { rows: issuanceRows } = await db.query(
|
||
`INSERT INTO card_issuances
|
||
(hotel_id, booking_id, room_id, lock_mac, issued_by, check_in, check_out)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
RETURNING id, issued_at`,
|
||
[hotelId, bookingId, booking.room_id, lock.lock_mac, req.user.sub, booking.check_in, booking.check_out],
|
||
)
|
||
|
||
return {
|
||
ok: true,
|
||
issuanceId: issuanceRows[0].id,
|
||
issuedAt: issuanceRows[0].issued_at,
|
||
roomName: booking.room_name,
|
||
roomNumber: booking.room_number,
|
||
lockMac: lock.lock_mac,
|
||
lockName: lock.lock_name,
|
||
checkIn: booking.check_in,
|
||
checkOut: booking.check_out,
|
||
cardNumber: r.cardNumber,
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── GET /api/hotels/:slug/bookings/:bookingId/cards ──────────────────────────
|
||
fastify.get<BookingParam>('/api/hotels/:slug/bookings/:bookingId/cards', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||
const { slug, bookingId } = 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 c.id, c.lock_mac, c.issued_at, c.check_in, c.check_out,
|
||
c.is_revoked, c.revoked_at, u.name AS issued_by_name
|
||
FROM card_issuances c
|
||
LEFT JOIN users u ON u.id = c.issued_by
|
||
WHERE c.hotel_id = $1 AND c.booking_id = $2
|
||
ORDER BY c.issued_at DESC`,
|
||
[hotelId, bookingId],
|
||
)
|
||
return rows.map(r => ({
|
||
id: r.id,
|
||
lockMac: r.lock_mac,
|
||
issuedAt: r.issued_at,
|
||
issuedBy: r.issued_by_name,
|
||
checkIn: r.check_in,
|
||
checkOut: r.check_out,
|
||
isRevoked: r.is_revoked,
|
||
revokedAt: r.revoked_at,
|
||
}))
|
||
})
|
||
}
|
||
|
||
export default ttlockRoutes
|