feat: chat — edit/delete messages + emoji reactions

- Migration 074: edited_at, deleted_at columns + chat_reactions table
- Backend: PATCH edit (own only), DELETE soft-delete (own/admin), POST toggle reaction
  fetchMessage helper returns full message with aggregated reactions in one query
- API types: ChatReaction interface, editedAt/deletedAt/reactions in ChatMessage
- ChatWidget: hover toolbar (😊 react · ✏️ edit · 🗑️ delete), inline edit with
  save/cancel (Enter/Esc), reaction emoji picker (8 emojis), reaction pills below
  messages (click to toggle), '· изм.' label on edited messages, soft-deleted
  messages show 'Сообщение удалено' placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 12:31:17 +03:00
parent 7ccf752429
commit a29f58282f
4 changed files with 440 additions and 93 deletions

View File

@@ -0,0 +1,11 @@
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS chat_reactions (
message_id UUID NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji VARCHAR(12) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (message_id, user_id, emoji)
);
CREATE INDEX IF NOT EXISTS idx_chat_reactions_message ON chat_reactions(message_id);

View File

@@ -3,6 +3,7 @@ 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) => {
@@ -13,7 +14,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// Ensure a room of given type exists for hotel (idempotent)
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`,
@@ -26,7 +26,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
[hotelId, type, name],
)
if (rows[0]) return rows[0].id as string
// Concurrent insert — fetch again
const { rows: r2 } = await db.query(
`SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = $2 LIMIT 1`,
[hotelId, type],
@@ -34,10 +33,37 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
return r2[0]?.id as string
}
const ensureGeneralRoom = (hotelId: string) => ensureRoom(hotelId, 'general', 'Общий чат')
const ensureGeneralRoom = (hotelId: string) => ensureRoom(hotelId, 'general', 'Общий чат')
const ensureNotificationsRoom = (hotelId: string) => ensureRoom(hotelId, 'notifications', 'Уведомления')
// GET /api/hotels/:slug/chat/rooms — list rooms (general + notifications + directs for current user)
// 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<SlugParam>(
'/api/hotels/:slug/chat/rooms',
{ onRequest: [fastify.authenticate] },
@@ -55,17 +81,19 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
const { rows } = await db.query(
`SELECT r.id, r.type, r.name,
(SELECT COUNT(*) FROM chat_messages m
WHERE m.room_id = r.id
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 m.text FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
(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,
-- 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
@@ -80,8 +108,9 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
},
)
// GET /api/hotels/:slug/chat/rooms/:roomId/messages
fastify.get<RoomParam & { Querystring: { before?: string; limit?: string } }>(
// ── GET messages ──────────────────────────────────────────────────────────
fastify.get<RoomParam & { Querystring: { limit?: string } }>(
'/api/hotels/:slug/chat/rooms/:roomId/messages',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
@@ -92,31 +121,44 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const limit = Math.min(Number(request.query.limit ?? 50), 100)
const userId = request.user.sub
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.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(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
ORDER BY m.created_at DESC
LIMIT $3`,
[roomId, hotelId, limit],
LIMIT $4`,
[roomId, hotelId, userId, limit],
)
// Update read status
await db.query(
`INSERT INTO chat_read_status (room_id, user_id, last_read)
VALUES ($1, $2, NOW())
`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],
[roomId, userId],
)
return rows.reverse() // chronological order
return rows.reverse()
},
)
// POST /api/hotels/:slug/chat/rooms/:roomId/messages
fastify.post<RoomParam & { Body: { text: string } }>(
// ── POST message ──────────────────────────────────────────────────────────
fastify.post<RoomParam & { Body: { text?: string; attachment_url?: string } }>(
'/api/hotels/:slug/chat/rooms/:roomId/messages',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
@@ -126,28 +168,121 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
// Disallow posting to notifications room
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 as { text?: string; attachment_url?: string }
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, room_id, sender_id, text, created_at, attachment_url`,
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null],
)
const msg = rows[0]
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, is_system: false, system_name: null }
return reply.code(201).send(result)
const msg = await fetchMessage(rows[0].id, request.user.sub)
return reply.code(201).send(msg)
},
)
// POST /api/hotels/:slug/chat/direct/:otherUserId — create/get direct room
// ── PATCH message (edit) ──────────────────────────────────────────────────
fastify.patch<MsgParam & { Body: { text: string } }>(
'/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<MsgParam>(
'/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<MsgParam & { Body: { emoji: string } }>(
'/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] },
@@ -163,15 +298,13 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
`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`,
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],
`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)`,
@@ -181,7 +314,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
},
)
// PATCH /api/hotels/:slug/chat/rooms/:roomId/read — mark as read
// ── PATCH mark read ───────────────────────────────────────────────────────
fastify.patch<RoomParam>(
'/api/hotels/:slug/chat/rooms/:roomId/read',
{ onRequest: [fastify.authenticate] },
@@ -196,7 +330,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
},
)
// GET /api/hotels/:slug/chat/search?q= — search messages across accessible rooms
// ── GET search ────────────────────────────────────────────────────────────
fastify.get<SlugParam & { Querystring: { q?: string } }>(
'/api/hotels/:slug/chat/search',
{ onRequest: [fastify.authenticate] },
@@ -209,8 +344,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
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,
@@ -220,20 +355,19 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
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.text ILIKE $3
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`,
ORDER BY m.created_at DESC LIMIT 30`,
[hotelId, userId, `%${q}%`],
)
return rows
},
)
// POST /api/hotels/:slug/chat/notify — post a system notification (manager/admin only)
// ── POST notify ───────────────────────────────────────────────────────────
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
'/api/hotels/:slug/chat/notify',
{ onRequest: [fastify.authenticate] },
@@ -253,11 +387,11 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
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, room_id, text, created_at, is_system, system_name`,
VALUES ($1, $2, NULL, $3, true, $4) RETURNING id`,
[notifRoomId, hotelId, text.trim(), system_name],
)
return reply.code(201).send({ ...rows[0], sender_name: system_name, sender_role: 'system' })
const msg = await fetchMessage(rows[0].id, request.user.sub)
return reply.code(201).send(msg)
},
)
}