Add multi-guest management: per-booking roster, docs, guest autocomplete
- Migration 010: booking_guests table (roster per booking with passport data) - Backend: /bookings/:id/guests CRUD — auto-links/creates guest profiles by passport - Backend: /hotel-settings GET/PATCH for key-value settings (require_guest_docs) - BookingDetailPanel: Гости tab with multi-guest list, inline add/edit form, guest autocomplete (debounced search in guests table), child/main badges, passport fields for adults, scan stub, require-docs warning - SettingsPage: toggle "Обязательное заполнение документов гостей" - Pass slug prop through CalendarPage → BookingCalendar → BookingDetailPanel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,9 @@ import housekeepingRoutes from './routes/housekeeping'
|
||||
import channelsRoutes from './routes/channels'
|
||||
import usersRoutes from './routes/users'
|
||||
import netupRoutes from './routes/netup'
|
||||
import guestsRoutes from './routes/guests'
|
||||
import guestsRoutes from './routes/guests'
|
||||
import bookingGuestsRoutes from './routes/booking-guests'
|
||||
import hotelSettingsRoutes from './routes/hotel-settings'
|
||||
|
||||
export async function buildApp() {
|
||||
const fastify = Fastify({
|
||||
@@ -75,6 +77,8 @@ export async function buildApp() {
|
||||
await fastify.register(usersRoutes)
|
||||
await fastify.register(netupRoutes)
|
||||
await fastify.register(guestsRoutes)
|
||||
await fastify.register(bookingGuestsRoutes)
|
||||
await fastify.register(hotelSettingsRoutes)
|
||||
|
||||
return fastify
|
||||
}
|
||||
|
||||
244
backend/src/routes/booking-guests.ts
Normal file
244
backend/src/routes/booking-guests.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
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
|
||||
}>
|
||||
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
// ── 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' })
|
||||
|
||||
// Verify booking belongs to hotel
|
||||
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
|
||||
|
||||
// Auto-link or create guest profile if passport provided
|
||||
if (b.passport_series && b.passport_number) {
|
||||
const series = b.passport_series.trim()
|
||||
const number = b.passport_number.trim()
|
||||
|
||||
const { rows: existing } = await db.query(
|
||||
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`,
|
||||
[hotelId, series, number],
|
||||
)
|
||||
|
||||
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, birth_date, nationality, notes, tags, rating)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, '', '{}', 3)
|
||||
RETURNING id`,
|
||||
[hotelId, b.first_name, b.last_name, series, number, b.birth_date ?? null, b.nationality ?? null],
|
||||
)
|
||||
guestId = ng.id
|
||||
}
|
||||
} else if (!b.is_child) {
|
||||
// Create a minimal guest profile for adults even without passport
|
||||
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, b.birth_date ?? null],
|
||||
)
|
||||
guestId = ng.id
|
||||
}
|
||||
|
||||
// If marking as main, clear previous main flag
|
||||
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 { 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)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
RETURNING *`,
|
||||
[
|
||||
bookingId, hotelId, guestId,
|
||||
b.first_name, b.last_name, b.middle_name ?? null,
|
||||
b.birth_date ?? null, b.is_child ?? false, b.is_main ?? false,
|
||||
b.passport_series ?? null, b.passport_number ?? null,
|
||||
b.passport_issued_by ?? null, b.passport_issue_date ?? null,
|
||||
b.nationality ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
// If main guest, link to booking
|
||||
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(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 setting is_main, clear other rows first
|
||||
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('birth_date', b.birth_date || null)
|
||||
add('is_child', b.is_child)
|
||||
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)
|
||||
|
||||
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' })
|
||||
|
||||
// Sync subset of fields to guest profile
|
||||
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)
|
||||
addG('passport_series', b.passport_series)
|
||||
addG('passport_number', b.passport_number)
|
||||
addG('birth_date', b.birth_date)
|
||||
addG('nationality', b.nationality)
|
||||
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 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
|
||||
66
backend/src/routes/hotel-settings.ts
Normal file
66
backend/src/routes/hotel-settings.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
|
||||
const hotelSettings: 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/hotel-settings ─────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/hotel-settings',
|
||||
{ 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 { rows } = await db.query(
|
||||
'SELECT key, value FROM hotel_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const row of rows) {
|
||||
out[row.key] = row.value
|
||||
}
|
||||
return out
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/hotel-settings ───────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: Record<string, unknown> }>(
|
||||
'/api/hotels/:slug/hotel-settings',
|
||||
{ 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 updates = request.body
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
await db.query(
|
||||
`INSERT INTO hotel_settings (hotel_id, key, value, updated_at)
|
||||
VALUES ($1, $2, $3::jsonb, NOW())
|
||||
ON CONFLICT (hotel_id, key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = NOW()`,
|
||||
[hotelId, key, JSON.stringify(value)],
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default hotelSettings
|
||||
Reference in New Issue
Block a user