Files
hotelsync/backend/src/routes/guests.ts
HotelSync babd6e950e Fix: add passport_issued_by/issue_date to guests table for full autocomplete
- Migration 014: add passport_issued_by, passport_issue_date to guests table
- guests.ts: include new fields in SELECT, INSERT, PATCH + encrypt/decrypt
- booking-guests.ts: sync issued_by/issue_date back to guests on update
- GuestApiType: add passportIssuedBy, passportIssueDate fields
- selectAcGuest: fill passport_issued_by and passport_issue_date from guest profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:47:12 +03:00

297 lines
13 KiB
TypeScript

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.passport_issued_by, g.passport_issue_date,
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<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),
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 guests: 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/guests ────────────────────────────────────────────
// ?q=<имя/email/телефон> или ?passport=<серия+номер>
fastify.get<SlugParam & { Querystring: { q?: string; passport?: string } }>(
'/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<SlugIdParam>(
'/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<SlugParam & { Body: {
first_name: string; last_name: string; middle_name?: string
email?: string; phone?: string
passport?: string; passport_series?: string; passport_number?: string
passport_issued_by?: string; passport_issue_date?: string
birth_date?: string; nationality?: string; gender?: string; city?: string
notes?: string; tags?: string[]; rating?: number
} }>(
'/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,
passport_issued_by, passport_issue_date,
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,$18,$19)
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.passport_issued_by ?? null),
encryptField(b.passport_issue_date ?? null),
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<SlugIdParam & { Body: Partial<{
first_name: string; last_name: string; middle_name: string
email: string; phone: string
passport: string; passport_series: string; passport_number: string
passport_issued_by: string; passport_issue_date: string
birth_date: string; nationality: string; gender: string; city: string
notes: string; tags: string[]; rating: number
}> }>(
'/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.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.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<SlugIdParam>(
'/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