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:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4176
-144
No files matched your search
@@ -0,0 +1,130 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChatChannel" AS ENUM ('SUPPORT', 'CONCIERGE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChatStatus" AS ENUM ('OPEN', 'CLOSED');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "ConciergeAuthor" ADD VALUE 'BOT';
|
||||
ALTER TYPE "ConciergeAuthor" ADD VALUE 'SYSTEM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Client" ADD COLUMN "chatChannel" "ChatChannel";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ConciergeMessage" ADD COLUMN "deliveryError" TEXT,
|
||||
ADD COLUMN "fileKey" TEXT,
|
||||
ADD COLUMN "fileName" TEXT,
|
||||
ADD COLUMN "internal" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "mime" TEXT,
|
||||
ADD COLUMN "size" INTEGER,
|
||||
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'BOT',
|
||||
ADD COLUMN "tgBot" TEXT,
|
||||
ADD COLUMN "tgFileId" TEXT,
|
||||
ADD COLUMN "threadId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatThread" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channel" "ChatChannel" NOT NULL,
|
||||
"clientId" TEXT,
|
||||
"providerId" TEXT,
|
||||
"status" "ChatStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"assigneeId" TEXT,
|
||||
"botActive" BOOLEAN NOT NULL DEFAULT false,
|
||||
"lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"waitingSince" TIMESTAMP(3),
|
||||
"closedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ChatThread_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channel" "ChatChannel",
|
||||
"title" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ChatTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatThread_channel_status_lastMessageAt_idx" ON "ChatThread"("channel", "status", "lastMessageAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatThread_assigneeId_status_idx" ON "ChatThread"("assigneeId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ChatThread_clientId_channel_key" ON "ChatThread"("clientId", "channel");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ChatThread_providerId_channel_key" ON "ChatThread"("providerId", "channel");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConciergeMessage_threadId_createdAt_idx" ON "ConciergeMessage"("threadId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ChatThread"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatTemplate" ADD CONSTRAINT "ChatTemplate_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- ───── Перенос старой переписки: у каждого собеседника появляется диалог ─────
|
||||
-- клиент с активной подпиской → консьерж, остальные → поддержка; есть непрочитанные — диалог открыт
|
||||
INSERT INTO "ChatThread" ("id", "channel", "clientId", "status", "lastMessageAt", "waitingSince", "closedAt", "createdAt")
|
||||
SELECT
|
||||
'cht' || substr(md5('c:' || m."clientId"), 1, 22),
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM "Subscription" s
|
||||
WHERE s."clientId" = m."clientId" AND s."status" = 'ACTIVE' AND s."startsAt" <= now() AND s."endsAt" > now()
|
||||
) THEN 'CONCIERGE'::"ChatChannel" ELSE 'SUPPORT'::"ChatChannel" END,
|
||||
m."clientId",
|
||||
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN 'OPEN'::"ChatStatus" ELSE 'CLOSED'::"ChatStatus" END,
|
||||
max(m."createdAt"),
|
||||
min(m."createdAt") FILTER (WHERE m."author" <> 'OPERATOR' AND m."readAt" IS NULL),
|
||||
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN NULL ELSE max(m."createdAt") END,
|
||||
min(m."createdAt")
|
||||
FROM "ConciergeMessage" m
|
||||
WHERE m."clientId" IS NOT NULL
|
||||
GROUP BY m."clientId";
|
||||
|
||||
-- исполнители пишут в поддержку
|
||||
INSERT INTO "ChatThread" ("id", "channel", "providerId", "status", "lastMessageAt", "waitingSince", "closedAt", "createdAt")
|
||||
SELECT
|
||||
'cht' || substr(md5('p:' || m."providerId"), 1, 22),
|
||||
'SUPPORT'::"ChatChannel",
|
||||
m."providerId",
|
||||
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN 'OPEN'::"ChatStatus" ELSE 'CLOSED'::"ChatStatus" END,
|
||||
max(m."createdAt"),
|
||||
min(m."createdAt") FILTER (WHERE m."author" <> 'OPERATOR' AND m."readAt" IS NULL),
|
||||
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN NULL ELSE max(m."createdAt") END,
|
||||
min(m."createdAt")
|
||||
FROM "ConciergeMessage" m
|
||||
WHERE m."providerId" IS NOT NULL
|
||||
GROUP BY m."providerId";
|
||||
|
||||
UPDATE "ConciergeMessage" m SET "threadId" = t."id"
|
||||
FROM "ChatThread" t
|
||||
WHERE (m."clientId" IS NOT NULL AND t."clientId" = m."clientId") OR (m."providerId" IS NOT NULL AND t."providerId" = m."providerId");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user