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 && (
+
+

+
+
+ )}
+
-
Enter — отправить, Shift+Enter — перенос
+
Enter — отправить · Shift+Enter — перенос
)}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 5361979..e8db626 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -335,8 +335,21 @@ export const api = {
req
('GET', `/api/hotels/${slug}/chat/rooms`),
getMessages: (slug: string, roomId: string, limit = 50) =>
req('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
- sendMessage: (slug: string, roomId: string, text: string) =>
- req('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text }),
+ sendMessage: (slug: string, roomId: string, text: string, attachmentUrl?: string) =>
+ req('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text, ...(attachmentUrl ? { attachment_url: attachmentUrl } : {}) }),
+ uploadImage: async (file: File): Promise<{ url: string }> => {
+ const form = new FormData()
+ form.append('file', file)
+ const token = getToken()
+ const res = await fetch(`${BASE}/api/upload?folder=chat`, {
+ method: 'POST',
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ credentials: 'include',
+ body: form,
+ })
+ if (!res.ok) throw new ApiError(res.status, 'Upload failed')
+ return res.json() as Promise<{ url: string }>
+ },
markRead: (slug: string, roomId: string) =>
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
openDirect: (slug: string, otherUserId: string) =>
@@ -1326,6 +1339,7 @@ export interface ChatMessage {
createdAt: string
isSystem: boolean
systemName: string | null
+ attachmentUrl: string | null
}
export interface ChatSearchResult {