feat: wire up loyalty + chat — register routes, API types, connect LoyaltyPage, add ChatWidget to layout

This commit is contained in:
2026-03-26 10:15:29 +03:00
parent 8ede920e5d
commit 113971f854
9 changed files with 964 additions and 63 deletions

190
backend/src/routes/chat.ts Normal file
View File

@@ -0,0 +1,190 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type RoomParam = { Params: { slug: string; roomId: string } }
const chatRoutes: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string) => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id as string | undefined
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// Ensure general room exists for hotel
const ensureGeneralRoom = async (hotelId: string) => {
const { rows } = await db.query(
`INSERT INTO chat_rooms (hotel_id, type, name)
VALUES ($1, 'general', 'Общий чат')
ON CONFLICT DO NOTHING
RETURNING id`,
[hotelId],
)
if (rows[0]) return rows[0].id as string
const { rows: existing } = await db.query(
`SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = 'general'`,
[hotelId],
)
return existing[0]?.id as string
}
// GET /api/hotels/:slug/chat/rooms — list rooms (general + directs for current user)
fastify.get<SlugParam>(
'/api/hotels/:slug/chat/rooms',
{ 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 generalRoomId = await ensureGeneralRoom(hotelId)
const userId = request.user.sub
// Get all rooms this user belongs to (general + directs)
const { rows } = await db.query(
`SELECT r.id, r.type, r.name,
(SELECT COUNT(*) FROM chat_messages m
WHERE m.room_id = r.id
AND m.created_at > COALESCE(
(SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id = $2),
'1970-01-01'
)
AND m.sender_id != $2
) AS unread_count,
(SELECT m.text FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
(SELECT m.created_at FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
(SELECT u.name FROM chat_messages m JOIN users u ON u.id = m.sender_id WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_sender,
-- for direct rooms: get the other user's name
(SELECT u.name FROM chat_room_members crm JOIN users u ON u.id = crm.user_id WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_name,
(SELECT crm.user_id FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_id
FROM chat_rooms r
WHERE r.hotel_id = $1
AND (r.type = 'general' OR EXISTS (
SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
))
ORDER BY last_message_at DESC NULLS LAST, r.type = 'general' DESC`,
[hotelId, userId],
)
void generalRoomId
return rows
},
)
// GET /api/hotels/:slug/chat/rooms/:roomId/messages
fastify.get<RoomParam & { Querystring: { before?: string; limit?: string } }>(
'/api/hotels/:slug/chat/rooms/:roomId/messages',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, roomId } = 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 limit = Math.min(Number(request.query.limit ?? 50), 100)
const { rows } = await db.query(
`SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
u.name AS sender_name, u.role AS sender_role
FROM chat_messages m
JOIN users u ON u.id = m.sender_id
WHERE m.room_id = $1 AND m.hotel_id = $2
ORDER BY m.created_at DESC
LIMIT $3`,
[roomId, hotelId, limit],
)
// Update read status
await db.query(
`INSERT INTO chat_read_status (room_id, user_id, last_read)
VALUES ($1, $2, NOW())
ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`,
[roomId, request.user.sub],
)
return rows.reverse() // chronological order
},
)
// POST /api/hotels/:slug/chat/rooms/:roomId/messages
fastify.post<RoomParam & { Body: { text: string } }>(
'/api/hotels/:slug/chat/rooms/:roomId/messages',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, roomId } = 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 { text } = request.body
if (!text?.trim()) return reply.code(400).send({ error: 'Text required' })
const { rows } = await db.query(
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text)
VALUES ($1, $2, $3, $4)
RETURNING id, room_id, sender_id, text, created_at`,
[roomId, hotelId, request.user.sub, text.trim()],
)
const msg = rows[0]
// Get sender name
const { rows: uRows } = await db.query('SELECT name, role FROM users WHERE id = $1', [request.user.sub])
const result = { ...msg, sender_name: uRows[0]?.name, sender_role: uRows[0]?.role }
return reply.code(201).send(result)
},
)
// POST /api/hotels/:slug/chat/direct/:otherUserId — create/get direct room
fastify.post<{ Params: { slug: string; otherUserId: string } }>(
'/api/hotels/:slug/chat/direct/:otherUserId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, otherUserId } = 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 userId = request.user.sub
// Check if direct room already exists between these two users
const { rows: existing } = await db.query(
`SELECT r.id FROM chat_rooms r
JOIN chat_room_members m1 ON m1.room_id = r.id AND m1.user_id = $1
JOIN chat_room_members m2 ON m2.room_id = r.id AND m2.user_id = $2
WHERE r.hotel_id = $3 AND r.type = 'direct'
LIMIT 1`,
[userId, otherUserId, hotelId],
)
if (existing[0]) return { room_id: existing[0].id }
// Create new direct room
const { rows: [room] } = await db.query(
`INSERT INTO chat_rooms (hotel_id, type) VALUES ($1, 'direct') RETURNING id`,
[hotelId],
)
await db.query(
`INSERT INTO chat_room_members (room_id, user_id) VALUES ($1, $2), ($1, $3)`,
[room.id, userId, otherUserId],
)
return reply.code(201).send({ room_id: room.id })
},
)
// PATCH /api/hotels/:slug/chat/rooms/:roomId/read — mark as read
fastify.patch<RoomParam>(
'/api/hotels/:slug/chat/rooms/:roomId/read',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { roomId } = request.params
await db.query(
`INSERT INTO chat_read_status (room_id, user_id, last_read) VALUES ($1, $2, NOW())
ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`,
[roomId, request.user.sub],
)
return { ok: true }
},
)
}
export default chatRoutes

View File

@@ -0,0 +1,271 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugGuestParam = { Params: { slug: string; guestId: string } }
interface LoyaltySettingsRow {
is_active: boolean
points_per_ruble: string
point_value: string
expiry_months: number
}
interface LoyaltySettingsBody {
isActive?: boolean
pointsPerRuble?: number
pointValue?: number
expiryMonths?: number
}
interface AddPointsBody {
amount: number
reason: string
notes?: string
}
const DEFAULT_SETTINGS: LoyaltySettingsRow = {
is_active: true,
points_per_ruble: '0.1',
point_value: '0.01',
expiry_months: 12,
}
function calcTier(points: number): string {
if (points >= 15000) return 'platinum'
if (points >= 5000) return 'gold'
if (points >= 1000) return 'silver'
return 'bronze'
}
function formatSettings(row: LoyaltySettingsRow) {
return {
isActive: row.is_active,
pointsPerRuble: parseFloat(row.points_per_ruble),
pointValue: parseFloat(row.point_value),
expiryMonths: row.expiry_months,
}
}
const loyaltyRoutes: 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/loyalty/settings
fastify.get<SlugParam>(
'/api/hotels/:slug/loyalty/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<LoyaltySettingsRow>(
'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1',
[hotelId],
)
return formatSettings(rows[0] ?? DEFAULT_SETTINGS)
},
)
// PATCH /api/hotels/:slug/loyalty/settings
fastify.patch<SlugParam & { Body: LoyaltySettingsBody }>(
'/api/hotels/:slug/loyalty/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 { isActive, pointsPerRuble, pointValue, expiryMonths } = request.body
// Fetch existing first
const { rows: existing } = await db.query<LoyaltySettingsRow>(
'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1',
[hotelId],
)
const cur = existing[0] ?? DEFAULT_SETTINGS
const newIsActive = isActive !== undefined ? isActive : cur.is_active
const newPointsPerRuble = pointsPerRuble !== undefined ? pointsPerRuble : parseFloat(cur.points_per_ruble)
const newPointValue = pointValue !== undefined ? pointValue : parseFloat(cur.point_value)
const newExpiryMonths = expiryMonths !== undefined ? expiryMonths : cur.expiry_months
await db.query(
`INSERT INTO loyalty_settings (hotel_id, is_active, points_per_ruble, point_value, expiry_months, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (hotel_id) DO UPDATE SET
is_active = EXCLUDED.is_active,
points_per_ruble = EXCLUDED.points_per_ruble,
point_value = EXCLUDED.point_value,
expiry_months = EXCLUDED.expiry_months,
updated_at = NOW()`,
[hotelId, newIsActive, newPointsPerRuble, newPointValue, newExpiryMonths],
)
const { rows } = await db.query<LoyaltySettingsRow>(
'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1',
[hotelId],
)
return formatSettings(rows[0])
},
)
// GET /api/hotels/:slug/loyalty/guests
fastify.get<SlugParam & { Querystring: { q?: string } }>(
'/api/hotels/:slug/loyalty/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
let query: string
let params: unknown[]
if (q && q.trim()) {
query = `
SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent
FROM guests
WHERE hotel_id = $1
AND (
lower(first_name || ' ' || last_name) LIKE lower($2)
OR lower(email) LIKE lower($2)
OR regexp_replace(phone, '[^0-9]', '', 'g') LIKE $3
)
ORDER BY loyalty_points DESC
LIMIT 100`
const likeQ = `%${q.trim()}%`
const digitsQ = `%${q.replace(/\D/g, '')}%`
params = [hotelId, likeQ, digitsQ]
} else {
query = `
SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent
FROM guests
WHERE hotel_id = $1
ORDER BY loyalty_points DESC
LIMIT 100`
params = [hotelId]
}
const { rows } = await db.query(query, params)
return rows.map((g: Record<string, unknown>) => ({
id: g.id,
name: [g.last_name, g.first_name, g.middle_name].filter(Boolean).join(' '),
email: g.email ?? null,
phone: g.phone ?? null,
loyaltyPoints: Number(g.loyalty_points) || 0,
loyaltyTier: g.loyalty_tier ?? 'bronze',
totalSpent: parseFloat(String(g.total_spent)) || 0,
}))
},
)
// POST /api/hotels/:slug/loyalty/guests/:guestId/points
fastify.post<SlugGuestParam & { Body: AddPointsBody }>(
'/api/hotels/:slug/loyalty/guests/:guestId/points',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, guestId } = 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 { amount, reason, notes } = request.body
if (typeof amount !== 'number' || !reason)
return reply.code(400).send({ error: 'amount and reason are required' })
// Get current guest
const { rows: gRows } = await db.query(
'SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent FROM guests WHERE id = $1 AND hotel_id = $2',
[guestId, hotelId],
)
if (!gRows[0]) return reply.code(404).send({ error: 'Guest not found' })
const guest = gRows[0]
const newPoints = Math.max(0, (Number(guest.loyalty_points) || 0) + amount)
const newTier = calcTier(newPoints)
await db.query(
'UPDATE guests SET loyalty_points = $1, loyalty_tier = $2 WHERE id = $3',
[newPoints, newTier, guestId],
)
await db.query(
`INSERT INTO loyalty_transactions (hotel_id, guest_id, amount, reason, notes, staff_id)
VALUES ($1, $2, $3, $4, $5, $6)`,
[hotelId, guestId, amount, reason, notes ?? null, request.user.userId ?? null],
)
return {
id: guest.id,
name: [guest.last_name, guest.first_name, guest.middle_name].filter(Boolean).join(' '),
email: guest.email ?? null,
phone: guest.phone ?? null,
loyaltyPoints: newPoints,
loyaltyTier: newTier,
totalSpent: parseFloat(String(guest.total_spent)) || 0,
}
},
)
// GET /api/hotels/:slug/loyalty/transactions
fastify.get<SlugParam & { Querystring: { limit?: string } }>(
'/api/hotels/:slug/loyalty/transactions',
{ 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 limit = Math.min(parseInt(request.query.limit ?? '50') || 50, 200)
const { rows } = await db.query(
`SELECT
lt.id,
lt.guest_id,
(g.last_name || ' ' || g.first_name) AS guest_name,
lt.amount,
lt.reason,
lt.notes,
u.name AS staff_name,
lt.created_at
FROM loyalty_transactions lt
JOIN guests g ON g.id = lt.guest_id
LEFT JOIN users u ON u.id = lt.staff_id
WHERE lt.hotel_id = $1
ORDER BY lt.created_at DESC
LIMIT $2`,
[hotelId, limit],
)
return rows.map((r: Record<string, unknown>) => ({
id: r.id,
guestId: r.guest_id,
guestName: r.guest_name,
amount: Number(r.amount),
reason: r.reason,
notes: r.notes ?? null,
staffName: r.staff_name ?? null,
createdAt: r.created_at,
}))
},
)
}
export default loyaltyRoutes