feat: wire up loyalty + chat — register routes, API types, connect LoyaltyPage, add ChatWidget to layout
This commit is contained in:
190
backend/src/routes/chat.ts
Normal file
190
backend/src/routes/chat.ts
Normal 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
|
||||
Reference in New Issue
Block a user