Add Guests module: API integration + passport identification
- Migration 009: add passport_series, passport_number, rating to guests table - Backend route /api/hotels/:slug/guests: list, get (with history), create/upsert by passport, update, delete - Frontend GuestsPage: loads from API, passport tab in modal, add guest form, search by passport - api.ts: add guests API methods and GuestApiType Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
backend/migrations/009_guests.sql
Normal file
12
backend/migrations/009_guests.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Migration 009 — Add passport_series, passport_number and rating to guests
|
||||||
|
|
||||||
|
ALTER TABLE guests
|
||||||
|
ADD COLUMN IF NOT EXISTS passport_series VARCHAR(20),
|
||||||
|
ADD COLUMN IF NOT EXISTS passport_number VARCHAR(20),
|
||||||
|
ADD COLUMN IF NOT EXISTS rating INTEGER NOT NULL DEFAULT 3
|
||||||
|
CHECK (rating BETWEEN 1 AND 5);
|
||||||
|
|
||||||
|
-- Unique index: one profile per passport (series+number) per hotel
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_guests_passport_unique
|
||||||
|
ON guests(hotel_id, passport_series, passport_number)
|
||||||
|
WHERE passport_series IS NOT NULL AND passport_number IS NOT NULL;
|
||||||
@@ -16,6 +16,7 @@ import housekeepingRoutes from './routes/housekeeping'
|
|||||||
import channelsRoutes from './routes/channels'
|
import channelsRoutes from './routes/channels'
|
||||||
import usersRoutes from './routes/users'
|
import usersRoutes from './routes/users'
|
||||||
import netupRoutes from './routes/netup'
|
import netupRoutes from './routes/netup'
|
||||||
|
import guestsRoutes from './routes/guests'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -73,6 +74,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(channelsRoutes)
|
await fastify.register(channelsRoutes)
|
||||||
await fastify.register(usersRoutes)
|
await fastify.register(usersRoutes)
|
||||||
await fastify.register(netupRoutes)
|
await fastify.register(netupRoutes)
|
||||||
|
await fastify.register(guestsRoutes)
|
||||||
|
|
||||||
return fastify
|
return fastify
|
||||||
}
|
}
|
||||||
|
|||||||
244
backend/src/routes/guests.ts
Normal file
244
backend/src/routes/guests.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
|
import { db } from '../db'
|
||||||
|
|
||||||
|
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.email, g.phone,
|
||||||
|
g.passport, g.passport_series, g.passport_number,
|
||||||
|
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`
|
||||||
|
|
||||||
|
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 ────────────────────────────────────────────
|
||||||
|
fastify.get<SlugParam & { Querystring: { q?: 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 } = request.query as { q?: string }
|
||||||
|
const params: unknown[] = [hotelId]
|
||||||
|
|
||||||
|
let where = 'WHERE g.hotel_id = $1'
|
||||||
|
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 OR
|
||||||
|
COALESCE(g.passport_series,'') LIKE $2 OR COALESCE(g.passport_number,'') 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── 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 { ...guest, history }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── POST /api/hotels/:slug/guests ────────────────────────────────────────────
|
||||||
|
// Creates a new guest or returns existing one if passport matches
|
||||||
|
fastify.post<SlugParam & { Body: {
|
||||||
|
first_name: string; last_name: string
|
||||||
|
email?: string; phone?: string
|
||||||
|
passport?: string; passport_series?: string; passport_number?: 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
|
||||||
|
|
||||||
|
// If passport provided — try to find existing guest
|
||||||
|
if (b.passport_series && b.passport_number) {
|
||||||
|
const { rows: existing } = await db.query(
|
||||||
|
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`,
|
||||||
|
[hotelId, b.passport_series.trim(), b.passport_number.trim()],
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
email = COALESCE($5, email),
|
||||||
|
phone = COALESCE($6, 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.email || null, b.phone || null],
|
||||||
|
)
|
||||||
|
return reply.code(200).send(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: [guest] } = await db.query(
|
||||||
|
`INSERT INTO guests
|
||||||
|
(hotel_id, first_name, last_name, email, phone,
|
||||||
|
passport, passport_series, passport_number,
|
||||||
|
birth_date, nationality, gender, city, notes, tags, rating)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
hotelId, b.first_name, b.last_name,
|
||||||
|
b.email ?? null, b.phone ?? null,
|
||||||
|
b.passport ?? null, b.passport_series ?? null, b.passport_number ?? null,
|
||||||
|
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(guest)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PATCH /api/hotels/:slug/guests/:id ──────────────────────────────────────
|
||||||
|
fastify.patch<SlugIdParam & { Body: Partial<{
|
||||||
|
first_name: string; last_name: string
|
||||||
|
email: string; phone: string
|
||||||
|
passport: string; passport_series: string; passport_number: 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('email', b.email)
|
||||||
|
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('gender', b.gender)
|
||||||
|
add('city', b.city)
|
||||||
|
add('notes', b.notes)
|
||||||
|
add('tags', b.tags)
|
||||||
|
add('rating', b.rating)
|
||||||
|
|
||||||
|
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 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
|
||||||
@@ -266,6 +266,24 @@ export const api = {
|
|||||||
req<Hotel>('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)),
|
req<Hotel>('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Guests ────────────────────────────────────────────────────────────────
|
||||||
|
guests: {
|
||||||
|
list: (slug: string, q?: string) =>
|
||||||
|
req<GuestApiType[]>('GET', `/api/hotels/${slug}/guests${q ? `?q=${encodeURIComponent(q)}` : ''}`),
|
||||||
|
|
||||||
|
get: (slug: string, id: string) =>
|
||||||
|
req<GuestApiType>('GET', `/api/hotels/${slug}/guests/${id}`),
|
||||||
|
|
||||||
|
create: (slug: string, data: GuestPayload) =>
|
||||||
|
req<GuestApiType>('POST', `/api/hotels/${slug}/guests`, data),
|
||||||
|
|
||||||
|
update: (slug: string, id: string, data: Partial<GuestPayload>) =>
|
||||||
|
req<GuestApiType>('PATCH', `/api/hotels/${slug}/guests/${id}`, data),
|
||||||
|
|
||||||
|
delete: (slug: string, id: string) =>
|
||||||
|
req<void>('DELETE', `/api/hotels/${slug}/guests/${id}`),
|
||||||
|
},
|
||||||
|
|
||||||
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
||||||
netup: {
|
netup: {
|
||||||
getSettings: (slug: string) =>
|
getSettings: (slug: string) =>
|
||||||
@@ -373,6 +391,59 @@ export interface HotelPayload {
|
|||||||
checkInTime?: string; checkOutTime?: string
|
checkInTime?: string; checkOutTime?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuestApiType {
|
||||||
|
id: string
|
||||||
|
hotelId: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string | null
|
||||||
|
phone: string | null
|
||||||
|
passport: string | null
|
||||||
|
passportSeries: string | null
|
||||||
|
passportNumber: string | null
|
||||||
|
birthDate: string | null
|
||||||
|
nationality: string | null
|
||||||
|
gender: string | null
|
||||||
|
city: string | null
|
||||||
|
notes: string
|
||||||
|
tags: string[]
|
||||||
|
loyaltyTier: string
|
||||||
|
loyaltyPoints: number
|
||||||
|
rating: number
|
||||||
|
totalStays: number
|
||||||
|
totalSpent: number
|
||||||
|
lastVisit: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
history?: {
|
||||||
|
id: string
|
||||||
|
checkIn: string
|
||||||
|
checkOut: string
|
||||||
|
status: string
|
||||||
|
totalAmount: number | null
|
||||||
|
paidAmount: number
|
||||||
|
roomNumber: string | null
|
||||||
|
roomType: string | null
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestPayload {
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
passport?: string
|
||||||
|
passport_series?: string
|
||||||
|
passport_number?: string
|
||||||
|
birth_date?: string
|
||||||
|
nationality?: string
|
||||||
|
gender?: string
|
||||||
|
city?: string
|
||||||
|
notes?: string
|
||||||
|
tags?: string[]
|
||||||
|
rating?: number
|
||||||
|
}
|
||||||
|
|
||||||
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {}
|
const out: Record<string, unknown> = {}
|
||||||
if (h.name !== undefined) out.name = h.name
|
if (h.name !== undefined) out.name = h.name
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user