import { FastifyPluginAsync } from 'fastify' import { db } from '../db' type SlugParam = { Params: { slug: string } } type RoomParam = { Params: { slug: string; roomId: string } } type MsgParam = { Params: { slug: string; roomId: string; msgId: 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 const ensureRoom = async (hotelId: string, type: string, name: string) => { const { rows: existing } = await db.query( `SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = $2 LIMIT 1`, [hotelId, type], ) if (existing[0]) return existing[0].id as string const { rows } = await db.query( `INSERT INTO chat_rooms (hotel_id, type, name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING id`, [hotelId, type, name], ) if (rows[0]) return rows[0].id as string const { rows: r2 } = await db.query( `SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = $2 LIMIT 1`, [hotelId, type], ) return r2[0]?.id as string } const ensureGeneralRoom = (hotelId: string) => ensureRoom(hotelId, 'general', 'Общий чат') const ensureNotificationsRoom = (hotelId: string) => ensureRoom(hotelId, 'notifications', 'Уведомления') // Helper: fetch full message with reactions const fetchMessage = async (msgId: string, userId: string) => { const { rows } = await db.query( `SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at, m.is_system, m.system_name, m.attachment_url, m.edited_at, m.deleted_at, COALESCE(m.system_name, u.name) AS sender_name, COALESCE(u.role, 'system') AS sender_role, COALESCE( (SELECT json_agg(r ORDER BY r.first_at) FROM (SELECT emoji, COUNT(*)::int AS count, bool_or(user_id = $2) AS has_own, MIN(created_at) AS first_at FROM chat_reactions WHERE message_id = m.id GROUP BY emoji) r ), '[]'::json ) AS reactions FROM chat_messages m LEFT JOIN users u ON u.id = m.sender_id WHERE m.id = $1`, [msgId, userId], ) return rows[0] } // ── GET rooms ───────────────────────────────────────────────────────────── fastify.get( '/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' }) await ensureGeneralRoom(hotelId) await ensureNotificationsRoom(hotelId) const userId = request.user.sub 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.deleted_at IS NULL 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 OR m.sender_id IS NULL) ) AS unread_count, (SELECT CASE WHEN m.deleted_at IS NOT NULL THEN 'Сообщение удалено' WHEN m.attachment_url IS NOT NULL AND m.text = '' THEN '📷 Фото' ELSE m.text END 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 COALESCE(m.system_name, u.name) FROM chat_messages m LEFT 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, (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 u.role 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_role, (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, (SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id != $2 LIMIT 1) AS other_user_last_read FROM chat_rooms r WHERE r.hotel_id = $1 AND (r.type IN ('general', 'notifications') 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], ) return rows }, ) // ── GET messages ────────────────────────────────────────────────────────── fastify.get( '/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 before = request.query.before // ISO timestamp cursor const userId = request.user.sub const params: unknown[] = [roomId, hotelId, userId, limit] const beforeClause = before ? `AND m.created_at < $5` : '' if (before) params.push(before) const { rows } = await db.query( `SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at, m.is_system, m.system_name, m.attachment_url, m.edited_at, m.deleted_at, COALESCE(m.system_name, u.name) AS sender_name, COALESCE(u.role, 'system') AS sender_role, COALESCE( (SELECT json_agg(r ORDER BY r.first_at) FROM (SELECT emoji, COUNT(*)::int AS count, bool_or(user_id = $3) AS has_own, MIN(created_at) AS first_at FROM chat_reactions WHERE message_id = m.id GROUP BY emoji) r ), '[]'::json ) AS reactions FROM chat_messages m LEFT JOIN users u ON u.id = m.sender_id WHERE m.room_id = $1 AND m.hotel_id = $2 ${beforeClause} ORDER BY m.created_at DESC LIMIT $4`, params, ) // Only mark read on initial load (no before cursor) if (!before) { 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, userId], ) } return rows.reverse() }, ) // ── POST message ────────────────────────────────────────────────────────── fastify.post( '/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 { rows: roomRows } = await db.query('SELECT type FROM chat_rooms WHERE id = $1', [roomId]) if (roomRows[0]?.type === 'notifications') return reply.code(403).send({ error: 'Cannot post to notifications room' }) const { text, attachment_url } = request.body if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' }) const { rows } = await db.query( `INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null], ) const msg = await fetchMessage(rows[0].id, request.user.sub) return reply.code(201).send(msg) }, ) // ── PATCH message (edit) ────────────────────────────────────────────────── fastify.patch( '/api/hotels/:slug/chat/rooms/:roomId/messages/:msgId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, msgId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) const { rows } = await db.query( `SELECT sender_id, is_system, deleted_at FROM chat_messages WHERE id = $1`, [msgId], ) const msg = rows[0] if (!msg) return reply.code(404).send({ error: 'Message not found' }) if (msg.is_system) return reply.code(403).send({ error: 'Cannot edit system message' }) if (msg.deleted_at) return reply.code(400).send({ error: 'Message is deleted' }) if (msg.sender_id !== request.user.sub) return reply.code(403).send({ error: 'Can only edit own messages' }) const { text } = request.body if (!text?.trim()) return reply.code(400).send({ error: 'Text required' }) await db.query( `UPDATE chat_messages SET text = $1, edited_at = NOW() WHERE id = $2`, [text.trim(), msgId], ) const updated = await fetchMessage(msgId, request.user.sub) return updated }, ) // ── DELETE message (soft) ───────────────────────────────────────────────── fastify.delete( '/api/hotels/:slug/chat/rooms/:roomId/messages/:msgId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, msgId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) const { rows } = await db.query( `SELECT sender_id, is_system FROM chat_messages WHERE id = $1`, [msgId], ) const msg = rows[0] if (!msg) return reply.code(404).send({ error: 'Message not found' }) if (msg.is_system) return reply.code(403).send({ error: 'Cannot delete system message' }) const isAdmin = ['super_admin', 'hotel_admin', 'manager'].includes(request.user.role) if (msg.sender_id !== request.user.sub && !isAdmin) return reply.code(403).send({ error: 'Can only delete own messages' }) await db.query( `UPDATE chat_messages SET deleted_at = NOW(), text = '', attachment_url = NULL WHERE id = $1`, [msgId], ) return { ok: true, id: msgId } }, ) // ── POST reaction (toggle) ──────────────────────────────────────────────── fastify.post( '/api/hotels/:slug/chat/rooms/:roomId/messages/:msgId/reactions', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, msgId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) const { emoji } = request.body if (!emoji) return reply.code(400).send({ error: 'Emoji required' }) const userId = request.user.sub // Toggle: if exists — remove, else — add const { rows } = await db.query( `SELECT 1 FROM chat_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3`, [msgId, userId, emoji], ) if (rows[0]) { await db.query( `DELETE FROM chat_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3`, [msgId, userId, emoji], ) } else { await db.query( `INSERT INTO chat_reactions (message_id, user_id, emoji) VALUES ($1, $2, $3)`, [msgId, userId, emoji], ) } const updated = await fetchMessage(msgId, userId) return updated }, ) // ── POST 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 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 } 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 mark read ─────────────────────────────────────────────────────── fastify.patch( '/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 } }, ) // ── GET search ──────────────────────────────────────────────────────────── fastify.get( '/api/hotels/:slug/chat/search', { 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.q ?? '').trim() if (!q) return [] const userId = request.user.sub const { rows } = await db.query( `SELECT m.id, m.room_id, m.text, m.created_at, COALESCE(m.system_name, u.name) AS sender_name, r.type AS room_type, r.name AS room_name, (SELECT u2.name FROM chat_room_members crm2 JOIN users u2 ON u2.id = crm2.user_id WHERE crm2.room_id = r.id AND crm2.user_id != $2 LIMIT 1) AS other_user_name FROM chat_messages m JOIN chat_rooms r ON r.id = m.room_id LEFT JOIN users u ON u.id = m.sender_id WHERE m.hotel_id = $1 AND m.deleted_at IS NULL AND m.text ILIKE $3 AND (r.type IN ('general', 'notifications') OR EXISTS ( SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2 )) ORDER BY m.created_at DESC LIMIT 30`, [hotelId, userId, `%${q}%`], ) return rows }, ) // ── POST presence (heartbeat) ───────────────────────────────────────────── fastify.post( '/api/hotels/:slug/chat/presence', { 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' }) await db.query( `INSERT INTO chat_presence (user_id, hotel_id, last_seen) VALUES ($1, $2, NOW()) ON CONFLICT (user_id, hotel_id) DO UPDATE SET last_seen = NOW()`, [request.user.sub, hotelId], ) return { ok: true } }, ) // ── GET online users ────────────────────────────────────────────────────── fastify.get( '/api/hotels/:slug/chat/presence', { 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 user_id FROM chat_presence WHERE hotel_id = $1 AND last_seen > NOW() - INTERVAL '90 seconds'`, [hotelId], ) return rows.map((r: { user_id: string }) => r.user_id) }, ) // ── POST typing ─────────────────────────────────────────────────────────── fastify.post( '/api/hotels/:slug/chat/rooms/:roomId/typing', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { roomId } = request.params const { typing } = request.body const { rows: uRows } = await db.query('SELECT name FROM users WHERE id = $1', [request.user.sub]) const name = uRows[0]?.name ?? 'Кто-то' if (typing) { await db.query( `INSERT INTO chat_typing (user_id, room_id, name, typing_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT (user_id, room_id) DO UPDATE SET typing_at = NOW(), name = $3`, [request.user.sub, roomId, name], ) } else { await db.query('DELETE FROM chat_typing WHERE user_id = $1 AND room_id = $2', [request.user.sub, roomId]) } return { ok: true } }, ) // ── GET typing ──────────────────────────────────────────────────────────── fastify.get( '/api/hotels/:slug/chat/rooms/:roomId/typing', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { roomId } = request.params const userId = request.user.sub const { rows } = await db.query( `SELECT name FROM chat_typing WHERE room_id = $1 AND user_id != $2 AND typing_at > NOW() - INTERVAL '5 seconds'`, [roomId, userId], ) return rows.map((r: { name: string }) => r.name) }, ) // ── POST notify ─────────────────────────────────────────────────────────── fastify.post( '/api/hotels/:slug/chat/notify', { 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' }) if (!['super_admin', 'hotel_admin', 'manager'].includes(request.user.role)) return reply.code(403).send({ error: 'Insufficient permissions' }) const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { text, system_name = 'Система' } = request.body if (!text?.trim()) return reply.code(400).send({ error: 'Text required' }) const notifRoomId = await ensureNotificationsRoom(hotelId) const { rows } = await db.query( `INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, is_system, system_name) VALUES ($1, $2, NULL, $3, true, $4) RETURNING id`, [notifRoomId, hotelId, text.trim(), system_name], ) const msg = await fetchMessage(rows[0].id, request.user.sub) return reply.code(201).send(msg) }, ) } export default chatRoutes