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

+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();
}