Add AES-256-GCM encryption for sensitive personal data fields
- 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>
This commit is contained in:
28
backend/migrations/012_encrypt_setup.sql
Normal file
28
backend/migrations/012_encrypt_setup.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Migration 012: подготовка схемы для шифрования чувствительных полей
|
||||||
|
-- birth_date и passport_issue_date меняем с DATE на TEXT (для хранения зашифрованных значений)
|
||||||
|
-- Добавляем passport_search_token — HMAC-индекс для поиска без расшифровки
|
||||||
|
|
||||||
|
-- ── guests ────────────────────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE guests
|
||||||
|
ALTER COLUMN birth_date TYPE TEXT USING birth_date::TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE guests
|
||||||
|
ADD COLUMN IF NOT EXISTS passport_search_token VARCHAR(64);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guests_passport_token
|
||||||
|
ON guests(hotel_id, passport_search_token)
|
||||||
|
WHERE passport_search_token IS NOT NULL;
|
||||||
|
|
||||||
|
-- ── booking_guests ────────────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE booking_guests
|
||||||
|
ALTER COLUMN birth_date TYPE TEXT USING birth_date::TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE booking_guests
|
||||||
|
ALTER COLUMN passport_issue_date TYPE TEXT USING passport_issue_date::TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE booking_guests
|
||||||
|
ADD COLUMN IF NOT EXISTS passport_search_token VARCHAR(64);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_booking_guests_passport_token
|
||||||
|
ON booking_guests(hotel_id, passport_search_token)
|
||||||
|
WHERE passport_search_token IS NOT NULL;
|
||||||
51
backend/src/lib/crypto.ts
Normal file
51
backend/src/lib/crypto.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'crypto'
|
||||||
|
|
||||||
|
const KEY = Buffer.from(process.env.ENCRYPTION_KEY ?? '', 'hex') // 32 bytes
|
||||||
|
const HMAC = process.env.HMAC_KEY ?? ''
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'test' && (KEY.length !== 32 || !HMAC)) {
|
||||||
|
throw new Error('ENCRYPTION_KEY (64 hex chars) and HMAC_KEY must be set in environment')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypt ───────────────────────────────────────────────────────────────────
|
||||||
|
// Format: hex(iv 12b) : hex(authTag 16b) : hex(ciphertext)
|
||||||
|
export function encrypt(plaintext: string): string {
|
||||||
|
const iv = randomBytes(12)
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', KEY, iv)
|
||||||
|
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
return `${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decrypt ───────────────────────────────────────────────────────────────────
|
||||||
|
export function decrypt(data: string): string {
|
||||||
|
const parts = data.split(':')
|
||||||
|
if (parts.length !== 3) return data // не зашифровано — вернуть как есть
|
||||||
|
const [ivHex, tagHex, encHex] = parts
|
||||||
|
if (ivHex.length !== 24) return data // не наш формат
|
||||||
|
const iv = Buffer.from(ivHex, 'hex')
|
||||||
|
const authTag = Buffer.from(tagHex, 'hex')
|
||||||
|
const enc = Buffer.from(encHex, 'hex')
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', KEY, iv)
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Null-safe helpers ─────────────────────────────────────────────────────────
|
||||||
|
export function encryptField(val: string | null | undefined): string | null {
|
||||||
|
if (!val) return null
|
||||||
|
return encrypt(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptField(val: string | null | undefined): string | null {
|
||||||
|
if (!val) return null
|
||||||
|
try { return decrypt(val) } catch { return val } // старые незашифрованные данные
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passport search token — HMAC(series+number) ───────────────────────────────
|
||||||
|
// Хранится рядом с зашифрованными полями для точного поиска без расшифровки
|
||||||
|
export function passportToken(seriesAndNumber: string): string {
|
||||||
|
return createHmac('sha256', HMAC)
|
||||||
|
.update(seriesAndNumber.toLowerCase().replace(/\s/g, ''))
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
|
import { encryptField, decryptField, passportToken } from '../lib/crypto'
|
||||||
|
|
||||||
type Params = { Params: { slug: string; bookingId: string } }
|
type Params = { Params: { slug: string; bookingId: string } }
|
||||||
type ParamsWithId = { Params: { slug: string; bookingId: string; id: string } }
|
type ParamsWithId = { Params: { slug: string; bookingId: string; id: string } }
|
||||||
@@ -12,6 +13,18 @@ type GuestBody = Partial<{
|
|||||||
nationality: 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 bookingGuests: FastifyPluginAsync = async (fastify) => {
|
||||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||||
@@ -40,7 +53,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
ORDER BY bg.is_main DESC, bg.is_child ASC, bg.created_at ASC`,
|
ORDER BY bg.is_main DESC, bg.is_child ASC, bg.created_at ASC`,
|
||||||
[bookingId, hotelId],
|
[bookingId, hotelId],
|
||||||
)
|
)
|
||||||
return rows
|
return rows.map(decryptBg)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +69,6 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
// Verify booking belongs to hotel
|
|
||||||
const { rows: [bk] } = await db.query(
|
const { rows: [bk] } = await db.query(
|
||||||
'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2',
|
'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2',
|
||||||
[bookingId, hotelId],
|
[bookingId, hotelId],
|
||||||
@@ -66,14 +78,15 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
const b = request.body
|
const b = request.body
|
||||||
let guestId: string | null = null
|
let guestId: string | null = null
|
||||||
|
|
||||||
// Auto-link or create guest profile if passport provided
|
// Ищем/создаём гостя по паспортному токену
|
||||||
if (b.passport_series && b.passport_number) {
|
if (b.passport_series && b.passport_number) {
|
||||||
const series = b.passport_series.trim()
|
const series = b.passport_series.trim()
|
||||||
const number = b.passport_number.trim()
|
const number = b.passport_number.trim()
|
||||||
|
const token = passportToken(series + number)
|
||||||
|
|
||||||
const { rows: existing } = await db.query(
|
const { rows: existing } = await db.query(
|
||||||
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`,
|
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_search_token = $2`,
|
||||||
[hotelId, series, number],
|
[hotelId, token],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
@@ -86,25 +99,30 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
} else {
|
} else {
|
||||||
const { rows: [ng] } = await db.query(
|
const { rows: [ng] } = await db.query(
|
||||||
`INSERT INTO guests
|
`INSERT INTO guests
|
||||||
(hotel_id, first_name, last_name, passport_series, passport_number, birth_date, nationality, notes, tags, rating)
|
(hotel_id, first_name, last_name, passport_series, passport_number,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, '', '{}', 3)
|
passport_search_token, birth_date, nationality, notes, tags, rating)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', '{}', 3)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[hotelId, b.first_name, b.last_name, series, number, b.birth_date ?? null, b.nationality ?? null],
|
[
|
||||||
|
hotelId, b.first_name, b.last_name,
|
||||||
|
encryptField(series), encryptField(number),
|
||||||
|
token,
|
||||||
|
encryptField(b.birth_date ?? null),
|
||||||
|
b.nationality ?? null,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
guestId = ng.id
|
guestId = ng.id
|
||||||
}
|
}
|
||||||
} else if (!b.is_child) {
|
} else if (!b.is_child) {
|
||||||
// Create a minimal guest profile for adults even without passport
|
|
||||||
const { rows: [ng] } = await db.query(
|
const { rows: [ng] } = await db.query(
|
||||||
`INSERT INTO guests (hotel_id, first_name, last_name, birth_date, notes, tags, rating)
|
`INSERT INTO guests (hotel_id, first_name, last_name, birth_date, notes, tags, rating)
|
||||||
VALUES ($1, $2, $3, $4, '', '{}', 3)
|
VALUES ($1, $2, $3, $4, '', '{}', 3)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[hotelId, b.first_name, b.last_name, b.birth_date ?? null],
|
[hotelId, b.first_name, b.last_name, encryptField(b.birth_date ?? null)],
|
||||||
)
|
)
|
||||||
guestId = ng.id
|
guestId = ng.id
|
||||||
}
|
}
|
||||||
|
|
||||||
// If marking as main, clear previous main flag
|
|
||||||
if (b.is_main) {
|
if (b.is_main) {
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2`,
|
`UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2`,
|
||||||
@@ -112,24 +130,31 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bgToken = (b.passport_series && b.passport_number)
|
||||||
|
? passportToken(b.passport_series.trim() + b.passport_number.trim())
|
||||||
|
: null
|
||||||
|
|
||||||
const { rows: [bg] } = await db.query(
|
const { rows: [bg] } = await db.query(
|
||||||
`INSERT INTO booking_guests
|
`INSERT INTO booking_guests
|
||||||
(booking_id, hotel_id, guest_id, first_name, last_name, middle_name,
|
(booking_id, hotel_id, guest_id, first_name, last_name, middle_name,
|
||||||
birth_date, is_child, is_main, passport_series, passport_number,
|
birth_date, is_child, is_main, passport_series, passport_number,
|
||||||
passport_issued_by, passport_issue_date, nationality)
|
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)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
bookingId, hotelId, guestId,
|
bookingId, hotelId, guestId,
|
||||||
b.first_name, b.last_name, b.middle_name ?? null,
|
b.first_name, b.last_name, b.middle_name ?? null,
|
||||||
b.birth_date ?? null, b.is_child ?? false, b.is_main ?? false,
|
encryptField(b.birth_date ?? null),
|
||||||
b.passport_series ?? null, b.passport_number ?? null,
|
b.is_child ?? false, b.is_main ?? false,
|
||||||
b.passport_issued_by ?? null, b.passport_issue_date ?? null,
|
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,
|
b.nationality ?? null,
|
||||||
|
bgToken,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
// If main guest, link to booking
|
|
||||||
if (b.is_main && guestId) {
|
if (b.is_main && guestId) {
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE bookings SET guest_id = $1, updated_at = NOW() WHERE id = $2 AND hotel_id = $3`,
|
`UPDATE bookings SET guest_id = $1, updated_at = NOW() WHERE id = $2 AND hotel_id = $3`,
|
||||||
@@ -137,7 +162,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(201).send(bg)
|
return reply.code(201).send(decryptBg(bg))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,7 +180,6 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
const b = request.body
|
const b = request.body
|
||||||
|
|
||||||
// If setting is_main, clear other rows first
|
|
||||||
if (b.is_main) {
|
if (b.is_main) {
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2 AND id != $3`,
|
`UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2 AND id != $3`,
|
||||||
@@ -170,17 +194,29 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
const add = (col: string, val: unknown) => {
|
const add = (col: string, val: unknown) => {
|
||||||
if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) }
|
if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) }
|
||||||
}
|
}
|
||||||
|
|
||||||
add('first_name', b.first_name)
|
add('first_name', b.first_name)
|
||||||
add('last_name', b.last_name)
|
add('last_name', b.last_name)
|
||||||
add('middle_name', b.middle_name)
|
add('middle_name', b.middle_name)
|
||||||
add('birth_date', b.birth_date || null)
|
|
||||||
add('is_child', b.is_child)
|
add('is_child', b.is_child)
|
||||||
add('is_main', b.is_main)
|
add('is_main', b.is_main)
|
||||||
add('passport_series', b.passport_series)
|
|
||||||
add('passport_number', b.passport_number)
|
|
||||||
add('passport_issued_by', b.passport_issued_by)
|
|
||||||
add('passport_issue_date', b.passport_issue_date || null)
|
|
||||||
add('nationality', b.nationality)
|
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' })
|
if (sets.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||||
sets.push('updated_at = NOW()')
|
sets.push('updated_at = NOW()')
|
||||||
@@ -193,7 +229,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
if (!bg) return reply.code(404).send({ error: 'Not found' })
|
if (!bg) return reply.code(404).send({ error: 'Not found' })
|
||||||
|
|
||||||
// Sync subset of fields to guest profile
|
// Синхронизируем часть полей в профиль гостя
|
||||||
if (bg.guest_id) {
|
if (bg.guest_id) {
|
||||||
const gSets: string[] = []
|
const gSets: string[] = []
|
||||||
const gVals: unknown[] = [bg.guest_id, hotelId]
|
const gVals: unknown[] = [bg.guest_id, hotelId]
|
||||||
@@ -203,10 +239,16 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
addG('first_name', b.first_name)
|
addG('first_name', b.first_name)
|
||||||
addG('last_name', b.last_name)
|
addG('last_name', b.last_name)
|
||||||
addG('passport_series', b.passport_series)
|
if (b.passport_series !== undefined) addG('passport_series', encryptField(b.passport_series))
|
||||||
addG('passport_number', b.passport_number)
|
if (b.passport_number !== undefined) addG('passport_number', encryptField(b.passport_number))
|
||||||
addG('birth_date', b.birth_date)
|
if (b.birth_date !== undefined) addG('birth_date', encryptField(b.birth_date || null))
|
||||||
addG('nationality', b.nationality)
|
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) {
|
if (gSets.length > 0) {
|
||||||
gSets.push('updated_at = NOW()')
|
gSets.push('updated_at = NOW()')
|
||||||
await db.query(
|
await db.query(
|
||||||
@@ -216,7 +258,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bg
|
return decryptBg(bg)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
|
import { encryptField, decryptField, passportToken } from '../lib/crypto'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||||
@@ -23,6 +24,17 @@ const STATS_JOIN = `
|
|||||||
GROUP BY guest_id
|
GROUP BY guest_id
|
||||||
) stats ON stats.guest_id = g.id`
|
) stats ON stats.guest_id = g.id`
|
||||||
|
|
||||||
|
// Расшифровывает чувствительные поля строки гостя перед отправкой клиенту
|
||||||
|
function decryptGuest(row: Record<string, unknown>) {
|
||||||
|
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 guests: FastifyPluginAsync = async (fastify) => {
|
||||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||||
@@ -33,7 +45,8 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
role === 'super_admin' || userSlug === slug
|
role === 'super_admin' || userSlug === slug
|
||||||
|
|
||||||
// ── GET /api/hotels/:slug/guests ────────────────────────────────────────────
|
// ── GET /api/hotels/:slug/guests ────────────────────────────────────────────
|
||||||
fastify.get<SlugParam & { Querystring: { q?: string } }>(
|
// ?q=<имя/email/телефон> или ?passport=<серия+номер>
|
||||||
|
fastify.get<SlugParam & { Querystring: { q?: string; passport?: string } }>(
|
||||||
'/api/hotels/:slug/guests',
|
'/api/hotels/:slug/guests',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -44,16 +57,19 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { q } = request.query as { q?: string }
|
const { q, passport: passportQ } = request.query as { q?: string; passport?: string }
|
||||||
const params: unknown[] = [hotelId]
|
const params: unknown[] = [hotelId]
|
||||||
|
|
||||||
let where = 'WHERE g.hotel_id = $1'
|
let where = 'WHERE g.hotel_id = $1'
|
||||||
if (q) {
|
|
||||||
|
if (passportQ) {
|
||||||
|
// Точный поиск по паспорту через HMAC-токен (зашифрованные поля не ищутся через LIKE)
|
||||||
|
params.push(passportToken(passportQ))
|
||||||
|
where += ` AND g.passport_search_token = $2`
|
||||||
|
} else if (q) {
|
||||||
params.push(`%${q.toLowerCase()}%`)
|
params.push(`%${q.toLowerCase()}%`)
|
||||||
where += ` AND (
|
where += ` AND (
|
||||||
LOWER(g.first_name) LIKE $2 OR LOWER(g.last_name) LIKE $2 OR
|
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 OR
|
LOWER(COALESCE(g.email,'')) LIKE $2 OR COALESCE(g.phone,'') LIKE $2
|
||||||
COALESCE(g.passport_series,'') LIKE $2 OR COALESCE(g.passport_number,'') LIKE $2
|
|
||||||
)`
|
)`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +83,7 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
ORDER BY g.last_name, g.first_name`,
|
ORDER BY g.last_name, g.first_name`,
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
return rows
|
return rows.map(decryptGuest)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,12 +120,11 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
ORDER BY b.check_in DESC`,
|
ORDER BY b.check_in DESC`,
|
||||||
[id, hotelId],
|
[id, hotelId],
|
||||||
)
|
)
|
||||||
return { ...guest, history }
|
return { ...decryptGuest(guest), history }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── POST /api/hotels/:slug/guests ────────────────────────────────────────────
|
// ── POST /api/hotels/:slug/guests ────────────────────────────────────────────
|
||||||
// Creates a new guest or returns existing one if passport matches
|
|
||||||
fastify.post<SlugParam & { Body: {
|
fastify.post<SlugParam & { Body: {
|
||||||
first_name: string; last_name: string; middle_name?: string
|
first_name: string; last_name: string; middle_name?: string
|
||||||
email?: string; phone?: string
|
email?: string; phone?: string
|
||||||
@@ -129,11 +144,12 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
const b = request.body
|
const b = request.body
|
||||||
|
|
||||||
// If passport provided — try to find existing guest
|
// Если передан паспорт — ищем существующего гостя по HMAC-токену
|
||||||
if (b.passport_series && b.passport_number) {
|
if (b.passport_series && b.passport_number) {
|
||||||
|
const token = passportToken(b.passport_series.trim() + b.passport_number.trim())
|
||||||
const { rows: existing } = await db.query(
|
const { rows: existing } = await db.query(
|
||||||
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`,
|
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_search_token = $2`,
|
||||||
[hotelId, b.passport_series.trim(), b.passport_number.trim()],
|
[hotelId, token],
|
||||||
)
|
)
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
const { rows: [updated] } = await db.query(
|
const { rows: [updated] } = await db.query(
|
||||||
@@ -146,28 +162,39 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1 AND hotel_id = $2
|
WHERE id = $1 AND hotel_id = $2
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[existing[0].id, hotelId, b.first_name || null, b.last_name || null, b.middle_name || null, b.email || null, b.phone || null],
|
[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(updated)
|
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(
|
const { rows: [guest] } = await db.query(
|
||||||
`INSERT INTO guests
|
`INSERT INTO guests
|
||||||
(hotel_id, first_name, last_name, middle_name, email, phone,
|
(hotel_id, first_name, last_name, middle_name, email, phone,
|
||||||
passport, passport_series, passport_number,
|
passport, passport_series, passport_number, passport_search_token,
|
||||||
birth_date, nationality, gender, city, notes, tags, rating)
|
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)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
hotelId, b.first_name, b.last_name, b.middle_name ?? null,
|
hotelId, b.first_name, b.last_name, b.middle_name ?? null,
|
||||||
b.email ?? null, b.phone ?? null,
|
b.email ?? null, b.phone ?? null,
|
||||||
b.passport ?? null, b.passport_series ?? null, b.passport_number ?? null,
|
encryptField(b.passport ?? null),
|
||||||
b.birth_date ?? null, b.nationality ?? null, b.gender ?? null, b.city ?? 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,
|
b.notes ?? '', b.tags ?? [], b.rating ?? 3,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return reply.code(201).send(guest)
|
return reply.code(201).send(decryptGuest(guest))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -197,21 +224,34 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
const add = (col: string, val: unknown) => {
|
const add = (col: string, val: unknown) => {
|
||||||
if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) }
|
if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) }
|
||||||
}
|
}
|
||||||
|
|
||||||
add('first_name', b.first_name)
|
add('first_name', b.first_name)
|
||||||
add('last_name', b.last_name)
|
add('last_name', b.last_name)
|
||||||
add('middle_name', b.middle_name)
|
add('middle_name', b.middle_name)
|
||||||
add('email', b.email)
|
add('email', b.email)
|
||||||
add('phone', b.phone)
|
add('phone', b.phone)
|
||||||
add('passport', b.passport)
|
|
||||||
add('passport_series', b.passport_series)
|
|
||||||
add('passport_number', b.passport_number)
|
|
||||||
add('birth_date', b.birth_date || null)
|
|
||||||
add('nationality', b.nationality)
|
add('nationality', b.nationality)
|
||||||
add('gender', b.gender)
|
add('gender', b.gender)
|
||||||
add('city', b.city)
|
add('city', b.city)
|
||||||
add('notes', b.notes)
|
add('notes', b.notes)
|
||||||
add('tags', b.tags)
|
add('tags', b.tags)
|
||||||
add('rating', b.rating)
|
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' })
|
if (sets.length === 0) return reply.code(400).send({ error: 'No fields to update' })
|
||||||
sets.push('updated_at = NOW()')
|
sets.push('updated_at = NOW()')
|
||||||
@@ -221,7 +261,7 @@ const guests: FastifyPluginAsync = async (fastify) => {
|
|||||||
vals,
|
vals,
|
||||||
)
|
)
|
||||||
if (!guest) return reply.code(404).send({ error: 'Guest not found' })
|
if (!guest) return reply.code(404).send({ error: 'Guest not found' })
|
||||||
return guest
|
return decryptGuest(guest)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
const query = (series + number).trim()
|
const query = (series + number).trim()
|
||||||
if (query.length < 4 || !slug) return
|
if (query.length < 4 || !slug) return
|
||||||
passportTimerRef.current = setTimeout(() => {
|
passportTimerRef.current = setTimeout(() => {
|
||||||
api.guests.list(slug, query)
|
api.guests.list(slug, undefined, query)
|
||||||
.then(results => {
|
.then(results => {
|
||||||
if (results.length === 1) selectAcGuest(results[0])
|
if (results.length === 1) selectAcGuest(results[0])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -292,8 +292,12 @@ export const api = {
|
|||||||
|
|
||||||
// ── Guests ────────────────────────────────────────────────────────────────
|
// ── Guests ────────────────────────────────────────────────────────────────
|
||||||
guests: {
|
guests: {
|
||||||
list: (slug: string, q?: string) =>
|
list: (slug: string, q?: string, passport?: string) => {
|
||||||
req<GuestApiType[]>('GET', `/api/hotels/${slug}/guests${q ? `?q=${encodeURIComponent(q)}` : ''}`),
|
const params = passport
|
||||||
|
? `?passport=${encodeURIComponent(passport)}`
|
||||||
|
: q ? `?q=${encodeURIComponent(q)}` : ''
|
||||||
|
return req<GuestApiType[]>('GET', `/api/hotels/${slug}/guests${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
get: (slug: string, id: string) =>
|
get: (slug: string, id: string) =>
|
||||||
req<GuestApiType>('GET', `/api/hotels/${slug}/guests/${id}`),
|
req<GuestApiType>('GET', `/api/hotels/${slug}/guests/${id}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user