import { FastifyPluginAsync } from 'fastify' import { db } from '../db' import { encryptField, decryptField, passportToken } from '../lib/crypto' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } const GUEST_FIELDS = ` g.id, g.hotel_id, g.first_name, g.last_name, g.middle_name, g.email, g.phone, g.passport, g.passport_series, g.passport_number, g.birth_date, g.nationality, g.gender, g.city, g.notes, g.tags, g.loyalty_tier, g.loyalty_points, g.rating, g.created_at, g.updated_at` const STATS_JOIN = ` LEFT JOIN ( SELECT guest_id, COUNT(*) FILTER (WHERE status NOT IN ('cancelled', 'no_show')) AS total_stays, COALESCE(SUM(paid_amount) FILTER (WHERE status = 'checked_out'), 0) AS total_spent, MAX(check_out) FILTER (WHERE status = 'checked_out') AS last_visit FROM bookings WHERE hotel_id = $1 GROUP BY guest_id ) stats ON stats.guest_id = g.id` // Расшифровывает чувствительные поля строки гостя перед отправкой клиенту function decryptGuest(row: Record) { return { ...row, passport: decryptField(row.passport as string | null), passport_series: decryptField(row.passport_series as string | null), passport_number: decryptField(row.passport_number as string | null), birth_date: decryptField(row.birth_date as string | null), } } const guests: 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 ?? null } const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug // ── GET /api/hotels/:slug/guests ──────────────────────────────────────────── // ?q=<имя/email/телефон> или ?passport=<серия+номер> fastify.get( '/api/hotels/:slug/guests', { 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 { q, passport: passportQ } = request.query as { q?: string; passport?: string } const params: unknown[] = [hotelId] let where = 'WHERE g.hotel_id = $1' if (passportQ) { // Точный поиск по паспорту через HMAC-токен (зашифрованные поля не ищутся через LIKE) params.push(passportToken(passportQ)) where += ` AND g.passport_search_token = $2` } else if (q) { params.push(`%${q.toLowerCase()}%`) where += ` AND ( LOWER(g.first_name) LIKE $2 OR LOWER(g.last_name) LIKE $2 OR LOWER(COALESCE(g.email,'')) LIKE $2 OR COALESCE(g.phone,'') LIKE $2 )` } const { rows } = await db.query( `SELECT ${GUEST_FIELDS}, COALESCE(stats.total_stays, 0) AS total_stays, COALESCE(stats.total_spent, 0) AS total_spent, stats.last_visit FROM guests g ${STATS_JOIN} ${where} ORDER BY g.last_name, g.first_name`, params, ) return rows.map(decryptGuest) }, ) // ── GET /api/hotels/:slug/guests/:id ──────────────────────────────────────── fastify.get( '/api/hotels/:slug/guests/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, id } = 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: [guest] } = await db.query( `SELECT ${GUEST_FIELDS}, COALESCE(stats.total_stays, 0) AS total_stays, COALESCE(stats.total_spent, 0) AS total_spent, stats.last_visit FROM guests g ${STATS_JOIN} WHERE g.id = $2 AND g.hotel_id = $1`, [hotelId, id], ) if (!guest) return reply.code(404).send({ error: 'Guest not found' }) const { rows: history } = await db.query( `SELECT b.id, b.check_in, b.check_out, b.status, b.total_amount, b.paid_amount, r.number AS room_number, r.type AS room_type FROM bookings b LEFT JOIN rooms r ON r.id = b.room_id WHERE b.guest_id = $1 AND b.hotel_id = $2 ORDER BY b.check_in DESC`, [id, hotelId], ) return { ...decryptGuest(guest), history } }, ) // ── POST /api/hotels/:slug/guests ──────────────────────────────────────────── fastify.post( '/api/hotels/:slug/guests', { 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 b = request.body // Если передан паспорт — ищем существующего гостя по HMAC-токену if (b.passport_series && b.passport_number) { const token = passportToken(b.passport_series.trim() + b.passport_number.trim()) const { rows: existing } = await db.query( `SELECT id FROM guests WHERE hotel_id = $1 AND passport_search_token = $2`, [hotelId, token], ) if (existing.length > 0) { const { rows: [updated] } = await db.query( `UPDATE guests SET first_name = COALESCE($3, first_name), last_name = COALESCE($4, last_name), middle_name = COALESCE($5, middle_name), email = COALESCE($6, email), phone = COALESCE($7, phone), updated_at = NOW() WHERE id = $1 AND hotel_id = $2 RETURNING *`, [existing[0].id, hotelId, b.first_name || null, b.last_name || null, b.middle_name || null, b.email || null, b.phone || null], ) return reply.code(200).send(decryptGuest(updated)) } } // Вычисляем токен и шифруем поля перед вставкой const token = (b.passport_series && b.passport_number) ? passportToken(b.passport_series.trim() + b.passport_number.trim()) : null const { rows: [guest] } = await db.query( `INSERT INTO guests (hotel_id, first_name, last_name, middle_name, email, phone, passport, passport_series, passport_number, passport_search_token, birth_date, nationality, gender, city, notes, tags, rating) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING *`, [ hotelId, b.first_name, b.last_name, b.middle_name ?? null, b.email ?? null, b.phone ?? null, encryptField(b.passport ?? null), encryptField(b.passport_series ?? null), encryptField(b.passport_number ?? null), token, encryptField(b.birth_date ?? null), b.nationality ?? null, b.gender ?? null, b.city ?? null, b.notes ?? '', b.tags ?? [], b.rating ?? 3, ], ) return reply.code(201).send(decryptGuest(guest)) }, ) // ── PATCH /api/hotels/:slug/guests/:id ────────────────────────────────────── fastify.patch }>( '/api/hotels/:slug/guests/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, id } = 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 b = request.body const sets: string[] = [] const vals: unknown[] = [id, hotelId] let idx = 3 const add = (col: string, val: unknown) => { if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) } } add('first_name', b.first_name) add('last_name', b.last_name) add('middle_name', b.middle_name) add('email', b.email) add('phone', b.phone) add('nationality', b.nationality) add('gender', b.gender) add('city', b.city) add('notes', b.notes) add('tags', b.tags) add('rating', b.rating) // Шифруем чувствительные поля if (b.passport !== undefined) add('passport', encryptField(b.passport)) if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series)) if (b.passport_number !== undefined) add('passport_number', encryptField(b.passport_number)) if (b.birth_date !== undefined) add('birth_date', encryptField(b.birth_date || null)) // Обновляем токен если поменялся паспорт if (b.passport_series !== undefined || b.passport_number !== undefined) { // Читаем текущие значения если нужно const { rows: [cur] } = await db.query( 'SELECT passport_series, passport_number FROM guests WHERE id = $1 AND hotel_id = $2', [id, hotelId], ) const series = b.passport_series ?? decryptField(cur?.passport_series) ?? '' const number = b.passport_number ?? decryptField(cur?.passport_number) ?? '' if (series && number) add('passport_search_token', passportToken(series.trim() + number.trim())) } if (sets.length === 0) return reply.code(400).send({ error: 'No fields to update' }) sets.push('updated_at = NOW()') const { rows: [guest] } = await db.query( `UPDATE guests SET ${sets.join(', ')} WHERE id = $1 AND hotel_id = $2 RETURNING *`, vals, ) if (!guest) return reply.code(404).send({ error: 'Guest not found' }) return decryptGuest(guest) }, ) // ── DELETE /api/hotels/:slug/guests/:id ───────────────────────────────────── fastify.delete( '/api/hotels/:slug/guests/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, id } = 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' }) await db.query('DELETE FROM guests WHERE id = $1 AND hotel_id = $2', [id, hotelId]) return reply.code(204).send() }, ) } export default guests