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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/** Совместимость: ответ клиенту */
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user