import { FastifyPluginAsync } from 'fastify' import { db } from '../db' import { encryptField, decryptField, passportToken } from '../lib/crypto' type Params = { Params: { slug: string; bookingId: string } } type ParamsWithId = { Params: { slug: string; bookingId: string; id: string } } type GuestBody = Partial<{ first_name: string; last_name: string; middle_name: string birth_date: string; is_child: boolean; is_main: boolean passport_series: string; passport_number: string passport_issued_by: string; passport_issue_date: string nationality: string }> // Расшифровывает чувствительные поля строки booking_guest перед отправкой клиенту function decryptBg(row: Record) { return { ...row, passport_series: decryptField(row.passport_series as string | null), passport_number: decryptField(row.passport_number as string | null), passport_issued_by: decryptField(row.passport_issued_by as string | null), passport_issue_date:decryptField(row.passport_issue_date as string | null), birth_date: decryptField(row.birth_date as string | null), } } const bookingGuests: 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/bookings/:bookingId/guests ───────────────────── fastify.get( '/api/hotels/:slug/bookings/:bookingId/guests', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = 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 } = await db.query( `SELECT bg.* FROM booking_guests bg WHERE bg.booking_id = $1 AND bg.hotel_id = $2 ORDER BY bg.is_main DESC, bg.is_child ASC, bg.created_at ASC`, [bookingId, hotelId], ) return rows.map(decryptBg) }, ) // ── POST /api/hotels/:slug/bookings/:bookingId/guests ───────────────────── fastify.post( '/api/hotels/:slug/bookings/:bookingId/guests', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = 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: [bk] } = await db.query( 'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2', [bookingId, hotelId], ) if (!bk) return reply.code(404).send({ error: 'Booking not found' }) const b = request.body let guestId: string | null = null // Ищем/создаём гостя по паспортному токену if (b.passport_series && b.passport_number) { const series = b.passport_series.trim() const number = b.passport_number.trim() const token = passportToken(series + number) 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) { guestId = existing[0].id await db.query( `UPDATE guests SET first_name = $3, last_name = $4, updated_at = NOW() WHERE id = $1 AND hotel_id = $2`, [guestId, hotelId, b.first_name, b.last_name], ) } else { const { rows: [ng] } = await db.query( `INSERT INTO guests (hotel_id, first_name, last_name, passport_series, passport_number, passport_search_token, birth_date, nationality, notes, tags, rating) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', '{}', 3) RETURNING id`, [ hotelId, b.first_name, b.last_name, encryptField(series), encryptField(number), token, encryptField(b.birth_date ?? null), b.nationality ?? null, ], ) guestId = ng.id } } else if (!b.is_child) { const { rows: [ng] } = await db.query( `INSERT INTO guests (hotel_id, first_name, last_name, birth_date, notes, tags, rating) VALUES ($1, $2, $3, $4, '', '{}', 3) RETURNING id`, [hotelId, b.first_name, b.last_name, encryptField(b.birth_date ?? null)], ) guestId = ng.id } if (b.is_main) { await db.query( `UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2`, [bookingId, hotelId], ) } const bgToken = (b.passport_series && b.passport_number) ? passportToken(b.passport_series.trim() + b.passport_number.trim()) : null const { rows: [bg] } = await db.query( `INSERT INTO booking_guests (booking_id, hotel_id, guest_id, first_name, last_name, middle_name, birth_date, is_child, is_main, passport_series, passport_number, passport_issued_by, passport_issue_date, nationality, passport_search_token) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, [ bookingId, hotelId, guestId, b.first_name, b.last_name, b.middle_name ?? null, encryptField(b.birth_date ?? null), b.is_child ?? false, b.is_main ?? false, encryptField(b.passport_series ?? null), encryptField(b.passport_number ?? null), encryptField(b.passport_issued_by ?? null), encryptField(b.passport_issue_date ?? null), b.nationality ?? null, bgToken, ], ) if (b.is_main && guestId) { await db.query( `UPDATE bookings SET guest_id = $1, updated_at = NOW() WHERE id = $2 AND hotel_id = $3`, [guestId, bookingId, hotelId], ) } return reply.code(201).send(decryptBg(bg)) }, ) // ── PATCH /api/hotels/:slug/bookings/:bookingId/guests/:id ──────────────── fastify.patch( '/api/hotels/:slug/bookings/:bookingId/guests/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId, 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 if (b.is_main) { await db.query( `UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2 AND id != $3`, [bookingId, hotelId, id], ) } const sets: string[] = [] const vals: unknown[] = [id, bookingId, hotelId] let idx = 4 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('is_child', b.is_child) add('is_main', b.is_main) add('nationality', b.nationality) // Шифруем чувствительные поля if (b.birth_date !== undefined) add('birth_date', encryptField(b.birth_date || null)) 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.passport_issued_by !== undefined) add('passport_issued_by', encryptField(b.passport_issued_by)) if (b.passport_issue_date !== undefined)add('passport_issue_date', encryptField(b.passport_issue_date || null)) // Обновляем токен если поменялся паспорт if (b.passport_series !== undefined || b.passport_number !== undefined) { const { rows: [cur] } = await db.query( 'SELECT passport_series, passport_number FROM booking_guests WHERE id = $1', [id], ) 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: 'Nothing to update' }) sets.push('updated_at = NOW()') const { rows: [bg] } = await db.query( `UPDATE booking_guests SET ${sets.join(', ')} WHERE id = $1 AND booking_id = $2 AND hotel_id = $3 RETURNING *`, vals, ) if (!bg) return reply.code(404).send({ error: 'Not found' }) // Синхронизируем часть полей в профиль гостя if (bg.guest_id) { const gSets: string[] = [] const gVals: unknown[] = [bg.guest_id, hotelId] let gi = 3 const addG = (col: string, val: unknown) => { if (val !== undefined) { gSets.push(`${col} = $${gi++}`); gVals.push(val) } } addG('first_name', b.first_name) addG('last_name', b.last_name) if (b.passport_series !== undefined) addG('passport_series', encryptField(b.passport_series)) if (b.passport_number !== undefined) addG('passport_number', encryptField(b.passport_number)) if (b.birth_date !== undefined) addG('birth_date', encryptField(b.birth_date || null)) addG('nationality', b.nationality) // Обновляем токен в профиле гостя if (b.passport_series !== undefined || b.passport_number !== undefined) { const series = b.passport_series ?? decryptField(bg.passport_series as string) ?? '' const number = b.passport_number ?? decryptField(bg.passport_number as string) ?? '' if (series && number) addG('passport_search_token', passportToken(series.trim() + number.trim())) } if (gSets.length > 0) { gSets.push('updated_at = NOW()') await db.query( `UPDATE guests SET ${gSets.join(', ')} WHERE id = $1 AND hotel_id = $2`, gVals, ) } } return decryptBg(bg) }, ) // ── DELETE /api/hotels/:slug/bookings/:bookingId/guests/:id ─────────────── fastify.delete( '/api/hotels/:slug/bookings/:bookingId/guests/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId, 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 booking_guests WHERE id = $1 AND booking_id = $2 AND hotel_id = $3`, [id, bookingId, hotelId], ) return reply.code(204).send() }, ) } export default bookingGuests