fix: loyalty tier crash, chat duplicate rooms, sidebar defaults

- LoyaltyPage: levelBadge() now falls back to bronze for unknown tiers
  (DB had loyalty_tier='standard' from old seed data)
- migration 040: deduplicate general chat rooms + add unique index,
  normalize loyalty_tier to bronze for non-standard values
- chat.ts: ON CONFLICT clause now targets the new partial unique index
- Sidebar: DEFAULT_GROUPS only opens 'basics' by default; bump
  localStorage key to sidebarGroups_v2 to clear stale stored state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:58:47 +03:00
parent 15fec905e1
commit 5128957d7c
4 changed files with 32 additions and 11 deletions

View File

@@ -0,0 +1,20 @@
-- Fix 1: unique general chat room per hotel (prevent duplicates on every open)
-- First deduplicate: keep the oldest general room per hotel, delete the rest
DELETE FROM chat_rooms
WHERE type = 'general'
AND id NOT IN (
SELECT DISTINCT ON (hotel_id) id
FROM chat_rooms
WHERE type = 'general'
ORDER BY hotel_id, created_at ASC
);
-- Now add the unique constraint
CREATE UNIQUE INDEX IF NOT EXISTS uq_chat_rooms_general_per_hotel
ON chat_rooms (hotel_id)
WHERE type = 'general';
-- Fix 2: normalize loyalty_tier values that don't match our 4 tiers
UPDATE guests
SET loyalty_tier = 'bronze'
WHERE loyalty_tier NOT IN ('bronze', 'silver', 'gold', 'platinum');

View File

@@ -18,7 +18,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO chat_rooms (hotel_id, type, name) `INSERT INTO chat_rooms (hotel_id, type, name)
VALUES ($1, 'general', 'Общий чат') VALUES ($1, 'general', 'Общий чат')
ON CONFLICT DO NOTHING ON CONFLICT (hotel_id) WHERE type = 'general' DO NOTHING
RETURNING id`, RETURNING id`,
[hotelId], [hotelId],
) )

View File

@@ -33,16 +33,16 @@ type GroupKey = 'basics' | 'prices' | 'service' | 'management' | 'settingsGroup'
const DEFAULT_GROUPS: Record<GroupKey, boolean> = { const DEFAULT_GROUPS: Record<GroupKey, boolean> = {
basics: true, basics: true,
prices: true, prices: false,
service: true, service: false,
management: true, management: false,
settingsGroup: true, settingsGroup: false,
devGroup: true, devGroup: false,
} }
function loadGroups(): Record<GroupKey, boolean> { function loadGroups(): Record<GroupKey, boolean> {
try { try {
const stored = JSON.parse(localStorage.getItem('sidebarGroups') || '{}') const stored = JSON.parse(localStorage.getItem('sidebarGroups_v2') || '{}')
return { ...DEFAULT_GROUPS, ...stored } return { ...DEFAULT_GROUPS, ...stored }
} catch { } catch {
return { ...DEFAULT_GROUPS } return { ...DEFAULT_GROUPS }
@@ -50,7 +50,7 @@ function loadGroups(): Record<GroupKey, boolean> {
} }
function saveGroups(groups: Record<GroupKey, boolean>) { function saveGroups(groups: Record<GroupKey, boolean>) {
localStorage.setItem('sidebarGroups', JSON.stringify(groups)) localStorage.setItem('sidebarGroups_v2', JSON.stringify(groups))
} }
// ── NavItem with star ────────────────────────────────────────────────────── // ── NavItem with star ──────────────────────────────────────────────────────

View File

@@ -214,8 +214,9 @@ const MANUAL_REASONS = [
'Другое', 'Другое',
] ]
function levelBadge(levelId: LevelId) { function levelBadge(levelId: string) {
const level = DEFAULT_LEVELS.find(l => l.id === levelId)! const level = DEFAULT_LEVELS.find(l => l.id === levelId) ?? DEFAULT_LEVELS[0]
const safeId = (DEFAULT_LEVELS.find(l => l.id === levelId)?.id ?? 'bronze') as LevelId
const bgs: Record<LevelId, string> = { const bgs: Record<LevelId, string> = {
bronze: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', bronze: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
silver: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300', silver: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
@@ -223,7 +224,7 @@ function levelBadge(levelId: LevelId) {
platinum: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400', platinum: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400',
} }
return ( return (
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold', bgs[levelId])}> <span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold', bgs[safeId])}>
<level.icon size={10} /> <level.icon size={10} />
{level.name} {level.name}
</span> </span>