Backoffice stage 2: chats (support + concierge, files)

- ChatThread per client/provider and channel; messages get files, internal notes,
  source (bot/mini app) and delivery errors; reply templates. Migration moves old
  concierge messages into threads.
- Routing: subscribers write to the concierge, others to support; /support and
  /concierge in the client bot; staff can take, transfer, close and move threads.
- Telegram: photos/documents from clients and providers, replies with the channel
  header, files sent from storage, notifications only to the assignee or the
  channel's staff, "Reply" into a specific thread.
- New admin: /chats with list, conversation, notes, files, templates, client panel;
  "Message" button on the client card; dashboard counts threads awaiting reply.
- Client mini app: in-app chat screen sharing the same conversation.
- Old admin keeps working on the same data (notes hidden there).
- Tests for routing, notes, assign/close/move, lists, recipients; separate test DB
  via TEST_DB_NAME; dev:chats-demo script.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:50:37 +04:00
1 parent d452824c62
commit 23ffb2bf1d
46 files changed
+4176 -144

No files matched your search

+85 -2
View File
@@ -226,6 +226,10 @@ model Client {
leads Lead[]
requests ClientRequest[]
concierge ConciergeMessage[]
chatThreads ChatThread[]
/// куда сейчас пишет клиент: SUPPORT или CONCIERGE (переключается командой бота или командой поддержки);
/// null — по умолчанию: подписчик → консьерж, остальные → поддержка
chatChannel ChatChannel?
@@index([agencyId])
}
@@ -298,6 +302,7 @@ model Provider {
@@index([role, active])
concierge ConciergeMessage[]
chatThreads ChatThread[]
}
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts)
@@ -855,6 +860,8 @@ model AdminUser {
audit AuditLog[]
loginRequests AdminLoginRequest[]
segments SavedSegment[]
chatThreads ChatThread[]
chatTemplates ChatTemplate[]
}
/// Вход в новую админку через Telegram: страница показывает ссылку t.me/<рабочий бот>?start=lg-<code>,
@@ -1110,19 +1117,41 @@ enum ConciergeAuthor {
CLIENT
PROVIDER
OPERATOR
/// ИИ-консьерж (на будущее): отвечает сам, человек может перехватить диалог
BOT
/// служебная строка в ленте: «взял диалог», «передал», «закрыл»
SYSTEM
}
/// Переписка из админки: с клиентом (клиентский бот) или с исполнителем (рабочий бот); ответ приходит в тот же чат с ботом
/// Сообщение в чате. Клиент пишет боту или в мини-аппе — всё попадает в один диалог (ChatThread).
/// Ответ команды приходит клиенту в чат с ботом (клиентский бот, для исполнителя — рабочий).
model ConciergeMessage {
id String @id @default(cuid())
/// диалог; у старых сообщений до этапа 2 проставлен миграцией
threadId String?
thread ChatThread? @relation(fields: [threadId], references: [id], onDelete: Cascade)
clientId String?
client Client? @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
author ConciergeAuthor
/// кто из операторов ответил
/// кто из команды написал (author = OPERATOR / SYSTEM)
adminUserId String?
text String
/// внутренняя заметка команды — клиент её не видит
internal Boolean @default(false)
/// откуда пришло: BOT | MINIAPP | ADMIN
source String @default("BOT")
/// файл: ключ в приватном хранилище (загрузила команда / мини-апп)…
fileKey String?
/// …или file_id в Telegram (прислал клиент боту); бот — client | pro
tgFileId String?
tgBot String?
fileName String?
mime String?
size Int?
/// не удалось доставить в Telegram (клиент заблокировал бота и т.п.)
deliveryError String?
/// оператор прочитал сообщение клиента
readAt DateTime?
createdAt DateTime @default(now())
@@ -1130,4 +1159,58 @@ model ConciergeMessage {
@@index([clientId, createdAt])
@@index([providerId, createdAt])
@@index([author, readAt])
@@index([threadId, createdAt])
}
enum ChatChannel {
/// поддержка — все клиенты и исполнители
SUPPORT
/// консьерж — только подписчики Barev+ / Elite
CONCIERGE
}
enum ChatStatus {
OPEN
CLOSED
}
/// Диалог: один на собеседника в каждом канале. Закрытый диалог открывается заново, когда клиент пишет снова.
model ChatThread {
id String @id @default(cuid())
channel ChatChannel
clientId String?
client Client? @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
status ChatStatus @default(OPEN)
/// кто из команды ведёт диалог
assigneeId String?
assignee AdminUser? @relation(fields: [assigneeId], references: [id])
/// диалог ведёт ИИ-консьерж (задел на будущее); человек может перехватить
botActive Boolean @default(false)
lastMessageAt DateTime @default(now())
/// с какого момента клиент ждёт ответа; null — ответили
waitingSince DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
messages ConciergeMessage[]
@@unique([clientId, channel])
@@unique([providerId, channel])
@@index([channel, status, lastMessageAt])
@@index([assigneeId, status])
}
/// Шаблон быстрого ответа в чате. {name} подставляется именем клиента.
model ChatTemplate {
id String @id @default(cuid())
/// для какого канала; null — для обоих
channel ChatChannel?
title String
text String
createdById String?
createdBy AdminUser? @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}