- New backend/src/lib/crypto.ts: encrypt/decrypt (AES-256-GCM) + passportToken (HMAC-SHA256) - Migration 012: birth_date/passport_issue_date → TEXT, add passport_search_token indexes - guests.ts: encrypt passport_series/number/birth_date on write, decrypt on read, passport search via HMAC token (?passport= param) instead of plaintext LIKE - booking-guests.ts: encrypt all sensitive fields, search token for passport lookup - api.ts: guests.list() supports passport= param for exact passport lookup - BookingDetailPanel: use passport param for passport-based autocomplete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
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<string, unknown>) {
|
|
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<string | null> => {
|
|
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<Params>(
|
|
'/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<Params & { Body: GuestBody & { first_name: string; last_name: string } }>(
|
|
'/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<ParamsWithId & { Body: GuestBody }>(
|
|
'/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<ParamsWithId>(
|
|
'/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
|