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

@@ -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");
+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
}
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "The provider hasn’t opened the work bot via the invite yet — nowhere to send messages.",
"CARD_EXPIRED": "The QR is out of date or invalid — ask the client to reopen the card.",
"OFFER_NOT_AVAILABLE": "This offer isn’t available at the client’s level.",
"OFFER_LIMIT_REACHED": "The limit for this offer is already used up."
"OFFER_LIMIT_REACHED": "The limit for this offer is already used up.",
"CONCIERGE_SUBSCRIBERS_ONLY": "The personal concierge is for Barev+ and Barev Elite members. For now, write to support — we will help too.",
"ASSIGNEE_NO_ACCESS": "This team member has no access to this channel."
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "already used",
"used_WEEK": "again next week",
"used_MONTH": "again next month"
},
"chat": {
"title": "Chat with Barev Club",
"support": "Support",
"concierge": "Personal concierge",
"hint": "This is the same conversation as in the bot chat: replies arrive here and in Telegram.",
"empty": "Tell us how we can help — we’ll reply here.",
"placeholder": "Message…",
"send": "Send",
"attach": "Attach a photo or file",
"conciergeOnly": "The personal concierge comes with Barev+ and Barev Elite",
"team": "Barev Club",
"fileTooBig": "The file is larger than 10 MB",
"file": "File"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Help",
"webRequestOrdered": "Request No. {{request}} received and booked as order No. {{order}} for {{date}}. Track the status in the app.",
"webRequestLinked": "Request No. {{request}} received. An operator will contact you to confirm the time.",
"conciergeReceived": "Passed to the concierge — we'll reply here, in this chat."
"conciergeReceived": "Passed to the concierge — we'll reply here, in this chat.",
"supportReceived": "Passed to support — we'll reply here, in this chat.",
"channelSupport": "You are now writing to support. Just send a message, photo or file.",
"channelConcierge": "You are now writing to your personal concierge. Just send a message, photo or file.",
"cmdSupport": "Write to support",
"cmdConcierge": "Write to the concierge (Barev+)"
},
"pro": {
"notLinked": "This bot is for Barev Club specialists. Ask the operator for an invite link.",
@@ -844,7 +865,11 @@
"newRequest": "App request #{{number}}",
"newWebRequest": "Site request #{{number}}",
"loginOk": "Sign-in confirmed — go back to your browser.",
"loginExpired": "The sign-in link has expired — reload the admin page."
"loginExpired": "The sign-in link has expired — reload the admin page.",
"channelSupport": "Support",
"channelConcierge": "Concierge",
"file": "file",
"chatAssigned": "{{by}} passed you a conversation: {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, card is valid",
@@ -948,7 +973,8 @@
"openPlan": "Open the app",
"concierge": "Barev Club concierge",
"subscriptionActivated": "{{plan}} is on until {{date}}. Discounts are already in service prices.",
"operator": "Barev Club operator"
"operator": "Barev Club operator",
"support": "Barev Club support"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "El profesional aún no ha abierto el bot con la invitación: no hay dónde escribirle.",
"CARD_EXPIRED": "El QR caducó o no es válido: pide al cliente que vuelva a abrir la tarjeta.",
"OFFER_NOT_AVAILABLE": "Esta oferta no está disponible en el nivel del cliente.",
"OFFER_LIMIT_REACHED": "Ya se agotó el límite de esta oferta."
"OFFER_LIMIT_REACHED": "Ya se agotó el límite de esta oferta.",
"CONCIERGE_SUBSCRIBERS_ONLY": "El conserje personal es para miembros de Barev+ y Barev Elite. De momento, escribe a soporte: también te ayudaremos.",
"ASSIGNEE_NO_ACCESS": "Esta persona del equipo no tiene acceso a este canal."
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "ya usado",
"used_WEEK": "de nuevo la próxima semana",
"used_MONTH": "de nuevo el próximo mes"
},
"chat": {
"title": "Chat con Barev Club",
"support": "Soporte",
"concierge": "Conserje personal",
"hint": "Es la misma conversación que en el chat del bot: la respuesta llegará aquí y a Telegram.",
"empty": "Cuéntanos en qué podemos ayudarte: te responderemos aquí.",
"placeholder": "Mensaje…",
"send": "Enviar",
"attach": "Adjuntar foto o archivo",
"conciergeOnly": "El conserje personal está incluido en Barev+ y Barev Elite",
"team": "Barev Club",
"fileTooBig": "El archivo supera los 10 MB",
"file": "Archivo"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Ayuda",
"webRequestOrdered": "Solicitud n.º {{request}} recibida y registrada como pedido n.º {{order}} para el {{date}}. El estado está en la app.",
"webRequestLinked": "Solicitud n.º {{request}} recibida. Un operador te contactará para confirmar la hora.",
"conciergeReceived": "Lo hemos pasado al conserje: te responderemos aquí, en este chat."
"conciergeReceived": "Lo hemos pasado al conserje: te responderemos aquí, en este chat.",
"supportReceived": "Lo hemos pasado a soporte: te responderemos aquí, en este chat.",
"channelSupport": "Ahora escribes a soporte. Envía un mensaje, una foto o un archivo.",
"channelConcierge": "Ahora escribes a tu conserje personal. Envía un mensaje, una foto o un archivo.",
"cmdSupport": "Escribir a soporte",
"cmdConcierge": "Escribir al conserje (Barev+)"
},
"pro": {
"notLinked": "Este bot es para los especialistas de Barev Club. Pide al operador un enlace de invitación.",
@@ -844,7 +865,11 @@
"newRequest": "Solicitud de la app n.º {{number}}",
"newWebRequest": "Solicitud de la web n.º {{number}}",
"loginOk": "Inicio de sesión confirmado: vuelve al navegador.",
"loginExpired": "El enlace de acceso ha caducado: recarga la página de administración."
"loginExpired": "El enlace de acceso ha caducado: recarga la página de administración.",
"channelSupport": "Soporte",
"channelConcierge": "Conserje",
"file": "archivo",
"chatAssigned": "{{by}} te ha pasado una conversación: {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, tarjeta válida",
@@ -948,7 +973,8 @@
"openPlan": "Abrir la app",
"concierge": "Conserje de Barev Club",
"subscriptionActivated": "{{plan}} activo hasta el {{date}}. Los descuentos ya están en los precios.",
"operator": "Operador de Barev Club"
"operator": "Operador de Barev Club",
"support": "Soporte de Barev Club"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "Կատարողը դեռ չի բացել աշխատանքային բոտը հրավերով՝ գրելու տեղ չկա։",
"CARD_EXPIRED": "QR-ը հնացել է կամ անվավեր է՝ խնդրեք հաճախորդին նորից բացել քարտը։",
"OFFER_NOT_AVAILABLE": "Այս առաջարկը հասանելի չէ հաճախորդի մակարդակում։",
"OFFER_LIMIT_REACHED": "Այս առաջարկի սահմանաչափն արդեն սպառված է։"
"OFFER_LIMIT_REACHED": "Այս առաջարկի սահմանաչափն արդեն սպառված է։",
"CONCIERGE_SUBSCRIBERS_ONLY": "Անձնական կոնսիերժը Barev+ և Barev Elite բաժանորդների համար է։ Առայժմ գրեք աջակցությանը՝ մենք էլ կօգնենք։",
"ASSIGNEE_NO_ACCESS": "Այս աշխատակիցը մուտք չունի այս ալիքին։"
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "արդեն ստացել եք",
"used_WEEK": "կրկին հաջորդ շաբաթից",
"used_MONTH": "կրկին հաջորդ ամսից"
},
"chat": {
"title": "Զրույց Barev Club-ի հետ",
"support": "Աջակցություն",
"concierge": "Անձնական կոնսիերժ",
"hint": "Սա նույն զրույցն է, ինչ բոտի չատում․ պատասխանը կգա և՛ այստեղ, և՛ Telegram-ում։",
"empty": "Գրեք, թե ինչով օգնել՝ կպատասխանենք այստեղ։",
"placeholder": "Հաղորդագրություն…",
"send": "Ուղարկել",
"attach": "Կցել լուսանկար կամ ֆայլ",
"conciergeOnly": "Անձնական կոնսիերժը՝ Barev+ և Barev Elite-ում",
"team": "Barev Club",
"fileTooBig": "Ֆայլը 10 ՄԲ-ից մեծ է",
"file": "Ֆայլ"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Օգնություն",
"webRequestOrdered": "Հայտ №{{request}}-ն ընդունված է և ձևակերպված որպես պատվեր №{{order}}՝ {{date}}։ Կարգավիճակը՝ հավելվածում։",
"webRequestLinked": "Հայտ №{{request}}-ն ընդունված է։ Օպերատորը կկապվի քեզ հետ և կհաստատի ժամը։",
"conciergeReceived": "Փոխանցեցինք կոնսիերժին՝ կպատասխանենք այստեղ, այս չատում։"
"conciergeReceived": "Փոխանցեցինք կոնսիերժին՝ կպատասխանենք այստեղ, այս չատում։",
"supportReceived": "Փոխանցեցինք աջակցությանը՝ կպատասխանենք այստեղ, այս չատում։",
"channelSupport": "Այժմ գրում եք աջակցությանը։ Պարզապես ուղարկեք հաղորդագրություն, լուսանկար կամ ֆայլ։",
"channelConcierge": "Այժմ գրում եք ձեր անձնական կոնսիերժին։ Պարզապես ուղարկեք հաղորդագրություն, լուսանկար կամ ֆայլ։",
"cmdSupport": "Գրել աջակցությանը",
"cmdConcierge": "Գրել կոնսիերժին (Barev+)"
},
"pro": {
"notLinked": "Այս բոտը Barev Club-ի կատարողների համար է։ Խնդրեք օպերատորից հրավերի հղում։",
@@ -844,7 +865,11 @@
"newRequest": "Հայտ հավելվածից №{{number}}",
"newWebRequest": "Հայտ կայքից №{{number}}",
"loginOk": "Մուտքը հաստատված է — վերադարձեք բրաուզեր։",
"loginExpired": "Մուտքի հղումը հնացել է — թարմացրեք ադմին էջը։"
"loginExpired": "Մուտքի հղումը հնացել է — թարմացրեք ադմին էջը։",
"channelSupport": "Աջակցություն",
"channelConcierge": "Կոնսիերժ",
"file": "ֆայլ",
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, քարտը գործում է",
@@ -948,7 +973,8 @@
"openPlan": "Բացել հավելվածը",
"concierge": "Barev Club կոնսիերժ",
"subscriptionActivated": "{{plan}}-ը միացված է մինչև {{date}}։ Զեղչերն արդեն ծառայությունների գներում են։",
"operator": "Barev Club օպերատոր"
"operator": "Barev Club օպերատոր",
"support": "Barev Club աջակցություն"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "Исполнитель ещё не открыл рабочий бот по инвайту — написать ему пока некуда.",
"CARD_EXPIRED": "QR устарел или недействителен — попросите клиента открыть карту заново.",
"OFFER_NOT_AVAILABLE": "Это предложение недоступно на уровне клиента.",
"OFFER_LIMIT_REACHED": "Лимит по этому предложению уже исчерпан."
"OFFER_LIMIT_REACHED": "Лимит по этому предложению уже исчерпан.",
"CONCIERGE_SUBSCRIBERS_ONLY": "Личный консьерж — для подписчиков Barev+ и Barev Elite. Пока пишите в поддержку — мы тоже поможем.",
"ASSIGNEE_NO_ACCESS": "У этого сотрудника нет доступа к этому каналу."
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "уже получено",
"used_WEEK": "снова со следующей недели",
"used_MONTH": "снова со следующего месяца"
},
"chat": {
"title": "Чат с Barev Club",
"support": "Поддержка",
"concierge": "Личный консьерж",
"hint": "Это та же переписка, что в чате с ботом: ответ придёт и сюда, и в Telegram.",
"empty": "Напишите, чем помочь, — ответим здесь.",
"placeholder": "Сообщение…",
"send": "Отправить",
"attach": "Прикрепить фото или файл",
"conciergeOnly": "Личный консьерж — в Barev+ и Barev Elite",
"team": "Barev Club",
"fileTooBig": "Файл больше 10 МБ",
"file": "Файл"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Помощь",
"webRequestOrdered": "Заявка №{{request}} принята и оформлена как заказ №{{order}} на {{date}}. Статус — в приложении.",
"webRequestLinked": "Заявка №{{request}} принята. Оператор свяжется с тобой и подтвердит время.",
"conciergeReceived": "Передали консьержу — ответим здесь, в этом чате."
"conciergeReceived": "Передали консьержу — ответим здесь, в этом чате.",
"supportReceived": "Передали в поддержку — ответим здесь, в этом чате.",
"channelSupport": "Теперь вы пишете в поддержку. Просто отправьте сообщение, фото или файл.",
"channelConcierge": "Теперь вы пишете личному консьержу. Просто отправьте сообщение, фото или файл.",
"cmdSupport": "Написать в поддержку",
"cmdConcierge": "Написать консьержу (Barev+)"
},
"pro": {
"notLinked": "Этот бот — для исполнителей Barev Club. Попроси у оператора ссылку-приглашение.",
@@ -844,7 +865,11 @@
"newRequest": "Заявка из приложения №{{number}}",
"newWebRequest": "Заявка с сайта №{{number}}",
"loginOk": "Вход в админку подтверждён — вернитесь в браузер.",
"loginExpired": "Ссылка для входа устарела — обновите страницу админки."
"loginExpired": "Ссылка для входа устарела — обновите страницу админки.",
"channelSupport": "Поддержка",
"channelConcierge": "Консьерж",
"file": "файл",
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, карта действует",
@@ -948,7 +973,8 @@
"openPlan": "Открыть приложение",
"concierge": "Консьерж Barev Club",
"subscriptionActivated": "{{plan}} включён до {{date}}. Скидки уже в ценах услуг.",
"operator": "Оператор Barev Club"
"operator": "Оператор Barev Club",
"support": "Поддержка Barev Club"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "服务人员尚未通过邀请打开工作机器人,暂时无法发送消息。",
"CARD_EXPIRED": "二维码已过期或无效——请客户重新打开会员卡。",
"OFFER_NOT_AVAILABLE": "该优惠不适用于客户当前等级。",
"OFFER_LIMIT_REACHED": "该优惠次数已用完。"
"OFFER_LIMIT_REACHED": "该优惠次数已用完。",
"CONCIERGE_SUBSCRIBERS_ONLY": "专属礼宾仅面向 Barev+ 和 Barev Elite 会员。目前请联系客服,我们同样会帮你。",
"ASSIGNEE_NO_ACCESS": "该成员无权访问此频道。"
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "已使用",
"used_WEEK": "下周可再次使用",
"used_MONTH": "下月可再次使用"
},
"chat": {
"title": "与 Barev Club 聊天",
"support": "客服",
"concierge": "专属礼宾",
"hint": "这与机器人聊天是同一个对话:回复会同时出现在这里和 Telegram。",
"empty": "告诉我们需要什么帮助,我们会在这里回复。",
"placeholder": "输入消息…",
"send": "发送",
"attach": "附加照片或文件",
"conciergeOnly": "专属礼宾包含在 Barev+ 和 Barev Elite 中",
"team": "Barev Club",
"fileTooBig": "文件超过 10 MB",
"file": "文件"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "帮助",
"webRequestOrdered": "申请 {{request}} 号已收到,并已生成订单 {{order}} 号,时间:{{date}}。状态请在应用中查看。",
"webRequestLinked": "申请 {{request}} 号已收到。客服会联系你确认时间。",
"conciergeReceived": "已转给礼宾,我们会在这个聊天中回复你。"
"conciergeReceived": "已转给礼宾,我们会在这个聊天中回复你。",
"supportReceived": "已转给客服,我们会在这个聊天中回复你。",
"channelSupport": "现在你在给客服写信。直接发送消息、照片或文件即可。",
"channelConcierge": "现在你在给专属礼宾写信。直接发送消息、照片或文件即可。",
"cmdSupport": "联系客服",
"cmdConcierge": "联系礼宾(Barev+)"
},
"pro": {
"notLinked": "此机器人供 Barev Club 服务人员使用。请向客服索取邀请链接。",
@@ -844,7 +865,11 @@
"newRequest": "应用申请 #{{number}}",
"newWebRequest": "网站申请 #{{number}}",
"loginOk": "登录已确认,请返回浏览器。",
"loginExpired": "登录链接已过期,请刷新管理页面。"
"loginExpired": "登录链接已过期,请刷新管理页面。",
"channelSupport": "客服",
"channelConcierge": "礼宾",
"file": "文件",
"chatAssigned": "{{by}} 将对话转交给你:{{name}}"
},
"partner": {
"valid": "{{name}} — {{level}},会员卡有效",
@@ -948,7 +973,8 @@
"openPlan": "打开应用",
"concierge": "Barev Club 礼宾",
"subscriptionActivated": "{{plan}} 已开通,有效期至 {{date}}。服务价格已含折扣。",
"operator": "Barev Club 客服"
"operator": "Barev Club 客服",
"support": "Barev Club 客服支持"
},
"admin": {
"nav": {
+7 -5
View File
@@ -7,23 +7,25 @@
".": "./src/index.ts",
"./queue": "./src/queue.ts",
"./env": "./src/env.ts",
"./telegram": "./src/telegram.ts"
"./telegram": "./src/telegram.ts",
"./storage": "./src/storage.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/i18n": "workspace:*",
"bullmq": "^5.81.5",
"dotenv": "^17.0.0",
"@barev/i18n": "workspace:*"
"dotenv": "^17.0.0"
},
"devDependencies": {
"@types/pg": "^8.15.0",
"dotenv": "^17.0.0",
"vitest": "^4.1.0",
"pg": "^8.16.0",
"@types/pg": "^8.15.0"
"vitest": "^4.1.0"
}
}
+447
View File
@@ -0,0 +1,447 @@
/**
* Чаты (этап 2 новой админки): два канала — поддержка (все клиенты и исполнители) и консьерж (только подписчики).
* Клиент пишет боту или в мини-аппе — сообщения попадают в один диалог; ответ команды приходит в чат с ботом.
* У собеседника по одному диалогу в каждом канале: закрытый диалог открывается заново, когда клиент пишет снова.
*/
import { activeSubscription } from '@barev/core';
import type { Prisma } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
export type ChatChannel = 'SUPPORT' | 'CONCIERGE';
export const CHAT_CHANNELS: ChatChannel[] = ['SUPPORT', 'CONCIERGE'];
export type ChatSource = 'BOT' | 'MINIAPP' | 'ADMIN';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
/** Файл в сообщении: из Telegram (file_id) или из нашего хранилища (ключ) */
export interface ChatFile {
tgFileId?: string | null;
tgBot?: 'client' | 'pro' | null;
fileKey?: string | null;
fileName?: string | null;
mime?: string | null;
size?: number | null;
}
const MAX_TEXT = 4000;
const clean = (s: string | null | undefined) => (s ?? '').trim().slice(0, MAX_TEXT);
const INCOMING = { in: ['CLIENT', 'PROVIDER'] as ('CLIENT' | 'PROVIDER')[] };
async function hasActiveSubscription(ctx: Ctx, clientId: string) {
const subs = await ctx.db.subscription.findMany({ where: { clientId, status: 'ACTIVE' } });
return !!activeSubscription(subs, ctx.now());
}
/** Куда сейчас пишет клиент: выбранный канал, иначе подписчик → консьерж, остальные → поддержка */
export async function clientChannel(ctx: Ctx, clientId: string): Promise<ChatChannel> {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, select: { chatChannel: true } });
const subscriber = await hasActiveSubscription(ctx, clientId);
if (client.chatChannel === 'SUPPORT') return 'SUPPORT';
return subscriber ? 'CONCIERGE' : 'SUPPORT';
}
/** Клиент сам выбирает канал (/support, /concierge в боте). Консьерж — только с подпиской */
export async function setClientChannel(ctx: Ctx, clientId: string, channel: ChatChannel) {
if (channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
await ctx.db.client.update({ where: { id: clientId }, data: { chatChannel: channel } });
return channel;
}
function threadKey(peer: Peer, channel: ChatChannel): Prisma.ChatThreadWhereUniqueInput {
return peer.clientId ? { clientId_channel: { clientId: peer.clientId, channel } } : { providerId_channel: { providerId: peer.providerId!, channel } };
}
async function ensureThread(ctx: Ctx, peer: Peer, channel: ChatChannel) {
return ctx.db.chatThread.upsert({
where: threadKey(peer, channel),
create: { ...peerWhere(peer), channel, status: 'CLOSED', lastMessageAt: ctx.now() },
update: {},
});
}
/**
* Входящее сообщение клиента или исполнителя (бот / мини-апп).
* opened = диалог был закрыт или новый — бот подтверждает «передали, ответим здесь».
*/
export async function receiveMessage(ctx: Ctx, peer: Peer, input: { text?: string | null; file?: ChatFile | null; source?: ChatSource }) {
const text = clean(input.text);
if (!text && !input.file) return null;
const channel: ChatChannel = peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT';
const before = await ensureThread(ctx, peer, channel);
const now = ctx.now();
const message = await ctx.db.conciergeMessage.create({
data: {
...peerWhere(peer),
threadId: before.id,
author: peer.clientId ? 'CLIENT' : 'PROVIDER',
text,
source: input.source ?? 'BOT',
...fileData(input.file),
},
});
const thread = await ctx.db.chatThread.update({
where: { id: before.id },
data: { status: 'OPEN', closedAt: null, lastMessageAt: now, waitingSince: before.waitingSince ?? now },
});
await ctx.notify.emit({ type: 'concierge.message', messageId: message.id });
return { message, thread, channel, opened: before.status === 'CLOSED' };
}
function fileData(f: ChatFile | null | undefined) {
if (!f) return {};
return { tgFileId: f.tgFileId ?? null, tgBot: f.tgBot ?? null, fileKey: f.fileKey ?? null, fileName: f.fileName?.slice(0, 200) ?? null, mime: f.mime ?? null, size: f.size ?? null };
}
async function staffName(ctx: Ctx, id: string) {
return (await ctx.db.adminUser.findUnique({ where: { id }, select: { name: true } }))?.name ?? '';
}
/** Служебная строка в ленте: кто взял, передал, закрыл. В text — код события, в fileName — параметр */
async function systemLine(ctx: Ctx, thread: { id: string; clientId: string | null; providerId: string | null }, adminUserId: string, code: string, param?: string) {
await ctx.db.conciergeMessage.create({
data: { threadId: thread.id, clientId: thread.clientId, providerId: thread.providerId, author: 'SYSTEM', adminUserId, text: code, fileName: param ?? null, internal: true, source: 'ADMIN', readAt: ctx.now() },
});
}
async function getThread(ctx: Ctx, id: string) {
const t = await ctx.db.chatThread.findUnique({ where: { id } });
if (!t) throw new DomainError('NOT_FOUND');
return t;
}
const markRead = (ctx: Ctx, threadId: string) =>
ctx.db.conciergeMessage.updateMany({ where: { threadId, author: INCOMING, readAt: null }, data: { readAt: ctx.now() } });
/**
* Сообщение команды: ответ клиенту (уходит в Telegram через воркер) или внутренняя заметка.
* Ответил первым в неназначенном диалоге — диалог за тобой.
*/
export async function staffSend(ctx: Ctx, input: { threadId: string; adminUserId: string; text?: string | null; internal?: boolean; file?: ChatFile | null }) {
const thread = await getThread(ctx, input.threadId);
const text = clean(input.text);
if (!text && !input.file) throw new DomainError('INVALID_PARAMS');
if (!input.internal && thread.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: thread.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const message = await ctx.db.conciergeMessage.create({
data: {
threadId: thread.id,
clientId: thread.clientId,
providerId: thread.providerId,
author: 'OPERATOR',
adminUserId: input.adminUserId,
text,
internal: !!input.internal,
source: 'ADMIN',
...fileData(input.file),
},
});
if (!input.internal) {
await ctx.db.chatThread.update({
where: { id: thread.id },
data: { waitingSince: null, lastMessageAt: ctx.now(), status: 'OPEN', closedAt: null, assigneeId: thread.assigneeId ?? input.adminUserId, botActive: false },
});
await markRead(ctx, thread.id);
await ctx.notify.emit({ type: 'concierge.reply', messageId: message.id });
}
return message;
}
/** Взять диалог себе / передать коллеге / снять ответственного (assigneeId = null) */
export async function assignThread(ctx: Ctx, input: { threadId: string; assigneeId: string | null; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.assigneeId === input.assigneeId) return thread;
if (input.assigneeId) {
const a = await ctx.db.adminUser.findUnique({ where: { id: input.assigneeId } });
if (!a?.active) throw new DomainError('NOT_FOUND');
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
if (!a.roles.includes(role) && !a.roles.includes('SUPERADMIN')) throw new DomainError('ASSIGNEE_NO_ACCESS');
}
const updated = await ctx.db.chatThread.update({ where: { id: thread.id }, data: { assigneeId: input.assigneeId, botActive: false } });
if (!input.assigneeId) await systemLine(ctx, thread, input.byAdminId, 'unassigned');
else if (input.assigneeId === input.byAdminId) await systemLine(ctx, thread, input.byAdminId, 'took');
else {
await systemLine(ctx, thread, input.byAdminId, 'transferred', await staffName(ctx, input.assigneeId));
await ctx.notify.emit({ type: 'chat.assigned', threadId: thread.id, assigneeId: input.assigneeId, byAdminId: input.byAdminId });
}
return updated;
}
/** Закрыть: вопрос решён. Клиент напишет снова — диалог откроется сам */
export async function closeThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.status === 'CLOSED') return thread;
await markRead(ctx, thread.id);
await systemLine(ctx, thread, input.byAdminId, 'closed');
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'CLOSED', closedAt: ctx.now(), waitingSince: null, botActive: false } });
}
export async function reopenThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.status === 'OPEN') return thread;
await systemLine(ctx, thread, input.byAdminId, 'reopened');
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'OPEN', closedAt: null } });
}
/**
* Перенести диалог клиента в другой канал (например, вопрос подписчика — к поддержке).
* Переписка переезжает целиком, дальше клиент пишет в новый канал.
*/
export async function moveThread(ctx: Ctx, input: { threadId: string; channel: ChatChannel; byAdminId: string }) {
const source = await getThread(ctx, input.threadId);
if (source.channel === input.channel) return source;
if (!source.clientId) throw new DomainError('INVALID_PARAMS');
if (input.channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, source.clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
const peer: Peer = { clientId: source.clientId };
const target = await ensureThread(ctx, peer, input.channel);
await ctx.db.conciergeMessage.updateMany({ where: { threadId: source.id }, data: { threadId: target.id } });
const waiting = [source.waitingSince, target.waitingSince].filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime())[0] ?? null;
const last = source.lastMessageAt > target.lastMessageAt ? source.lastMessageAt : target.lastMessageAt;
await ctx.db.chatThread.delete({ where: { id: source.id } });
const moved = await ctx.db.chatThread.update({
where: { id: target.id },
data: { status: 'OPEN', closedAt: null, waitingSince: waiting, lastMessageAt: last, assigneeId: target.assigneeId, botActive: false },
});
await ctx.db.client.update({ where: { id: source.clientId }, data: { chatChannel: input.channel } });
await systemLine(ctx, moved, input.byAdminId, 'moved', input.channel);
return moved;
}
/** Открыть (или создать пустой) диалог с клиентом — чтобы написать первым из карточки */
export async function openThreadFor(ctx: Ctx, peer: Peer, channel?: ChatChannel) {
const ch = channel ?? (peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT');
return ensureThread(ctx, peer, ch);
}
// ───── списки для команды ─────
export type ThreadFilter = 'mine' | 'unassigned' | 'waiting' | 'open' | 'closed' | 'all';
function filterWhere(filter: ThreadFilter, adminUserId: string): Prisma.ChatThreadWhereInput {
switch (filter) {
case 'mine':
return { assigneeId: adminUserId, status: 'OPEN' };
case 'unassigned':
return { assigneeId: null, status: 'OPEN' };
case 'waiting':
return { status: 'OPEN', waitingSince: { not: null } };
case 'open':
return { status: 'OPEN' };
case 'closed':
return { status: 'CLOSED' };
default:
return {};
}
}
const peerInclude = {
client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } }, subscriptions: { where: { status: 'ACTIVE' as const } } } },
provider: { include: { user: { select: { firstName: true, username: true, locale: true } } } },
assignee: { select: { id: true, name: true } },
} satisfies Prisma.ChatThreadInclude;
export function peerName(t: { client?: { user: { firstName: string | null; lastName: string | null; username: string | null } } | null; provider?: { name: string } | null }) {
if (t.client) {
const u = t.client.user;
return `${u.firstName ?? ''} ${u.lastName ?? ''}`.trim() || (u.username ? `@${u.username}` : '—');
}
return t.provider?.name ?? '—';
}
/** Короткая строка для списка: текст, «📎 файл» или код события */
function preview(m: { author: string; text: string; fileName: string | null; mime: string | null; tgFileId: string | null; fileKey: string | null } | undefined) {
if (!m) return null;
const hasFile = !!(m.tgFileId || m.fileKey);
return { author: m.author, text: m.text, file: hasFile ? (m.fileName ?? (m.mime?.startsWith('image/') ? 'image' : 'file')) : null };
}
export async function listThreads(
ctx: Ctx,
input: { channels: ChatChannel[]; channel?: ChatChannel; filter: ThreadFilter; adminUserId: string; search?: string; limit?: number },
) {
const channels = input.channel ? input.channels.filter((c) => c === input.channel) : input.channels;
const q = input.search?.trim();
const where: Prisma.ChatThreadWhereInput = {
channel: { in: channels },
...filterWhere(input.filter, input.adminUserId),
// пустой диалог (открыли из карточки и ничего не написали) в списке не нужен
messages: { some: {} },
...(q
? {
OR: [
{
client: {
user: {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ username: { contains: q.replace(/^@/, ''), mode: 'insensitive' } },
{ phone: { contains: q } },
],
},
},
},
{ provider: { name: { contains: q, mode: 'insensitive' } } },
{ messages: { some: { text: { contains: q, mode: 'insensitive' }, author: { not: 'SYSTEM' } } } },
],
}
: {}),
};
const rows = await ctx.db.chatThread.findMany({
where,
include: { ...peerInclude, messages: { where: { author: { not: 'SYSTEM' } }, orderBy: { createdAt: 'desc' }, take: 1 } },
// ждущие ответа — сверху, дольше всех ждущий — первым; остальные — по свежести
orderBy: [{ lastMessageAt: 'desc' }],
take: input.limit ?? 100,
});
const unread = rows.length
? await ctx.db.conciergeMessage.groupBy({ by: ['threadId'], where: { threadId: { in: rows.map((r) => r.id) }, author: INCOMING, readAt: null }, _count: true })
: [];
const unreadBy = new Map(unread.map((u) => [u.threadId, u._count]));
const now = ctx.now();
return rows
.map((t) => ({
id: t.id,
channel: t.channel,
status: t.status,
clientId: t.clientId,
providerId: t.providerId,
name: peerName(t),
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
providerRole: t.provider?.role ?? null,
assignee: t.assignee,
botActive: t.botActive,
waitingSince: t.status === 'OPEN' ? t.waitingSince : null,
lastMessageAt: t.lastMessageAt,
last: preview(t.messages[0]),
unread: unreadBy.get(t.id) ?? 0,
}))
.sort((a, b) => {
if (!!a.waitingSince !== !!b.waitingSince) return a.waitingSince ? -1 : 1;
if (a.waitingSince && b.waitingSince) return a.waitingSince.getTime() - b.waitingSince.getTime();
return b.lastMessageAt.getTime() - a.lastMessageAt.getTime();
});
}
/** Счётчики для вкладок и меню: ждут ответа / без ответственного / мои — по каждому каналу */
export async function threadCounts(ctx: Ctx, input: { channels: ChatChannel[]; adminUserId: string }) {
const base = { channel: { in: input.channels }, status: 'OPEN' as const };
const [waiting, unassigned, mine] = await Promise.all([
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, waitingSince: { not: null } }, _count: true }),
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: null, messages: { some: {} } }, _count: true }),
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: input.adminUserId }, _count: true }),
]);
const by = (rows: { channel: ChatChannel; _count: number }[]) => Object.fromEntries(CHAT_CHANNELS.map((c) => [c, rows.find((r) => r.channel === c)?._count ?? 0])) as Record<ChatChannel, number>;
const w = by(waiting);
return { waiting: w, unassigned: by(unassigned), mine: by(mine), total: input.channels.reduce((s, c) => s + w[c], 0) };
}
/** Диалог целиком для команды; открыл — входящие прочитаны */
export async function getThreadForStaff(ctx: Ctx, id: string, opts: { markRead?: boolean } = {}) {
const t = await ctx.db.chatThread.findUnique({ where: { id }, include: peerInclude });
if (!t) throw new DomainError('NOT_FOUND');
if (opts.markRead !== false) await markRead(ctx, id);
const messages = await ctx.db.conciergeMessage.findMany({ where: { threadId: id }, orderBy: { createdAt: 'asc' }, take: 500 });
const authorIds = [...new Set(messages.map((m) => m.adminUserId).filter((x): x is string => !!x))];
const authors = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: authorIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
// второй канал этого же клиента — чтобы показать «есть переписка в поддержке/консьерже»
const other = t.clientId ? await ctx.db.chatThread.findFirst({ where: { clientId: t.clientId, id: { not: t.id }, messages: { some: {} } }, select: { id: true, channel: true, status: true } }) : null;
const now = ctx.now();
return {
id: t.id,
channel: t.channel,
status: t.status,
clientId: t.clientId,
providerId: t.providerId,
name: peerName(t),
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
locale: t.client?.user.locale ?? t.provider?.user?.locale ?? 'ru',
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
providerRole: t.provider?.role ?? null,
providerLinked: t.provider ? !!t.provider.userId : null,
assignee: t.assignee,
botActive: t.botActive,
waitingSince: t.waitingSince,
other,
messages: messages.map((m) => ({
id: m.id,
author: m.author,
authorName: m.adminUserId ? (authors.get(m.adminUserId) ?? null) : null,
text: m.text,
internal: m.internal,
source: m.source,
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime, size: m.size } : null,
param: m.author === 'SYSTEM' ? m.fileName : null,
deliveryError: m.deliveryError,
readAt: m.readAt,
createdAt: m.createdAt,
})),
};
}
// ───── мини-апп клиента ─────
/** Переписка клиента со всеми каналами — без заметок команды и служебных строк */
export async function clientConversation(ctx: Ctx, clientId: string) {
const [messages, channel, subscriber] = await Promise.all([
ctx.db.conciergeMessage.findMany({
where: { clientId, internal: false, author: { in: ['CLIENT', 'OPERATOR', 'BOT'] } },
orderBy: { createdAt: 'asc' },
take: 300,
include: { thread: { select: { channel: true } } },
}),
clientChannel(ctx, clientId),
hasActiveSubscription(ctx, clientId),
]);
return {
channel,
canConcierge: subscriber,
messages: messages.map((m) => ({
id: m.id,
mine: m.author === 'CLIENT',
channel: m.thread?.channel ?? 'SUPPORT',
text: m.text,
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime } : null,
createdAt: m.createdAt,
})),
};
}
// ───── шаблоны ответов ─────
export function listTemplates(ctx: Ctx, channels: ChatChannel[]) {
return ctx.db.chatTemplate.findMany({ where: { OR: [{ channel: null }, { channel: { in: channels } }] }, orderBy: [{ title: 'asc' }] });
}
export async function saveTemplate(ctx: Ctx, input: { id?: string; channel: ChatChannel | null; title: string; text: string; adminUserId: string }) {
const data = { channel: input.channel, title: input.title.trim().slice(0, 80), text: clean(input.text) };
if (!data.title || !data.text) throw new DomainError('INVALID_PARAMS');
if (input.id) return ctx.db.chatTemplate.update({ where: { id: input.id }, data });
return ctx.db.chatTemplate.create({ data: { ...data, createdById: input.adminUserId } });
}
export async function deleteTemplate(ctx: Ctx, id: string) {
await ctx.db.chatTemplate.delete({ where: { id } });
}
/** Подставить имя клиента в шаблон */
export function fillTemplate(text: string, vars: { name: string }) {
return text.replaceAll('{name}', vars.name);
}
/** Кому из команды сообщить о новом сообщении в Telegram */
export async function chatRecipients(ctx: Ctx, thread: { channel: ChatChannel; assigneeId: string | null }) {
const staff = await ctx.db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
if (thread.assigneeId) {
const a = staff.find((s) => s.id === thread.assigneeId);
if (a) return [a];
}
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
// без ролей — сотрудник только старой админки, ему приходят все уведомления, как раньше
return staff.filter((s) => s.roles.length === 0 || s.roles.includes('SUPERADMIN') || s.roles.includes(role));
}
+47 -38
View File
@@ -1,26 +1,27 @@
/**
* Переписка для старой админки и кнопки «Ответить» в рабочем боте.
* Данные — те же диалоги, что в новой админке (chats.ts); заметки команды и служебные строки здесь не видны.
*/
import { DomainError, type Ctx } from './context.ts';
import { clientChannel, receiveMessage, staffSend, type ChatFile, type Peer } from './chats.ts';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId });
export type { Peer } from './chats.ts';
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
/** что видит старая админка: переписка без заметок и служебных строк */
const VISIBLE = { internal: false, author: { in: ['CLIENT', 'PROVIDER', 'OPERATOR', 'BOT'] as ('CLIENT' | 'PROVIDER' | 'OPERATOR' | 'BOT')[] } };
const withFileText = <M extends { text: string; fileName: string | null; tgFileId: string | null; fileKey: string | null }>(m: M) =>
m.text || !(m.tgFileId || m.fileKey) ? m : { ...m, text: `📎 ${m.fileName ?? 'file'}` };
async function incoming(ctx: Ctx, peer: Peer, text: string) {
const body = text.trim().slice(0, 4000);
if (!body) return null;
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: peer.clientId ? 'CLIENT' : 'PROVIDER', text: body } });
await ctx.notify.emit({ type: 'concierge.message', messageId: msg.id });
return msg;
}
/** Сообщение клиента боту — в чат консьержа в админке и уведомление операторам */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string) => incoming(ctx, { clientId }, text);
/** Сообщение исполнителя рабочему боту */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string) => incoming(ctx, { providerId }, text);
/** Сообщение клиента боту — в чат поддержки или консьержа и уведомление команде */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { clientId }, { text, file });
/** Сообщение исполнителя рабочему боту — в поддержку */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { providerId }, { text, file });
/** Список диалогов для оператора: последнее сообщение и сколько непрочитанных, свежие сверху */
export async function listConversations(ctx: Ctx) {
const [last, unread] = await Promise.all([
ctx.db.conciergeMessage.findMany({
where: VISIBLE,
distinct: ['clientId', 'providerId'],
orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }],
include: {
@@ -28,30 +29,34 @@ export async function listConversations(ctx: Ctx) {
provider: { include: { user: { select: { username: true, locale: true } } } },
},
}),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { not: 'OPERATOR' }, readAt: null }, _count: true }),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null }, _count: true }),
]);
const key = (m: { clientId: string | null; providerId: string | null }) => (m.clientId ? `c:${m.clientId}` : `p:${m.providerId}`);
const unreadBy = new Map(unread.map((u) => [key(u), u._count]));
return last
.map((m) => ({
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
}))
.map((raw) => {
const m = withFileText(raw);
return {
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
};
})
.sort((a, b) => b.last.createdAt.getTime() - a.last.createdAt.getTime());
}
/** Переписка; открыл — входящие прочитаны. Пустая — можно написать первым */
export async function conversation(ctx: Ctx, peer: Peer) {
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
return ctx.db.conciergeMessage.findMany({ where: peerWhere(peer), orderBy: { createdAt: 'asc' }, take: 500 });
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null }, data: { readAt: ctx.now() } });
const list = await ctx.db.conciergeMessage.findMany({ where: { ...peerWhere(peer), ...VISIBLE }, orderBy: { createdAt: 'asc' }, take: 500 });
return list.map(withFileText);
}
/** Кто это — для заголовка нового диалога, который оператор начинает первым */
@@ -65,22 +70,26 @@ export async function peerInfo(ctx: Ctx, peer: Peer) {
}
export function unreadConciergeCount(ctx: Ctx) {
return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, readAt: null } });
return ctx.db.conciergeMessage.count({ where: { author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null } });
}
/** Ответ оператора: сохраняем и отправляем в чат с ботом (воркер) */
/**
* Ответ оператора по собеседнику (старая админка, «Ответить» в боте):
* в диалог, где клиент писал последним, иначе — в канал, куда он сейчас пишет.
*/
export async function replyTo(ctx: Ctx, input: Peer & { adminUserId: string; text: string }) {
const peer: Peer = input.clientId ? { clientId: input.clientId } : { providerId: input.providerId! };
if (peer.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: peer.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: 'OPERATOR', adminUserId: input.adminUserId, text: input.text.trim().slice(0, 4000) } });
// ответили — входящие этого диалога прочитаны
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
await ctx.notify.emit({ type: 'concierge.reply', messageId: msg.id });
return msg;
const latest = await ctx.db.chatThread.findFirst({ where: peerWhere(peer), orderBy: { lastMessageAt: 'desc' } });
const thread =
latest ??
(await ctx.db.chatThread.create({
data: { ...peerWhere(peer), channel: peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT', status: 'OPEN', lastMessageAt: ctx.now() },
}));
return staffSend(ctx, { threadId: thread.id, adminUserId: input.adminUserId, text: input.text });
}
/** Совместимость: ответ клиенту */
+5 -1
View File
@@ -23,6 +23,8 @@ export type DomainEvent =
| { type: 'subscription.activated'; subscriptionId: string }
/** уведомления операторам в рабочий бот */
| { type: 'concierge.message'; messageId: string }
/** диалог передали коллеге — ему уведомление в рабочий бот */
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
@@ -77,7 +79,9 @@ export type DomainErrorCode =
| 'PROVIDER_NOT_LINKED'
| 'CARD_EXPIRED'
| 'OFFER_NOT_AVAILABLE'
| 'OFFER_LIMIT_REACHED';
| 'OFFER_LIMIT_REACHED'
| 'CONCIERGE_SUBSCRIBERS_ONLY'
| 'ASSIGNEE_NO_ACCESS';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+1
View File
@@ -14,4 +14,5 @@ export * from './leads.ts';
export * from './relocation.ts';
export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './chats.ts';
export * from './partners.ts';
+43
View File
@@ -0,0 +1,43 @@
/**
* Приватное объектное хранилище (S3-совместимое) для файлов чатов — общее для API и воркера.
* Настройки — из S3_* в .env.
*/
import { randomUUID } from 'node:crypto';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
let client: S3Client | null = null;
function s3() {
client ??= new S3Client({
endpoint: process.env.S3_ENDPOINT ?? 'http://localhost:9000',
region: process.env.S3_REGION ?? 'us-east-1',
forcePathStyle: true,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY ?? '', secretAccessKey: process.env.S3_SECRET_KEY ?? '' },
});
return client;
}
const bucket = () => process.env.S3_BUCKET ?? 'barev-private';
/** Расширение из имени файла (только буквы/цифры), иначе bin */
function extOf(name: string | null | undefined) {
const m = name?.match(/\.([a-z0-9]{1,8})$/i);
return m ? m[1]!.toLowerCase() : 'bin';
}
export async function putFile(prefix: string, body: Buffer, mime: string, fileName?: string | null) {
const key = `${prefix}/${randomUUID()}.${extOf(fileName)}`;
await s3().send(
new PutObjectCommand({
Bucket: bucket(),
Key: key,
Body: body,
ContentType: mime,
...(process.env.NODE_ENV === 'production' ? { ServerSideEncryption: 'AES256' as const } : {}),
}),
);
return key;
}
export async function getFile(key: string): Promise<Buffer> {
const res = await s3().send(new GetObjectCommand({ Bucket: bucket(), Key: key }));
return Buffer.from(await res.Body!.transformToByteArray());
}
+37 -3
View File
@@ -25,11 +25,12 @@ export class TelegramSender {
private label: string,
) {}
async send(msg: OutgoingMessage): Promise<void> {
/** false — получатель недоступен (заблокировал бота / бот ему не писал) */
async send(msg: OutgoingMessage): Promise<boolean> {
if (!this.token) {
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
return;
return true;
}
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: 'POST',
@@ -48,10 +49,43 @@ export class TelegramSender {
// (например, тестовые пользователи seed); повторять бессмысленно
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
return;
return false;
}
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Файл с подписью: картинки — как фото (видно сразу), остальное — документом. false — получатель недоступен */
async sendFile(msg: { chatId: bigint | number | string; caption: string; body: Buffer; fileName: string; mime: string }): Promise<boolean> {
const asPhoto = /^image\/(jpeg|png|webp)$/.test(msg.mime) && msg.body.length <= 10 * 1024 * 1024;
if (!this.token) {
console.log(`[${this.label} → ${msg.chatId}] 📎 ${msg.fileName} (${msg.body.length} B) ${msg.caption.replace(/<[^>]+>/g, '')}`);
return true;
}
const form = new FormData();
form.set('chat_id', String(msg.chatId));
form.set('caption', msg.caption.slice(0, 1024));
form.set('parse_mode', 'HTML');
form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(msg.body)], { type: msg.mime }), msg.fileName);
const res = await fetch(`https://api.telegram.org/bot${this.token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { method: 'POST', body: form });
if (!res.ok) {
const body = await res.text();
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) return false;
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Скачать файл, который прислали боту (до 20 МБ — ограничение Bot API) */
async download(fileId: string): Promise<Response | null> {
if (!this.token) return null;
const info = await fetch(`https://api.telegram.org/bot${this.token}/getFile?file_id=${encodeURIComponent(fileId)}`);
if (!info.ok) return null;
const json = (await info.json()) as { ok: boolean; result?: { file_path?: string } };
if (!json.result?.file_path) return null;
const file = await fetch(`https://api.telegram.org/file/bot${this.token}/${json.result.file_path}`);
return file.ok ? file : null;
}
}
+189
View File
@@ -0,0 +1,189 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import {
addClientMessage,
assignThread,
chatRecipients,
clientConversation,
closeThread,
conversation,
DomainError,
getThreadForStaff,
giveConsent,
grantSubscription,
listThreads,
moveThread,
openThreadFor,
receiveMessage,
replyTo,
setClientChannel,
staffSend,
startClient,
threadCounts,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, tgUser } from './helpers.ts';
beforeEach(async () => {
await fixtures();
});
afterAll(() => db.$disconnect());
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, subscriber = false) {
const s = await startClient(ctx, tgUser(id));
await giveConsent(ctx, s.clientId);
if (subscriber) await grantSubscription(ctx, { clientId: s.clientId, plan: 'BAREV_PLUS', days: 30 });
return s.clientId;
}
const staff = (name: string, roles: ('SUPERADMIN' | 'SUPPORT' | 'CONCIERGE')[], telegramId?: number) =>
db.adminUser.create({ data: { name, roles, telegramId: telegramId ? BigInt(telegramId) : undefined } });
describe('чаты: поддержка и консьерж', () => {
it('подписчик пишет консьержу, остальные — в поддержку; бот и мини-апп — одна переписка', async () => {
const { ctx, events } = makeCtx(NOW);
const plain = await client(ctx, 701);
const member = await client(ctx, 702, true);
const a = await addClientMessage(ctx, plain, 'Где мой заказ?');
expect(a).toMatchObject({ channel: 'SUPPORT', opened: true });
const b = await addClientMessage(ctx, member, 'Нужен нотариус');
expect(b).toMatchObject({ channel: 'CONCIERGE', opened: true });
// второе сообщение из мини-аппа — в тот же диалог, уже открытый
const c = await receiveMessage(ctx, { clientId: member }, { text: 'И переводчик', source: 'MINIAPP' });
expect(c).toMatchObject({ opened: false });
expect(c!.thread.id).toBe(b!.thread.id);
expect(c!.message.source).toBe('MINIAPP');
expect(events.filter((e) => e.type === 'concierge.message')).toHaveLength(3);
// пустое сообщение без файла не сохраняется
expect(await receiveMessage(ctx, { clientId: plain }, { text: ' ' })).toBeNull();
});
it('клиент сам выбирает канал; консьерж — только с подпиской', async () => {
const { ctx } = makeCtx(NOW);
const plain = await client(ctx, 711);
const member = await client(ctx, 712, true);
expect(await code(setClientChannel(ctx, plain, 'CONCIERGE'))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
await setClientChannel(ctx, member, 'SUPPORT');
expect((await addClientMessage(ctx, member, 'Вопрос по оплате'))!.channel).toBe('SUPPORT');
await setClientChannel(ctx, member, 'CONCIERGE');
expect((await addClientMessage(ctx, member, 'Столик на субботу'))!.channel).toBe('CONCIERGE');
});
it('ответ: уходит клиенту, снимает «ждёт ответа», диалог за ответившим; заметка клиенту не уходит', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await client(ctx, 721);
const op = await staff('Гор', ['SUPPORT']);
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
expect(thread.waitingSince).toEqual(NOW);
const note = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'VIP', internal: true });
expect(events).not.toContainEqual({ type: 'concierge.reply', messageId: note.id });
expect((await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } })).waitingSince).toEqual(NOW);
const reply = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'Здравствуйте!' });
expect(events).toContainEqual({ type: 'concierge.reply', messageId: reply.id });
const t = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
expect(t).toMatchObject({ waitingSince: null, assigneeId: op.id });
expect(await code(staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: ' ' }))).toBe('INVALID_PARAMS');
// клиент видит ответ, но не заметку; старая админка — тоже без заметок
const mine = await clientConversation(ctx, clientId);
expect(mine.messages.map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
expect((await conversation(ctx, { clientId })).map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
});
it('взять, передать коллеге с доступом, закрыть; закрытый открывается, когда клиент пишет снова', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await client(ctx, 731, true);
const anna = await staff('Анна', ['CONCIERGE']);
const gor = await staff('Гор', ['SUPPORT']);
const boss = await staff('Босс', ['SUPERADMIN']);
const { thread } = (await addClientMessage(ctx, clientId, 'Нужен столик'))!;
expect(thread.channel).toBe('CONCIERGE');
await assignThread(ctx, { threadId: thread.id, assigneeId: boss.id, byAdminId: boss.id });
// у поддержки нет доступа к консьержу
expect(await code(assignThread(ctx, { threadId: thread.id, assigneeId: gor.id, byAdminId: boss.id }))).toBe('ASSIGNEE_NO_ACCESS');
await assignThread(ctx, { threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
expect(events).toContainEqual({ type: 'chat.assigned', threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
await closeThread(ctx, { threadId: thread.id, byAdminId: anna.id });
const closed = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
expect(closed).toMatchObject({ status: 'CLOSED', waitingSince: null });
const again = await addClientMessage(ctx, clientId, 'Ещё вопрос');
expect(again).toMatchObject({ opened: true });
expect(again!.thread).toMatchObject({ id: thread.id, status: 'OPEN', assigneeId: anna.id });
// служебные строки видны команде, но не клиенту
const full = await getThreadForStaff(ctx, thread.id);
expect(full.messages.filter((m) => m.author === 'SYSTEM').map((m) => m.text)).toEqual(['took', 'transferred', 'closed']);
expect((await clientConversation(ctx, clientId)).messages).toHaveLength(2);
});
it('перенос в другой канал: переписка переезжает, клиент дальше пишет туда; в консьерж — только подписчика', async () => {
const { ctx } = makeCtx(NOW);
const member = await client(ctx, 741, true);
const plain = await client(ctx, 742);
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
const t1 = (await addClientMessage(ctx, member, 'Вопрос про оплату подписки'))!.thread;
const moved = await moveThread(ctx, { threadId: t1.id, channel: 'SUPPORT', byAdminId: op.id });
expect(moved.channel).toBe('SUPPORT');
expect(await db.chatThread.findUnique({ where: { id: t1.id } })).toBeNull();
expect(await db.conciergeMessage.count({ where: { threadId: moved.id, author: 'CLIENT' } })).toBe(1);
expect((await addClientMessage(ctx, member, 'Спасибо'))!.thread.id).toBe(moved.id);
const t2 = (await addClientMessage(ctx, plain, 'Хочу консьержа'))!.thread;
expect(await code(moveThread(ctx, { threadId: t2.id, channel: 'CONCIERGE', byAdminId: op.id }))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
});
it('списки и счётчики: только свои каналы, ждущие ответа — сверху; пустой диалог из карточки не виден', async () => {
const { ctx, clock } = makeCtx(NOW);
const op = await staff('Гор', ['SUPPORT']);
const a = await client(ctx, 751);
const b = await client(ctx, 752);
const m = await client(ctx, 753, true);
await addClientMessage(ctx, a, 'первый');
clock.now = new Date(NOW.getTime() + 60_000);
const tb = (await addClientMessage(ctx, b, 'второй'))!.thread;
await staffSend(ctx, { threadId: tb.id, adminUserId: op.id, text: 'ответили' });
await addClientMessage(ctx, m, 'консьержу');
await openThreadFor(ctx, { clientId: a }, 'CONCIERGE');
const list = await listThreads(ctx, { channels: ['SUPPORT'], filter: 'open', adminUserId: op.id });
expect(list.map((x) => x.clientId)).toEqual([a, b]);
expect(list[0]).toMatchObject({ unread: 1, waitingSince: NOW });
expect(await listThreads(ctx, { channels: ['SUPPORT'], filter: 'mine', adminUserId: op.id })).toHaveLength(1);
expect(await listThreads(ctx, { channels: ['SUPPORT', 'CONCIERGE'], filter: 'open', adminUserId: op.id, search: 'консьерж' })).toHaveLength(1);
const counts = await threadCounts(ctx, { channels: ['SUPPORT', 'CONCIERGE'], adminUserId: op.id });
expect(counts).toMatchObject({ waiting: { SUPPORT: 1, CONCIERGE: 1 }, mine: { SUPPORT: 1, CONCIERGE: 0 }, unassigned: { SUPPORT: 1, CONCIERGE: 1 }, total: 2 });
});
it('уведомления: ответственному, иначе — сотрудникам канала', async () => {
const { ctx } = makeCtx(NOW);
const anna = await staff('Анна', ['CONCIERGE'], 9001);
const gor = await staff('Гор', ['SUPPORT'], 9002);
const boss = await staff('Босс', ['SUPERADMIN'], 9003);
const ids = async (t: { channel: 'SUPPORT' | 'CONCIERGE'; assigneeId: string | null }) => (await chatRecipients(ctx, t)).map((s) => s.name).sort();
expect(await ids({ channel: 'CONCIERGE', assigneeId: null })).toEqual(['Анна', 'Босс']);
expect(await ids({ channel: 'SUPPORT', assigneeId: null })).toEqual(['Босс', 'Гор']);
expect(await ids({ channel: 'SUPPORT', assigneeId: gor.id })).toEqual(['Гор']);
void anna;
void boss;
});
it('ответ из старой админки / из Telegram попадает в последний диалог клиента', async () => {
const { ctx } = makeCtx(NOW);
const clientId = await client(ctx, 761, true);
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
const msg = await replyTo(ctx, { clientId, adminUserId: op.id, text: 'Добрый день' });
expect(msg.threadId).toBe(thread.id);
// клиент без переписки — диалог создаётся в его канале
const fresh = await client(ctx, 762);
const first = await replyTo(ctx, { clientId: fresh, adminUserId: op.id, text: 'Здравствуйте, это поддержка' });
expect((await db.chatThread.findUniqueOrThrow({ where: { id: first.threadId! } })).channel).toBe('SUPPORT');
});
});
+4 -4
View File
@@ -554,7 +554,7 @@ describe('приложение: переезд, Elite, заявки операт
const clientId = await onboardedClient(ctx, 504);
await addClientMessage(ctx, clientId, 'Как продлить визу?');
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
await conversation(ctx, clientId);
await conversation(ctx, { clientId });
expect((await listConversations(ctx))[0]!.unread).toBe(0);
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
@@ -582,12 +582,12 @@ describe('шаблоны переезда и переписка с исполн
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
const { ctx, events } = makeCtx(NOW);
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
const msg = await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь');
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg!.id });
const msg = (await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь'))!.message;
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg.id });
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg!.id } })).readAt).not.toBeNull();
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg.id } })).readAt).not.toBeNull();
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
});
+5 -3
View File
@@ -8,11 +8,13 @@ export default async function setup() {
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
const base = new URL(process.env.DATABASE_URL!);
const testUrl = new URL(base);
testUrl.pathname = '/barev_test';
// TEST_DB_NAME — своя тестовая БД (например, для второй рабочей копии)
const name = process.env.TEST_DB_NAME ?? 'barev_test';
testUrl.pathname = `/${name}`;
const admin = new pg.Client({ connectionString: base.toString() });
await admin.connect();
const exists = await admin.query(`SELECT 1 FROM pg_database WHERE datname = 'barev_test'`);
if (!exists.rowCount) await admin.query('CREATE DATABASE barev_test');
const exists = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [name]);
if (!exists.rowCount) await admin.query(`CREATE DATABASE "${name}"`);
await admin.end();
execSync('npx prisma migrate deploy', {
cwd: path.resolve(import.meta.dirname, '../../db'),
+1 -1
View File
@@ -8,7 +8,7 @@ config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true }
function testUrl() {
const u = new URL(process.env.DATABASE_URL!);
u.pathname = '/barev_test';
u.pathname = `/${process.env.TEST_DB_NAME ?? 'barev_test'}`;
return u.toString();
}