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
{msg.text}
+ )}{fmtTime(msg.createdAt)}
@@ -581,7 +625,34 @@ export function ChatWidget() { {/* Input — hidden for notifications room */} {activeRoom?.type !== 'notifications' && (Enter — отправить, Shift+Enter — перенос
+Enter — отправить · Shift+Enter — перенос