feat: chat — photo attachments

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 12:13:07 +03:00
parent d0dbf33701
commit 7ccf752429
5 changed files with 108 additions and 22 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment_url TEXT;

View File

@@ -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])

View File

@@ -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']