From 7ccf75242967a97835d5538c2ee6aec91f42dc2d Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 14 Apr 2026 12:13:07 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20photo=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 073: attachment_url column in chat_messages - upload.ts: add 'chat' folder to ALLOWED_FOLDERS - chat.ts: accept attachment_url in send message, return it in GET messages - api.ts: attachmentUrl in ChatMessage type, uploadImage() with raw FormData fetch - ChatWidget: paperclip button, file preview thumbnail with remove, image display inline in message bubbles (clickable, opens original), send enabled with image only Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/073_chat_attachments.sql | 1 + backend/src/routes/chat.ts | 14 +-- backend/src/routes/upload.ts | 2 +- src/components/chat/ChatWidget.tsx | 95 ++++++++++++++++++--- src/lib/api.ts | 18 +++- 5 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 backend/migrations/073_chat_attachments.sql diff --git a/backend/migrations/073_chat_attachments.sql b/backend/migrations/073_chat_attachments.sql new file mode 100644 index 0000000..c303880 --- /dev/null +++ b/backend/migrations/073_chat_attachments.sql @@ -0,0 +1 @@ +ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment_url TEXT; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 5b87e48..21f0c37 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -94,7 +94,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { 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, - m.is_system, m.system_name, + m.is_system, m.system_name, m.attachment_url, COALESCE(m.system_name, u.name) AS sender_name, COALESCE(u.role, 'system') AS sender_role FROM chat_messages m @@ -131,14 +131,14 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { if (roomRows[0]?.type === 'notifications') return reply.code(403).send({ error: 'Cannot post to notifications room' }) - const { text } = request.body - if (!text?.trim()) return reply.code(400).send({ error: 'Text required' }) + const { text, attachment_url } = request.body as { text?: string; attachment_url?: string } + 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) - VALUES ($1, $2, $3, $4) - RETURNING id, room_id, sender_id, text, created_at`, - [roomId, hotelId, request.user.sub, text.trim()], + `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`, + [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]) diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts index be6ca7f..fe95030 100644 --- a/backend/src/routes/upload.ts +++ b/backend/src/routes/upload.ts @@ -7,7 +7,7 @@ import { pipeline } from 'stream/promises' const UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads') const CDN_HOST = process.env.CDN_HOST ?? 'cdn.hotelsync.ru' -const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests', 'tasks'] as const +const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests', 'tasks', 'chat'] as const type UploadFolder = typeof ALLOWED_FOLDERS[number] const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index c61ca0f..626bd21 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { MessageSquare, X, ChevronLeft, Send, Users, Loader2, - Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, + Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip, } from 'lucide-react' import { api, type ChatRoom, type ChatMessage, type ChatSearchResult } from '../../lib/api' import type { User } from '../../types' @@ -85,6 +85,8 @@ export function ChatWidget() { const [loadingRooms, setLoadingRooms] = useState(false) const [loadingMsgs, setLoadingMsgs] = useState(false) const [sending, setSending] = useState(false) + const [attachment, setAttachment] = useState(null) + const [attachPreview, setAttachPreview] = useState(null) // Search state const [searchQuery, setSearchQuery] = useState('') @@ -100,6 +102,7 @@ export function ChatWidget() { const messagesEndRef = useRef(null) const pollRef = useRef | null>(null) const searchInputRef = useRef(null) + const fileInputRef = useRef(null) const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) @@ -233,16 +236,40 @@ export function ChatWidget() { } } + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setAttachment(file) + const reader = new FileReader() + reader.onload = ev => setAttachPreview(ev.target?.result as string) + reader.readAsDataURL(file) + e.target.value = '' + } + + const removeAttachment = () => { + setAttachment(null) + setAttachPreview(null) + } + const sendMessage = async () => { - if (!text.trim() || !activeRoom || sending) return + if (!text.trim() && !attachment || !activeRoom || sending) return const t = text.trim() + const file = attachment setText('') + setAttachment(null) + setAttachPreview(null) setSending(true) try { - const msg = await api.chat.sendMessage(slug, activeRoom.id, t) + let attachmentUrl: string | undefined + if (file) { + const { url } = await api.chat.uploadImage(file) + attachmentUrl = url + } + const msg = await api.chat.sendMessage(slug, activeRoom.id, t, attachmentUrl) setMessages(prev => [...prev, msg]) } catch { setText(t) + if (file) { setAttachment(file); setAttachPreview(attachPreview) } } finally { setSending(false) } @@ -251,7 +278,7 @@ export function ChatWidget() { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() - void sendMessage() + if (text.trim() || attachment) void sendMessage() } } @@ -560,14 +587,31 @@ export function ChatWidget() {

)}
- {msg.text} + {msg.attachmentUrl && ( + + вложение + + )} + {msg.text && ( +

{msg.text}

+ )}

{fmtTime(msg.createdAt)}

@@ -581,7 +625,34 @@ export function ChatWidget() { {/* Input — hidden for notifications room */} {activeRoom?.type !== 'notifications' && (
-
+ {/* Attachment preview */} + {attachPreview && ( +
+ превью + +
+ )} +
+ {/* Hidden file input */} + +