fix: chat — VARCHAR(10) overflow for notifications type, skeleton loading
- Migration 072: widen type column to VARCHAR(20), add partial unique index for general rooms - Backend: replace brittle ON CONFLICT with explicit SELECT-then-INSERT ensureRoom helper - Frontend: show room skeletons (Общий чат + Уведомления) during load instead of spinner, hide '0 чатов' subtitle when list is empty Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
6
backend/migrations/072_chat_room_type_length.sql
Normal file
6
backend/migrations/072_chat_room_type_length.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Increase type column length to fit 'notifications'
|
||||
ALTER TABLE chat_rooms ALTER COLUMN type TYPE VARCHAR(20);
|
||||
|
||||
-- Partial unique index for general room per hotel (needed for ON CONFLICT)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chat_rooms_general_hotel
|
||||
ON chat_rooms(hotel_id) WHERE type = 'general';
|
||||
@@ -13,39 +13,29 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
// Ensure general room exists for hotel
|
||||
const ensureGeneralRoom = async (hotelId: string) => {
|
||||
// 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`,
|
||||
[hotelId, type],
|
||||
)
|
||||
if (existing[0]) return existing[0].id as string
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO chat_rooms (hotel_id, type, name)
|
||||
VALUES ($1, 'general', 'Общий чат')
|
||||
ON CONFLICT (hotel_id) WHERE type = 'general' DO NOTHING
|
||||
RETURNING id`,
|
||||
[hotelId],
|
||||
`INSERT INTO chat_rooms (hotel_id, type, name) VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING RETURNING id`,
|
||||
[hotelId, type, name],
|
||||
)
|
||||
if (rows[0]) return rows[0].id as string
|
||||
const { rows: existing } = await db.query(
|
||||
`SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = 'general'`,
|
||||
[hotelId],
|
||||
// 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],
|
||||
)
|
||||
return existing[0]?.id as string
|
||||
return r2[0]?.id as string
|
||||
}
|
||||
|
||||
// Ensure notifications room exists for hotel
|
||||
const ensureNotificationsRoom = async (hotelId: string) => {
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO chat_rooms (hotel_id, type, name)
|
||||
VALUES ($1, 'notifications', 'Уведомления')
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id`,
|
||||
[hotelId],
|
||||
)
|
||||
if (rows[0]) return rows[0].id as string
|
||||
const { rows: existing } = await db.query(
|
||||
`SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = 'notifications'`,
|
||||
[hotelId],
|
||||
)
|
||||
return existing[0]?.id as string
|
||||
}
|
||||
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)
|
||||
fastify.get<SlugParam>(
|
||||
|
||||
@@ -322,7 +322,7 @@ export function ChatWidget() {
|
||||
{view === 'search' && 'Поиск'}
|
||||
{view === 'settings' && 'Настройки чата'}
|
||||
</p>
|
||||
{view === 'rooms' && (
|
||||
{view === 'rooms' && visibleRooms.length > 0 && (
|
||||
<p className="text-xs text-white/70">{visibleRooms.length} чатов</p>
|
||||
)}
|
||||
{view === 'messages' && activeRoom?.type === 'notifications' && (
|
||||
@@ -348,10 +348,12 @@ export function ChatWidget() {
|
||||
{/* ── Rooms list ── */}
|
||||
{view === 'rooms' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingRooms ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 size={20} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
{loadingRooms && visibleRooms.length === 0 ? (
|
||||
// Skeleton placeholders while loading
|
||||
<>
|
||||
<RoomSkeleton icon={<Users size={16} className="text-brand-400" />} bg="bg-brand-100 dark:bg-brand-900/30" label="Общий чат" />
|
||||
{settings.notifVisible && <RoomSkeleton icon={<Bell size={16} className="text-amber-500" />} bg="bg-amber-100 dark:bg-amber-900/30" label="Уведомления" />}
|
||||
</>
|
||||
) : visibleRooms.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-slate-400 px-4">
|
||||
Нет чатов. Нажмите <PenSquare size={13} className="inline" /> чтобы начать.
|
||||
@@ -681,3 +683,17 @@ function ToggleRow({ icon, label, checked, onChange }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomSkeleton({ icon, bg, label }: { icon: React.ReactNode; bg: string; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700/50 animate-pulse">
|
||||
<div className={cn('w-9 h-9 rounded-full flex items-center justify-center shrink-0', bg)}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">{label}</p>
|
||||
<div className="h-3 w-24 bg-slate-200 dark:bg-slate-600 rounded mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user