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

+2
View File
@@ -23,6 +23,8 @@ API_URL=http://localhost:4000
CLIENT_MINIAPP_URL=http://localhost:5174
PRO_MINIAPP_URL=http://localhost:5175
ADMIN_URL=http://localhost:5173
# новая админка; если задан https-адрес, кнопка «Открыть» в уведомлениях о сообщениях ведёт в её чаты
BACKOFFICE_URL=http://localhost:5176
API_PORT=4000
# ---- Админка ----
+2 -1
View File
@@ -14,7 +14,8 @@
"typecheck": "tsc --noEmit",
"dev:initdata": "tsx scripts/dev-initdata.ts",
"dev:demo": "tsx scripts/dev-demo.ts",
"dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts"
"dev:bo-demo": "tsx scripts/dev-backoffice-demo.ts",
"dev:chats-demo": "tsx scripts/dev-chats-demo.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
+4 -1
View File
@@ -28,6 +28,9 @@ await db.order.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.subscription.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.cardUsage.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.auditLog.deleteMany({ where: { entityId: { in: oldClientIds } } });
// демо-чаты (dev:chats-demo) ссылаются на клиентов
await db.chatThread.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.conciergeMessage.deleteMany({ where: { clientId: { in: oldClientIds } } });
await db.client.deleteMany({ where: { id: { in: oldClientIds } } });
await db.user.deleteMany({ where: { id: { in: old.map((u) => u.id) } } });
await db.lead.deleteMany({ where: { contact: { startsWith: '+374 99 00' } } });
@@ -40,7 +43,7 @@ const [services, agencies, providers, partners, owner] = await Promise.all([
db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } }),
]);
const PEOPLE: [string, string | null, string, 'ru' | 'en' | 'hy' | 'zh' | 'es'][] = [
const PEOPLE: [string, string | null, string | null, 'ru' | 'en' | 'hy' | 'zh' | 'es'][] = [
['Мария', 'Иванова', 'masha_iv', 'ru'],
['Дмитрий', 'Козлов', 'dkozlov', 'ru'],
['Sarah', 'Miller', 'sarah_m', 'en'],
+195
View File
@@ -0,0 +1,195 @@
/**
* Локальная разработка: демо-переписка для раздела «Чаты» новой админки (этап 2).
* pnpm --filter @barev/api dev:bo-demo — сначала демо-клиенты
* pnpm --filter @barev/api dev:chats-demo — потом диалоги
* Повторный запуск пересоздаёт диалоги демо-клиентов. Добавляет двух сотрудников: консьержа и поддержку.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { getDb } from '@barev/db';
import { putFile } from '@barev/services/storage';
import { env } from '../src/env.ts';
if (env.isProd) throw new Error('Только для разработки');
const db = getDb();
const MIN = 60_000;
const now = Date.now();
const ago = (m: number) => new Date(now - m * MIN);
async function clientByTg(tg: bigint) {
const u = await db.user.findUnique({ where: { telegramId: tg }, include: { client: true } });
if (!u?.client) throw new Error(`Нет демо-клиента ${tg} — сначала запустите pnpm --filter @barev/api dev:bo-demo`);
return u.client;
}
async function staff(name: string, roles: ('SUPPORT' | 'CONCIERGE' | 'SUPERADMIN')[]) {
return (await db.adminUser.findFirst({ where: { name } })) ?? db.adminUser.create({ data: { name, roles } });
}
const [owner, operator, anna, gor] = await Promise.all([
db.adminUser.findFirst({ where: { roles: { has: 'SUPERADMIN' } } }),
db.adminUser.findFirst({ where: { roles: { hasEvery: ['SUPPORT', 'CONCIERGE'] } } }),
staff('Анна Мелконян', ['CONCIERGE']),
staff('Гор Авакян', ['SUPPORT']),
]);
if (!owner || !operator) throw new Error('Нет суперадмина или оператора — запустите seed');
const arsen = await clientByTg(100001003n); // Elite
const dmitry = await clientByTg(100001001n); // Barev+
const maria = await clientByTg(100001000n); // без подписки
const sarah = await clientByTg(100001002n); // Barev+, пишет в поддержку
const alex = await clientByTg(100001005n); // без подписки
const katya = await clientByTg(100001004n); // Barev+
const natalia = await clientByTg(100001007n); // без подписки
const lilit = await clientByTg(100001025n); // Barev+
const provider = await db.provider.findFirst({ where: { userId: { not: null }, active: true }, orderBy: { createdAt: 'asc' } });
const clientIds = [arsen, dmitry, maria, sarah, alex, katya, natalia, lilit].map((c) => c.id);
await db.chatThread.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, ...(provider ? [{ providerId: provider.id }] : [])] } });
await db.conciergeMessage.deleteMany({ where: { OR: [{ clientId: { in: clientIds } }, ...(provider ? [{ providerId: provider.id }] : [])] } });
await db.client.updateMany({ where: { id: { in: clientIds } }, data: { chatChannel: null } });
await db.client.update({ where: { id: sarah.id }, data: { chatChannel: 'SUPPORT' } });
// файлы: фото от клиента и PDF от команды — в локальное хранилище
const root = path.resolve(import.meta.dirname, '../../..');
const photo = readFileSync(path.join(root, 'flowers/S.jpg'));
const photoKey = await putFile('chats/demo', photo, 'image/jpeg', 'kitchen.jpg');
const pdf = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 300 144]/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj 4 0 obj<</Length 52>>stream\nBT /F1 18 Tf 30 70 Td (Barev Club - checklist) Tj ET\nendstream endobj 5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF',
);
const pdfKey = await putFile('chats/demo', pdf, 'application/pdf', 'Чек-лист переезда.pdf');
type Msg = {
author: 'CLIENT' | 'PROVIDER' | 'OPERATOR' | 'SYSTEM';
text: string;
at: number;
by?: string;
internal?: boolean;
file?: { fileKey: string; fileName: string; mime: string; size: number };
param?: string;
deliveryError?: string;
source?: string;
};
async function thread(
peer: { clientId: string } | { providerId: string },
channel: 'SUPPORT' | 'CONCIERGE',
opts: { assigneeId?: string; status?: 'OPEN' | 'CLOSED'; waiting?: number },
messages: Msg[],
) {
const last = Math.min(...messages.map((m) => m.at));
const t = await db.chatThread.create({
data: {
...peer,
channel,
status: opts.status ?? 'OPEN',
assigneeId: opts.assigneeId,
lastMessageAt: ago(last),
waitingSince: opts.waiting != null ? ago(opts.waiting) : null,
closedAt: opts.status === 'CLOSED' ? ago(last) : null,
createdAt: ago(Math.max(...messages.map((m) => m.at))),
},
});
for (const m of messages) {
const incoming = m.author === 'CLIENT' || m.author === 'PROVIDER';
await db.conciergeMessage.create({
data: {
threadId: t.id,
...peer,
author: m.author,
adminUserId: m.by,
text: m.text,
internal: m.internal ?? m.author === 'SYSTEM',
source: m.source ?? (incoming ? 'BOT' : 'ADMIN'),
fileName: m.file?.fileName ?? m.param ?? null,
fileKey: m.file?.fileKey,
mime: m.file?.mime,
size: m.file?.size,
deliveryError: m.deliveryError,
// ждущие ответа входящие ещё не прочитаны
readAt: incoming && opts.waiting != null && m.at <= opts.waiting ? null : ago(m.at - 1),
createdAt: ago(m.at),
},
});
}
}
const kitchen = { fileKey: photoKey, fileName: 'kitchen.jpg', mime: 'image/jpeg', size: photo.length };
const checklist = { fileKey: pdfKey, fileName: 'Чек-лист переезда.pdf', mime: 'application/pdf', size: pdf.length };
// ── консьерж ──
await thread({ clientId: arsen.id }, 'CONCIERGE', { assigneeId: anna.id, waiting: 4 }, [
{ author: 'CLIENT', text: 'Добрый вечер! В субботу у жены день рождения. Можете забронировать столик на четверых где-нибудь с видом на Арарат?', at: 190 },
{ author: 'SYSTEM', text: 'took', at: 186, by: anna.id },
{ author: 'OPERATOR', text: 'Добрый вечер, Арсен! Поздравляем вашу супругу 🌸 Во сколько удобно и есть ли пожелания по кухне?', at: 185, by: anna.id },
{ author: 'CLIENT', text: 'К 19:00. Армянская или грузинская, без громкой музыки.', at: 170 },
{ author: 'OPERATOR', text: 'Elite: предпочитает звонок после 18:00, в прошлый раз просил столик у окна.', at: 168, by: anna.id, internal: true },
{ author: 'OPERATOR', text: 'Предлагаю «Lavash» на Туманяна или «Ташир Пицца» на крыше Каскада. В «Lavash» есть тихий зал и вид на Арарат из окон. Бронировать?', at: 150, by: anna.id },
{ author: 'CLIENT', text: 'Давайте Lavash. И можно торт заказать туда же?', at: 4 },
]);
await thread({ clientId: dmitry.id }, 'CONCIERGE', { waiting: 18 }, [
{ author: 'CLIENT', text: 'Здравствуйте! Нужен нотариус с переводом паспорта на армянский — на этой неделе. Сколько это стоит и где ближе к Кентрону?', at: 20 },
{ author: 'CLIENT', text: 'И ещё вопрос: на кухне после клининга остались разводы на фасадах, вот фото.', at: 18, file: kitchen },
]);
await thread({ clientId: katya.id }, 'CONCIERGE', { assigneeId: owner.id }, [
{ author: 'CLIENT', text: 'Подскажите, что нужно сделать в первую неделю после переезда?', at: 60 * 26 },
{ author: 'OPERATOR', text: 'Екатерина, собрали для вас чек-лист: SIM-карта, банк, регистрация, соцкарта. Всё по шагам в файле.', at: 60 * 25, by: owner.id, file: checklist },
{ author: 'CLIENT', text: 'Спасибо огромное, очень удобно!', at: 60 * 24 },
{ author: 'OPERATOR', text: 'Рады помочь! Если с банком будут сложности — напишите, подскажем отделение, где говорят по-русски.', at: 60 * 24 - 5, by: owner.id },
]);
await thread({ clientId: lilit.id }, 'CONCIERGE', { assigneeId: anna.id, waiting: 9 }, [
{ author: 'CLIENT', text: 'Բարև ձեզ, ուզում եմ տունը մաքրել շաբաթ օրը', at: 40 },
{ author: 'SYSTEM', text: 'moved', at: 35, by: gor.id, param: 'CONCIERGE' },
{ author: 'OPERATOR', text: 'Բարև, Լիլիթ։ Շաբաթ օրը ազատ է 11:00-ից։ Քանի՞ սենյակ է։', at: 30, by: anna.id },
{ author: 'CLIENT', text: 'Երեք սենյակ, խնդրում եմ 11:00', at: 9, source: 'MINIAPP' },
]);
// ── поддержка ──
await thread({ clientId: sarah.id }, 'SUPPORT', { waiting: 52 }, [
{ author: 'CLIENT', text: 'Hi! The cleaner was supposed to come at 10:00 but nobody showed up. Order #1042. Could you check please?', at: 52 },
{ author: 'CLIENT', text: 'Still waiting…', at: 31 },
]);
await thread({ clientId: maria.id }, 'SUPPORT', { assigneeId: operator.id }, [
{ author: 'CLIENT', text: 'Можно перенести уборку с четверга на пятницу?', at: 95 },
{ author: 'SYSTEM', text: 'took', at: 90, by: operator.id },
{ author: 'OPERATOR', text: 'Мария, да, перенесли на пятницу 12:00. Исполнитель тот же.', at: 88, by: operator.id },
{ author: 'CLIENT', text: 'Отлично, спасибо!', at: 80 },
{ author: 'OPERATOR', text: 'Хорошего дня 🙂', at: 79, by: operator.id },
]);
await thread({ clientId: natalia.id }, 'SUPPORT', { assigneeId: gor.id }, [
{ author: 'CLIENT', text: 'Как получить подарочный месяц Barev+?', at: 60 * 5 },
{ author: 'OPERATOR', text: 'Наталья, подарочный месяц включается по коду агентства. Отправьте код командой /code.', at: 60 * 4, by: gor.id, deliveryError: 'UNREACHABLE' },
{ author: 'OPERATOR', text: 'Клиентка заблокировала бота — сообщение не дошло. Позвонить по телефону из карточки.', at: 60 * 4 - 2, by: gor.id, internal: true },
]);
await thread({ clientId: alex.id }, 'SUPPORT', { assigneeId: gor.id, status: 'CLOSED' }, [
{ author: 'CLIENT', text: 'Hello, do you work on Sundays?', at: 60 * 30 },
{ author: 'OPERATOR', text: 'Hi Alex! Yes, cleaning is available on Sundays from 10:00 to 18:00.', at: 60 * 29, by: gor.id },
{ author: 'CLIENT', text: 'Great, thanks', at: 60 * 29 - 10 },
{ author: 'SYSTEM', text: 'closed', at: 60 * 29 - 12, by: gor.id },
]);
if (provider) {
await thread({ providerId: provider.id }, 'SUPPORT', { waiting: 7 }, [
{ author: 'PROVIDER', text: 'Здравствуйте, по заказу на Абовяна нет кода от подъезда, клиент не отвечает. Что делать?', at: 7 },
]);
}
// ── шаблоны ответов ──
const TEMPLATES: { channel: 'SUPPORT' | 'CONCIERGE' | null; title: string; text: string }[] = [
{ channel: null, title: 'Приветствие', text: 'Здравствуйте, {name}! Чем можем помочь?' },
{ channel: null, title: 'Уточняем', text: '{name}, уточняем детали и вернёмся с ответом в течение 15 минут.' },
{ channel: 'SUPPORT', title: 'Перенос заказа', text: 'Перенесли заказ. Исполнитель получил новое время, напоминание придёт за час.' },
{ channel: 'CONCIERGE', title: 'Бронь готова', text: 'Готово! Забронировали на ваше имя. Если планы изменятся — просто напишите сюда.' },
];
await db.chatTemplate.deleteMany({ where: { title: { in: TEMPLATES.map((t) => t.title) } } });
for (const t of TEMPLATES) await db.chatTemplate.create({ data: { ...t, createdById: owner.id } });
console.log(`Демо-чаты готовы: ${provider ? 9 : 8} диалогов, ${TEMPLATES.length} шаблона. Сотрудники: ${anna.name} (консьерж), ${gor.name} (поддержка).`);
process.exit(0);
+108
View File
@@ -0,0 +1,108 @@
import {
assignThread,
closeThread,
deleteTemplate,
getThreadForStaff,
listTemplates,
listThreads,
moveThread,
openThreadFor,
reopenThread,
saveTemplate,
staffSend,
threadCounts,
type ChatChannel,
} from '@barev/services';
import type { Db } from '@barev/db';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, type StaffRole } from '../../trpc.ts';
const CHANNELS = ['SUPPORT', 'CONCIERGE'] as const;
/** Какие каналы видит сотрудник: поддержка — SUPPORT, консьерж — CONCIERGE, суперадмин — оба */
export function channelsFor(roles: StaffRole[], isSuper: boolean): ChatChannel[] {
if (isSuper) return ['SUPPORT', 'CONCIERGE'];
return CHANNELS.filter((c) => roles.includes(c));
}
const chatProcedure = rolesProcedure('SUPPORT', 'CONCIERGE').use(async ({ ctx, next }) => next({ ctx: { channels: channelsFor(ctx.roles, ctx.isSuper) } }));
/** Диалог существует и его канал доступен этому сотруднику */
async function guard(ctx: { db: Db; channels: ChatChannel[] }, id: string) {
const t = await ctx.db.chatThread.findUnique({ where: { id } });
if (!t) throw new TRPCError({ code: 'NOT_FOUND' });
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return t;
}
const filter = z.enum(['mine', 'unassigned', 'waiting', 'open', 'closed', 'all']);
export const boChatsRouter = router({
counts: chatProcedure.query(({ ctx }) => threadCounts(ctx.svc, { channels: ctx.channels, adminUserId: ctx.admin.id })),
list: chatProcedure
.input(z.object({ channel: z.enum(CHANNELS).optional(), filter, search: z.string().trim().max(100).optional() }))
.query(({ ctx, input }) => listThreads(ctx.svc, { ...input, channels: ctx.channels, adminUserId: ctx.admin.id })),
get: chatProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
await guard(ctx, input.id);
return getThreadForStaff(ctx.svc, input.id);
}),
send: chatProcedure
.input(z.object({ id: z.string(), text: z.string().max(4000), internal: z.boolean().default(false) }))
.mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
const m = await staffSend(ctx.svc, { threadId: input.id, adminUserId: ctx.admin.id, text: input.text, internal: input.internal });
return { id: m.id };
}),
/** Взять себе (assigneeId = я), передать коллеге или снять ответственного (null) */
assign: chatProcedure.input(z.object({ id: z.string(), assigneeId: z.string().nullable() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await assignThread(ctx.svc, { threadId: input.id, assigneeId: input.assigneeId, byAdminId: ctx.admin.id });
}),
close: chatProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await closeThread(ctx.svc, { threadId: input.id, byAdminId: ctx.admin.id });
}),
reopen: chatProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await reopenThread(ctx.svc, { threadId: input.id, byAdminId: ctx.admin.id });
}),
/** Перенести в другой канал; диалог остаётся видимым, только если новый канал доступен сотруднику */
move: chatProcedure.input(z.object({ id: z.string(), channel: z.enum(CHANNELS) })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
const t = await moveThread(ctx.svc, { threadId: input.id, channel: input.channel, byAdminId: ctx.admin.id });
return { id: t.id, visible: ctx.channels.includes(t.channel) };
}),
/** Написать клиенту из карточки: открывает диалог в канале, куда он пишет (или в указанном) */
openFor: chatProcedure.input(z.object({ clientId: z.string(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => {
const channel = input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined);
const t = await openThreadFor(ctx.svc, { clientId: input.clientId }, channel);
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return { id: t.id };
}),
/** Кому можно передать диалог этого канала */
colleagues: chatProcedure.input(z.object({ channel: z.enum(CHANNELS) })).query(async ({ ctx, input }) => {
const staff = await ctx.db.adminUser.findMany({ where: { active: true, roles: { hasSome: ['SUPERADMIN', input.channel] } }, orderBy: { name: 'asc' } });
return staff.map((s) => ({ id: s.id, name: s.name, roles: s.roles, telegramLinked: !!s.telegramId }));
}),
templates: router({
list: chatProcedure.query(({ ctx }) => listTemplates(ctx.svc, ctx.channels)),
save: chatProcedure
.input(z.object({ id: z.string().optional(), channel: z.enum(CHANNELS).nullable(), title: z.string().trim().min(1).max(80), text: z.string().trim().min(1).max(4000) }))
.mutation(async ({ ctx, input }) => {
if (input.channel && !ctx.channels.includes(input.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return saveTemplate(ctx.svc, { ...input, adminUserId: ctx.admin.id });
}),
remove: chatProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => deleteTemplate(ctx.svc, input.id)),
}),
});
+4 -2
View File
@@ -1,5 +1,6 @@
import { DAY, startOfYerevanDay } from '@barev/core';
import { unreadConciergeCount } from '@barev/services';
import { threadCounts } from '@barev/services';
import { channelsFor } from './chats.ts';
import { router, staffProcedure } from '../../trpc.ts';
/** День по Еревану (UTC+4, без перехода на летнее время) */
@@ -16,7 +17,8 @@ export const boDashboardRouter = router({
const now = ctx.svc.now();
const [unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring] = await Promise.all([
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
unreadConciergeCount(ctx.svc),
// диалоги, которые ждут ответа, — только в каналах, доступных сотруднику
threadCounts(ctx.svc, { channels: channelsFor(ctx.roles, ctx.isSuper), adminUserId: ctx.admin.id }).then((c) => c.total),
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.lead.count({ where: { status: 'NEW' } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
+2
View File
@@ -2,6 +2,7 @@ import { router } from '../../trpc.ts';
import { boAuthRouter, boTeamRouter } from './auth.ts';
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
import { boDashboardRouter } from './dashboard.ts';
import { boChatsRouter } from './chats.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
@@ -10,4 +11,5 @@ export const backofficeRouter = router({
dashboard: boDashboardRouter,
clients: boClientsRouter,
segments: boSegmentsRouter,
chats: boChatsRouter,
});
+13
View File
@@ -27,6 +27,9 @@ import {
toggleRelocationDoc,
cardQrToken,
clientPartners,
clientConversation,
receiveMessage,
setClientChannel,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@@ -232,4 +235,14 @@ export const clientRouter = router({
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'CLIENT', actorId: ctx.client.id } });
return { url: await signedUrl(file.storageKey) };
}),
/** Чат с командой: та же переписка, что в боте; ответы приходят и сюда, и в чат с ботом */
chat: router({
get: clientProcedure.query(({ ctx }) => clientConversation(ctx.svc, ctx.client.id)),
send: consentedClientProcedure.input(z.object({ text: z.string().trim().min(1).max(4000) })).mutation(async ({ ctx, input }) => {
const res = await receiveMessage(ctx.svc, { clientId: ctx.client.id }, { text: input.text, source: 'MINIAPP' });
return { id: res?.message.id ?? null, channel: res?.channel ?? null };
}),
setChannel: consentedClientProcedure.input(z.object({ channel: z.enum(['SUPPORT', 'CONCIERGE']) })).mutation(({ ctx, input }) => setClientChannel(ctx.svc, ctx.client.id, input.channel)),
}),
});
+114 -4
View File
@@ -1,16 +1,24 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import multipart, { type MultipartFile } from '@fastify/multipart';
import * as Sentry from '@sentry/node';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { getFlow } from '@barev/core';
import { DomainError, proIdentity, setChecklistItem } from '@barev/services';
import Fastify from 'fastify';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend } from '@barev/services';
import { getFile, putFile } from '@barev/services/storage';
import { TelegramSender } from '@barev/services/telegram';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
import { validateInitData } from './auth/telegram.ts';
import { env } from './env.ts';
import { ctx as svc, db, ensureBucket, putObject } from './infra.ts';
import { appRouter, type AppRouter } from './router.ts';
import { createContext } from './trpc.ts';
import { channelsFor } from './routers/backoffice/chats.ts';
import { createContext, type StaffRole } from './trpc.ts';
/** Скачивание файлов, присланных ботам, — по их токенам */
const clientBotFiles = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
const proBotFiles = new TelegramSender(env.PRO_BOT_TOKEN, 'pro-bot');
if (env.SENTRY_DSN) Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
@@ -82,6 +90,108 @@ app.post('/files/pro/checklist-photo', async (req, reply) => {
return { fileId: file.id };
});
// ───── файлы в чатах ─────
/** Что можно прислать в чат: картинки, PDF, офисные документы, текст, архив */
const CHAT_TYPES = /^(image\/(jpeg|png|webp|gif|heic)|application\/pdf|application\/msword|application\/vnd\.openxmlformats-officedocument\.[\w.]+|application\/vnd\.ms-excel|text\/plain|application\/zip)$/;
/** Сотрудник новой админки по cookie-сессии и каналы, которые ему доступны */
async function staffFromCookie(req: FastifyRequest) {
const session = await findSession(db, req.cookies[ADMIN_COOKIE]);
if (!session?.verified || session.adminUser.roles.length === 0) return null;
const roles = session.adminUser.roles as StaffRole[];
return { admin: session.adminUser, channels: channelsFor(roles, roles.includes('SUPERADMIN')) };
}
function multipartField(part: MultipartFile, name: string) {
const f = part.fields[name];
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
}
/** Имя файла как пришло, но без путей и управляющих символов */
const safeName = (name: string | undefined) => (name ?? 'file').replace(/[\\/\u0000-\u001f]/g, '_').slice(-120) || 'file';
/** Команда отправляет файл в диалог (или во внутреннюю заметку) */
app.post<{ Params: { threadId: string } }>('/files/bo/chat/:threadId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
const thread = await db.chatThread.findUnique({ where: { id: req.params.threadId } });
if (!thread) return reply.code(404).send({ error: 'NOT_FOUND' });
if (!staff.channels.includes(thread.channel)) return reply.code(403).send({ error: 'ROLE_FORBIDDEN' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`chats/${thread.id}`, body, part.mimetype, fileName);
try {
const m = await staffSend(svc, {
threadId: thread.id,
adminUserId: staff.admin.id,
text: multipartField(part, 'text') ?? '',
internal: multipartField(part, 'internal') === 'true',
file: { fileKey, fileName, mime: part.mimetype, size: body.length },
});
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.chats.sendFile', entity: 'chats', entityId: thread.id, data: { fileName, size: body.length } } });
return { id: m.id };
} catch (e) {
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
throw e;
}
});
/** Отдать файл сообщения: из нашего хранилища или из Telegram (бот скачивает по file_id) */
async function sendChatFile(reply: FastifyReply, m: { fileKey: string | null; tgFileId: string | null; tgBot: string | null; mime: string | null; fileName: string | null }) {
reply.header('cache-control', 'private, max-age=3600');
reply.header('content-type', m.mime ?? 'application/octet-stream');
reply.header('content-disposition', `inline; filename*=UTF-8''${encodeURIComponent(m.fileName ?? 'file')}`);
reply.header('x-content-type-options', 'nosniff');
if (m.fileKey) return reply.send(await getFile(m.fileKey));
if (m.tgFileId) {
const res = await (m.tgBot === 'pro' ? proBotFiles : clientBotFiles).download(m.tgFileId);
if (!res?.body) return reply.code(404).send({ error: 'FILE_UNAVAILABLE' });
return reply.send(Buffer.from(await res.arrayBuffer()));
}
return reply.code(404).send({ error: 'NOT_FOUND' });
}
app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId }, include: { thread: { select: { channel: true } } } });
if (!m?.thread || !staff.channels.includes(m.thread.channel)) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, m);
});
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
function clientFromInit(req: FastifyRequest) {
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
const init = validateInitData(auth, env.clientBotToken, env.TG_INIT_DATA_MAX_AGE);
return init.ok ? getClientByTelegramId(db, BigInt(init.user.id)) : Promise.resolve(null);
}
app.get<{ Params: { messageId: string } }>('/files/client/chat-file/:messageId', async (req, reply) => {
const client = await clientFromInit(req);
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId } });
if (!m || m.clientId !== client.id || m.internal) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, m);
});
app.post('/files/client/chat', async (req, reply) => {
const client = await clientFromInit(req);
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
if (!client.consentAt) return reply.code(412).send({ error: 'CONSENT_REQUIRED' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`chats/client-${client.id}`, body, part.mimetype, fileName);
const res = await receiveMessage(svc, { clientId: client.id }, { text: multipartField(part, 'text') ?? '', source: 'MINIAPP', file: { fileKey, fileName, mime: part.mimetype, size: body.length } });
return { id: res?.message.id ?? null };
});
/** Ошибки из Mini App и админки (sendBeacon) — только в лог, без хранения */
app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
const b = (typeof req.body === 'object' && req.body) || {};
+6 -2
View File
@@ -10,6 +10,7 @@ import { Clients } from './pages/Clients.tsx';
import { ClientCard } from './pages/ClientCard.tsx';
import { Team } from './pages/Team.tsx';
import { Soon } from './pages/Soon.tsx';
import { Chats } from './pages/Chats.tsx';
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
export interface Me {
@@ -42,7 +43,7 @@ interface NavItem {
const NAV: NavItem[] = [
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
{ path: '/chats', key: 'nav.chats', icon: 'chats', stage: 2, roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', stage: 3, roles: ['SUPPORT'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, roles: ['SUPPORT'], group: 'work' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' },
@@ -71,7 +72,10 @@ function Shell({ me }: { me: Me }) {
const current = items.find((n) => n.path === path || (n.path !== '/' && path.startsWith(`${n.path}/`)));
let page: ReactNode;
const clientId = match('/clients/:id', path)?.id;
const threadId = match('/chats/:id', path)?.id;
const canChat = items.some((n) => n.path === '/chats');
if (path === '/') page = <Dashboard me={me} />;
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
else if (path === '/clients' && current) page = <Clients me={me} />;
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
else if (path === '/team' && isSuper) page = <Team me={me} />;
@@ -82,7 +86,7 @@ function Shell({ me }: { me: Me }) {
const tabItems = items.slice(0, 4);
return (
<div className="bo-layout">
<div className={`bo-layout${path.startsWith('/chats') ? ' bo-layout--chats' : ''}${threadId ? ' bo-layout--thread' : ''}`}>
<aside className="bo-side">
<div className="bo-side__logo">
<Logo size={22} color="#f6e9dc" />
+789
View File
@@ -0,0 +1,789 @@
/*
* Чаты (этап 2). Три колонки: список диалогов · переписка · о клиенте.
* Переписка — на кремовом фоне: клиент слева белым, команда справа сливовым, заметки — жёлтым «стикером».
*/
/* страница чатов занимает ровно экран: прокручиваются список и лента, а не страница */
.bo-layout--chats .bo-main {
padding-top: 20px;
padding-bottom: 20px;
height: 100dvh;
display: flex;
flex-direction: column;
}
.bo-layout--chats .bo-banner {
margin-bottom: 14px;
}
.bo-chat {
flex: 1;
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 14px;
min-height: 0;
}
.bo-chat--side {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) 300px;
}
.bo-chat__list,
.bo-chat__conv,
.bo-chat__side {
background: var(--bo-panel);
border-radius: var(--bo-r-panel);
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.bo-chat__listhead {
padding: 18px 16px 10px;
display: flex;
flex-direction: column;
gap: 12px;
border-bottom: 1px solid var(--bc-line);
}
.bo-chat__title {
font-size: 30px;
}
.bo-chtabs {
display: flex;
padding: 4px;
gap: 4px;
border-radius: var(--bc-pill);
background: var(--bo-soft);
}
.bo-chtabs button {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 34px;
border: 0;
border-radius: var(--bc-pill);
background: none;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
cursor: pointer;
}
.bo-chtabs button[aria-pressed='true'] {
background: #fff;
color: var(--bc-ink);
box-shadow: 0 2px 8px rgba(30, 27, 26, 0.08);
}
.bo-chat__dot {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: var(--bc-pill);
background: var(--bc-accent);
color: #fff;
font-size: 11px;
font-weight: 700;
display: inline-grid;
place-items: center;
}
.bo-chat__search {
flex: none;
max-width: none;
}
.bo-chat__search .bo-input {
background: var(--bo-soft);
min-height: 40px;
}
.bo-chat__filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.bo-chat__filters .bo-pill {
height: 30px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
}
.bo-chat__pillnum {
font-weight: 700;
color: var(--bc-accent);
}
.bo-pill[aria-pressed='true'] .bo-chat__pillnum {
color: var(--bc-pink);
}
.bo-chat__items {
list-style: none;
margin: 0;
padding: 6px;
overflow-y: auto;
flex: 1;
}
.bo-chat__none {
padding: 32px 20px;
text-align: center;
color: var(--bc-ink-2);
}
.bo-thread {
display: flex;
gap: 12px;
padding: 12px 10px;
border-radius: 18px;
color: inherit;
text-decoration: none;
}
.bo-thread:hover {
background: var(--bo-soft);
}
.bo-thread[aria-current='true'] {
background: var(--bo-soft);
box-shadow: inset 3px 0 0 var(--bc-accent);
}
.bo-thread[data-closed] {
opacity: 0.62;
}
.bo-thread__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.bo-thread__top {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.bo-thread__top .bo-plan {
height: 20px;
padding: 0 7px;
font-size: 11px;
}
.bo-thread__name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.bo-thread__time {
margin-left: auto;
font-size: 12px;
color: var(--bc-ink-3);
white-space: nowrap;
}
.bo-thread__last {
font-size: 14px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-thread__meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
min-height: 20px;
min-width: 0;
}
.bo-thread__meta .bo-nav__count {
margin-left: 0;
flex: none;
}
.bo-thread__who {
font-size: 12px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.bo-thread__who--none {
color: var(--bc-ink-3);
}
.bo-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 8px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
background: var(--bo-soft);
color: var(--bc-ink-2);
}
.bo-chip--SUPPORT {
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
.bo-chip--CONCIERGE {
background: var(--bc-pink);
color: var(--bc-plum);
}
.bo-chip--provider {
background: var(--bc-docs);
color: var(--bc-docs-ink);
}
.bo-wait-chip {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: var(--bc-pill);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.bo-wait-chip--calm {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bo-wait-chip--warm {
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-wait-chip--hot {
background: var(--bc-accent);
color: #fff;
}
/* пустое состояние справа */
.bo-chat__pick {
margin: auto;
padding: 32px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bo-chat__pickicon {
width: 64px;
height: 64px;
border-radius: 22px;
display: grid;
place-items: center;
background: var(--bc-pink);
color: var(--bc-plum);
margin-bottom: 8px;
}
/* шапка переписки */
.bo-chat__conv {
container-type: inline-size;
}
.bo-conv__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--bc-line);
}
@container (max-width: 640px) {
.bo-composer__hint {
display: none;
}
}
.bo-conv__back {
display: none;
color: var(--bc-ink);
}
.bo-conv__who {
min-width: 0;
flex: 1 1 180px;
}
.bo-conv__name {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 18px;
white-space: nowrap;
min-width: 0;
}
.bo-conv__name > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.bo-conv__sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 13px;
color: var(--bc-ink-3);
margin-top: 3px;
}
.bo-conv__actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.bo-iconbtn {
width: 36px;
height: 36px;
border-radius: 12px;
border: 0;
background: none;
color: var(--bc-ink-2);
display: inline-grid;
place-items: center;
cursor: pointer;
flex: none;
}
.bo-iconbtn:hover:not(:disabled),
.bo-iconbtn[aria-expanded='true'],
.bo-iconbtn[aria-pressed='true'] {
background: var(--bo-soft);
color: var(--bc-ink);
}
.bo-iconbtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bo-assignee {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 12px 0 4px;
border-radius: var(--bc-pill);
border: 1.5px solid var(--bc-line);
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.bo-assignee .bo-avatar {
font-size: 10px;
}
.bo-conv__banner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
font-size: 14px;
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
.bo-conv__banner--bot {
background: var(--bc-learn);
color: var(--bc-learn-ink);
}
.bo-conv__closed {
padding: 8px 18px;
font-size: 13px;
color: var(--bc-ink-2);
background: var(--bc-bg);
text-align: center;
}
/* лента сообщений */
.bo-msgs {
flex: 1;
overflow-y: auto;
background: var(--bc-bg);
padding: 16px clamp(12px, 3vw, 28px) 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.bo-msgs__day {
align-self: center;
margin: 12px 0 6px;
padding: 3px 12px;
border-radius: var(--bc-pill);
background: #fff;
font-size: 12px;
font-weight: 600;
color: var(--bc-ink-3);
}
.bo-msg {
max-width: min(560px, 82%);
padding: 10px 14px 8px;
border-radius: 20px;
font-size: 15px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.bo-msg--in {
align-self: flex-start;
background: #fff;
border-bottom-left-radius: 6px;
box-shadow: 0 1px 0 var(--bc-line);
}
.bo-msg--out {
align-self: flex-end;
background: var(--bc-plum);
color: #f6e9dc;
border-bottom-right-radius: 6px;
}
.bo-msg--note {
align-self: flex-end;
background: #fbefbd;
color: var(--bc-perks-ink);
border: 1.5px dashed rgba(74, 16, 40, 0.22);
border-bottom-right-radius: 6px;
}
.bo-msg__text {
white-space: pre-wrap;
}
.bo-msg__label {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
margin-bottom: 4px;
opacity: 0.75;
}
.bo-msg__meta {
margin-top: 4px;
font-size: 11px;
opacity: 0.6;
text-align: right;
}
.bo-msg--in .bo-msg__meta {
text-align: left;
}
.bo-msg__err {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 5px 8px;
border-radius: 10px;
background: #fff;
color: var(--bc-danger);
font-size: 12px;
font-weight: 500;
}
.bo-msg__img {
display: block;
margin: -4px -8px 6px;
}
.bo-msg__img img {
display: block;
max-width: 100%;
max-height: 280px;
border-radius: 14px;
object-fit: cover;
}
.bo-msg__file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 8px 8px;
margin: 0 -6px 6px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.14);
color: inherit;
text-decoration: none;
}
.bo-msg--in .bo-msg__file,
.bo-msg--note .bo-msg__file {
background: var(--bo-soft);
}
.bo-msg__file svg {
flex: none;
}
.bo-msg__fname {
display: block;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-sys {
align-self: center;
font-size: 12px;
color: var(--bc-ink-2);
text-align: center;
padding: 2px 8px;
}
/* поле ответа */
.bo-composer {
padding: 8px 14px 14px;
border-top: 1px solid var(--bc-line);
background: #fff;
}
.bo-composer__tabs {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
}
.bo-composer__tabs [role='tab'] {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 12px;
border: 0;
border-radius: var(--bc-pill);
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--bc-ink-3);
cursor: pointer;
}
.bo-composer__tabs [role='tab'][aria-selected='true'] {
background: var(--bc-ink);
color: #fff;
}
.bo-composer--note .bo-composer__tabs [role='tab'][aria-selected='true'] {
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-composer__tabs [role='tab']:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bo-composer__hint {
font-size: 12px;
color: var(--bc-ink-3);
}
.bo-composer__box {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 6px 6px 6px 14px;
border-radius: 22px;
border: 1.5px solid var(--bc-line);
background: #fff;
}
.bo-composer__box:focus-within {
border-color: var(--bc-accent);
}
.bo-composer--note .bo-composer__box {
background: #fdf6d8;
border-style: dashed;
border-color: rgba(74, 16, 40, 0.25);
}
.bo-composer__input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
resize: none;
background: none;
font: inherit;
font-size: 15px;
line-height: 1.45;
padding: 7px 0;
max-height: 220px;
}
.bo-composer__tools {
display: flex;
align-items: center;
gap: 2px;
}
/* всплывающие меню */
.bo-pop-anchor {
position: relative;
display: inline-flex;
}
.bo-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 50;
min-width: 220px;
max-width: min(340px, 86vw);
padding: 6px;
border-radius: 18px;
background: #fff;
box-shadow:
0 18px 50px rgba(30, 27, 26, 0.18),
0 0 0 1px var(--bc-line);
display: flex;
flex-direction: column;
gap: 2px;
}
.bo-pop--up {
top: auto;
bottom: calc(100% + 8px);
}
.bo-pop__item {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 6px 12px;
border: 0;
border-radius: 12px;
background: none;
font: inherit;
font-size: 14px;
text-align: left;
color: var(--bc-ink);
cursor: pointer;
}
.bo-pop__item:hover {
background: var(--bo-soft);
}
.bo-pop__item[aria-current='true'] {
color: var(--bc-ink-3);
}
.bo-tpl {
width: 340px;
padding: 10px;
gap: 8px;
}
.bo-tpl__list {
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.bo-tpl__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 0;
border-radius: 12px;
background: none;
text-align: left;
font: inherit;
font-size: 14px;
cursor: pointer;
color: var(--bc-ink);
}
.bo-tpl__item span {
font-size: 13px;
color: var(--bc-ink-2);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bo-tpl__item:hover {
background: var(--bo-soft);
}
/* о клиенте */
.bo-chat__side {
padding: 16px 18px 20px;
overflow-y: auto;
}
.bo-side-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.bo-minipass {
display: flex;
flex-direction: column;
gap: 2px;
padding: 14px 16px;
border-radius: 20px;
background: var(--bo-soft);
font-size: 13px;
}
.bo-minipass__plan {
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 20px;
}
.bo-minipass--BAREV_PLUS {
background: var(--bc-pink);
color: var(--bc-plum);
}
.bo-minipass--ELITE {
background: var(--bc-plum);
color: var(--bc-perks);
}
.bo-side-panel__note {
margin: 0;
padding: 10px 12px;
border-radius: 14px;
background: #fbefbd;
color: var(--bc-perks-ink);
font-size: 13px;
white-space: pre-line;
}
.bo-side-panel__orders {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
font-size: 14px;
}
/* узкий десктоп: «о клиенте» — выезжающая панель поверх */
@media (max-width: 1559px) {
.bo-chat--side {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
}
.bo-chat__side {
position: fixed;
top: 12px;
right: 12px;
bottom: 12px;
width: min(340px, calc(100vw - 24px));
z-index: 45;
box-shadow: 0 30px 80px rgba(30, 27, 26, 0.28);
}
}
/* телефон: список и переписка — отдельными экранами */
@media (max-width: 860px) {
.bo-layout--chats .bo-main {
padding: 12px 10px calc(96px + env(safe-area-inset-bottom));
}
.bo-layout--thread .bo-main {
padding-bottom: 10px;
}
.bo-layout--thread .bo-tabbar,
.bo-layout--thread .bo-banner {
display: none;
}
.bo-chat,
.bo-chat--side {
grid-template-columns: minmax(0, 1fr);
}
.bo-chat--thread .bo-chat__list,
.bo-chat:not(.bo-chat--thread) .bo-chat__conv {
display: none;
}
.bo-conv__back {
display: inline-grid;
}
.bo-conv__head {
padding: 10px 10px 10px 12px;
gap: 8px;
}
.bo-conv__head > .bo-avatar {
display: none;
}
.bo-hide-sm {
display: none;
}
.bo-msg {
max-width: 88%;
}
.bo-composer {
padding: 6px 8px 10px;
}
.bo-tpl {
width: min(320px, calc(100vw - 40px));
}
}
+10
View File
@@ -27,6 +27,16 @@ const PATHS = {
arrowUp: 'M12 19V5M6 11l6-6 6 6',
arrowDown: 'M12 5v14M6 13l6 6 6-6',
external: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
clip: 'M20 11.5l-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8',
bolt: 'M13 3L5 13.5h6L10 21l8-10.5h-6z',
lock: 'M6 11h12v9H6zM8.5 11V8a3.5 3.5 0 0 1 7 0v3',
file: 'M7 3h7l5 5v13H7zM14 3v5h5',
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5M12 7.5v.01',
swap: 'M4 8h14l-3.5-3.5M20 16H6l3.5 3.5',
userPlus: 'M10 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM3 21a7 7 0 0 1 12-4.9M19 14v6M16 17h6',
archive: 'M4 5h16v4H4zM5 9v10h14V9M10 13h4',
bot: 'M6 9h12v10H6zM12 5v4M9.5 13.5v.01M14.5 13.5v.01M9 17h6M3 13v2M21 13v2',
warn: 'M12 3l10 18H2zM12 10v5M12 18v.01',
} as const;
export type IconName = keyof typeof PATHS;
+306 -3
View File
@@ -79,7 +79,7 @@ const ru = {
'dash.attention': 'Требует внимания',
'dash.allClear': 'Срочного нет — всё разобрано.',
'att.unassigned': 'Заказы без исполнителя',
'att.unreadChats': 'Непрочитанные сообщения',
'att.unreadChats': 'Ждут ответа в чатах',
'att.webRequests': 'Заявки с сайта',
'att.leads': 'Заявки на переезд',
'att.appRequests': 'Заявки из приложения',
@@ -298,6 +298,107 @@ const ru = {
'team.disabled': 'Доступ отключён',
'team.you': 'это вы',
'team.saved': 'Роли сохранены',
// ───── чаты (этап 2) ─────
'chat.title': 'Чаты',
'chat.all': 'Все',
'channel.SUPPORT': 'Поддержка',
'channel.CONCIERGE': 'Консьерж',
'chat.f.waiting': 'Ждут ответа',
'chat.f.mine': 'Мои',
'chat.f.unassigned': 'Без ответственного',
'chat.f.open': 'Открытые',
'chat.f.closed': 'Закрытые',
'chat.f.all': 'Все',
'chat.search': 'Имя, @username, телефон или текст',
'chat.emptyList': 'Здесь пусто. Новые сообщения появятся сами.',
'chat.emptyFilter': 'Ничего не нашли — попробуйте другой фильтр.',
'chat.pick': 'Выберите диалог',
'chat.pickHint': 'Сверху — те, кто ждёт ответа дольше всех.',
'chat.waiting': 'ждёт {t}',
'chat.you': 'Вы',
'chat.provider': 'Исполнитель',
'chat.unassigned': 'Никто не ведёт',
'chat.take': 'Взять себе',
'chat.transfer': 'Передать',
'chat.transferTo': 'Передать коллеге',
'chat.unassign': 'Снять ответственного',
'chat.close': 'Закрыть',
'chat.reopen': 'Открыть снова',
'chat.closedBanner': 'Диалог закрыт. Клиент напишет — откроется сам; ответить можно и сейчас.',
'chat.moveTo': 'Перенести в «{channel}»',
'chat.moved': 'Диалог перенесён в «{channel}»',
'chat.movedAway': 'Диалог перенесён в «{channel}». У вас нет доступа к этому каналу.',
'chat.other': 'Есть переписка в канале «{channel}»',
'chat.otherOpen': 'Открыть',
'chat.bot': 'Диалог ведёт ИИ-консьерж.',
'chat.botTake': 'Перехватить',
'chat.reply': 'Ответ',
'chat.note': 'Заметка',
'chat.replyPh': 'Ответ клиенту — придёт ему в Telegram',
'chat.notePh': 'Заметка для команды — клиент её не увидит',
'chat.send': 'Отправить',
'chat.saveNote': 'Сохранить заметку',
'chat.attach': 'Прикрепить файл',
'chat.templates': 'Шаблоны',
'chat.tplManage': 'Настроить шаблоны',
'chat.tplEmpty': 'Шаблонов пока нет.',
'chat.tplNew': 'Новый шаблон',
'chat.tplTitle': 'Название',
'chat.tplText': 'Текст',
'chat.tplTextHint': '{name} заменится на имя клиента',
'chat.tplChannel': 'Канал',
'chat.tplBoth': 'Оба канала',
'chat.tplDelete': 'Удалить',
'chat.tplSaved': 'Шаблон сохранён',
'chat.tplSearch': 'Найти шаблон',
'chat.noteLabel': 'Заметка · видит только команда',
'chat.fromApp': 'из приложения',
'chat.undelivered': 'Не доставлено: клиент заблокировал бота или ни разу его не открывал',
'chat.fileTooBig': 'Файл больше 10 МБ',
'chat.enterHint': 'Enter — отправить · Shift+Enter — новая строка',
'chat.providerNotLinked': 'Исполнитель ещё не открыл рабочий бот — пока можно оставить только заметку.',
'chat.client': 'О клиенте',
'chat.aboutProvider': 'Об исполнителе',
'chat.openCard': 'Карточка клиента',
'chat.write': 'Написать',
'chat.back': 'Все диалоги',
'chat.sys.took': '{name} взял(а) диалог',
'chat.sys.transferred': '{name} передал(а) диалог: {param}',
'chat.sys.unassigned': '{name} снял(а) ответственного',
'chat.sys.closed': '{name} закрыл(а) диалог',
'chat.sys.reopened': '{name} открыл(а) диалог снова',
'chat.sys.moved': '{name} перенёс(ла) диалог в «{param}»',
'chat.ago.now': 'только что',
'chat.ago.min': '{n} мин',
'chat.ago.h': '{n} ч',
'chat.ago.d': '{n} дн',
'chat.today': 'Сегодня',
'chat.yesterday': 'Вчера',
'chat.image': 'Фото',
'chat.file': 'Файл',
'chat.subUntil': 'до {date}',
'chat.orders': 'Заказы',
'chat.phone': 'Телефон',
'chat.lang': 'Язык',
'chat.source': 'Откуда',
'chat.linked': 'Рабочий бот открыт',
'chat.notLinked': 'Рабочий бот не открыт',
'chat.details': 'Подробнее',
'chat.more': 'Ещё действия',
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Консьерж — только для подписчиков. Сначала выдайте клиенту подписку.',
'err.ASSIGNEE_NO_ACCESS': 'У этого сотрудника нет доступа к этому каналу.',
'err.PROVIDER_NOT_LINKED': 'Исполнитель ещё не открыл рабочий бот — написать ему пока некуда.',
'err.BAD_FILE_TYPE': 'Такой файл не подойдёт. Можно фото, PDF, Word, Excel, текст или ZIP.',
'audit.bo.chats.send': 'ответил(а) в чате',
'audit.bo.chats.sendFile': 'отправил(а) файл в чате',
'audit.bo.chats.assign': 'назначил(а) ответственного в чате',
'audit.bo.chats.close': 'закрыл(а) диалог',
'audit.bo.chats.reopen': 'открыл(а) диалог снова',
'audit.bo.chats.move': 'перенёс(ла) диалог в другой канал',
'audit.bo.chats.openFor': 'открыл(а) чат с клиентом',
'audit.bo.chats.templates.save': 'изменил(а) шаблон ответа',
'audit.bo.chats.templates.remove': 'удалил(а) шаблон ответа',
};
export type BoKey = keyof typeof ru;
@@ -376,7 +477,7 @@ const en: Dict = {
'dash.attention': 'Needs attention',
'dash.allClear': 'Nothing urgent — all caught up.',
'att.unassigned': 'Orders without a provider',
'att.unreadChats': 'Unread messages',
'att.unreadChats': 'Chats awaiting reply',
'att.webRequests': 'Website requests',
'att.leads': 'Relocation requests',
'att.appRequests': 'In-app requests',
@@ -595,6 +696,107 @@ const en: Dict = {
'team.disabled': 'Access disabled',
'team.you': 'you',
'team.saved': 'Roles saved',
// ───── чаты (этап 2) ─────
'chat.title': 'Chats',
'chat.all': 'All',
'channel.SUPPORT': 'Support',
'channel.CONCIERGE': 'Concierge',
'chat.f.waiting': 'Awaiting reply',
'chat.f.mine': 'Mine',
'chat.f.unassigned': 'Unassigned',
'chat.f.open': 'Open',
'chat.f.closed': 'Closed',
'chat.f.all': 'All',
'chat.search': 'Name, @username, phone or text',
'chat.emptyList': 'Nothing here. New messages will appear on their own.',
'chat.emptyFilter': 'Nothing found — try another filter.',
'chat.pick': 'Pick a conversation',
'chat.pickHint': 'Those waiting longest are on top.',
'chat.waiting': 'waiting {t}',
'chat.you': 'You',
'chat.provider': 'Provider',
'chat.unassigned': 'Nobody assigned',
'chat.take': 'Take it',
'chat.transfer': 'Pass on',
'chat.transferTo': 'Pass to a colleague',
'chat.unassign': 'Remove assignee',
'chat.close': 'Close',
'chat.reopen': 'Reopen',
'chat.closedBanner': 'The conversation is closed. It reopens when the client writes; you can still reply now.',
'chat.moveTo': 'Move to {channel}',
'chat.moved': 'Moved to {channel}',
'chat.movedAway': 'Moved to {channel}. You have no access to that channel.',
'chat.other': 'There is also a {channel} conversation',
'chat.otherOpen': 'Open',
'chat.bot': 'The AI concierge is handling this conversation.',
'chat.botTake': 'Take over',
'chat.reply': 'Reply',
'chat.note': 'Note',
'chat.replyPh': 'Reply to the client — goes to their Telegram',
'chat.notePh': 'Note for the team — the client won’t see it',
'chat.send': 'Send',
'chat.saveNote': 'Save note',
'chat.attach': 'Attach a file',
'chat.templates': 'Templates',
'chat.tplManage': 'Manage templates',
'chat.tplEmpty': 'No templates yet.',
'chat.tplNew': 'New template',
'chat.tplTitle': 'Title',
'chat.tplText': 'Text',
'chat.tplTextHint': '{name} is replaced with the client’s name',
'chat.tplChannel': 'Channel',
'chat.tplBoth': 'Both channels',
'chat.tplDelete': 'Delete',
'chat.tplSaved': 'Template saved',
'chat.tplSearch': 'Find a template',
'chat.noteLabel': 'Note · team only',
'chat.fromApp': 'from the app',
'chat.undelivered': 'Not delivered: the client blocked the bot or never opened it',
'chat.fileTooBig': 'The file is larger than 10 MB',
'chat.enterHint': 'Enter to send · Shift+Enter for a new line',
'chat.providerNotLinked': 'The provider hasn’t opened the work bot yet — you can only leave a note for now.',
'chat.client': 'About the client',
'chat.aboutProvider': 'About the provider',
'chat.openCard': 'Client card',
'chat.write': 'Message',
'chat.back': 'All conversations',
'chat.sys.took': '{name} took the conversation',
'chat.sys.transferred': '{name} passed the conversation to {param}',
'chat.sys.unassigned': '{name} removed the assignee',
'chat.sys.closed': '{name} closed the conversation',
'chat.sys.reopened': '{name} reopened the conversation',
'chat.sys.moved': '{name} moved the conversation to {param}',
'chat.ago.now': 'just now',
'chat.ago.min': '{n} min',
'chat.ago.h': '{n} h',
'chat.ago.d': '{n} d',
'chat.today': 'Today',
'chat.yesterday': 'Yesterday',
'chat.image': 'Photo',
'chat.file': 'File',
'chat.subUntil': 'until {date}',
'chat.orders': 'Orders',
'chat.phone': 'Phone',
'chat.lang': 'Language',
'chat.source': 'Source',
'chat.linked': 'Work bot opened',
'chat.notLinked': 'Work bot not opened',
'chat.details': 'Details',
'chat.more': 'More actions',
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'The concierge is for members only. Give the client a subscription first.',
'err.ASSIGNEE_NO_ACCESS': 'This team member has no access to this channel.',
'err.PROVIDER_NOT_LINKED': 'The provider hasn’t opened the work bot yet.',
'err.BAD_FILE_TYPE': 'This file type isn’t supported. Use a photo, PDF, Word, Excel, text or ZIP.',
'audit.bo.chats.send': 'replied in a chat',
'audit.bo.chats.sendFile': 'sent a file in a chat',
'audit.bo.chats.assign': 'assigned a chat',
'audit.bo.chats.close': 'closed a conversation',
'audit.bo.chats.reopen': 'reopened a conversation',
'audit.bo.chats.move': 'moved a conversation to another channel',
'audit.bo.chats.openFor': 'opened a chat with a client',
'audit.bo.chats.templates.save': 'edited a reply template',
'audit.bo.chats.templates.remove': 'deleted a reply template',
};
const hy: Dict = {
@@ -670,7 +872,7 @@ const hy: Dict = {
'dash.attention': 'Պահանջում է ուշադրություն',
'dash.allClear': 'Շտապ ոչինչ չկա․ ամեն ինչ արված է։',
'att.unassigned': 'Պատվերներ առանց կատարողի',
'att.unreadChats': 'Չկարդացված հաղորդագրություններ',
'att.unreadChats': 'Պատասխանի սպասող զրույցներ',
'att.webRequests': 'Հայտեր կայքից',
'att.leads': 'Տեղափոխման հայտեր',
'att.appRequests': 'Հայտեր հավելվածից',
@@ -889,6 +1091,107 @@ const hy: Dict = {
'team.disabled': 'Մուտքն անջատված է',
'team.you': 'դուք',
'team.saved': 'Դերերը պահպանված են',
// ───── чаты (этап 2) ─────
'chat.title': 'Զրույցներ',
'chat.all': 'Բոլորը',
'channel.SUPPORT': 'Աջակցություն',
'channel.CONCIERGE': 'Կոնսիերժ',
'chat.f.waiting': 'Սպասում են պատասխանի',
'chat.f.mine': 'Իմը',
'chat.f.unassigned': 'Առանց պատասխանատուի',
'chat.f.open': 'Բաց',
'chat.f.closed': 'Փակ',
'chat.f.all': 'Բոլորը',
'chat.search': 'Անուն, @username, հեռախոս կամ տեքստ',
'chat.emptyList': 'Այստեղ դատարկ է։ Նոր հաղորդագրությունները կհայտնվեն ինքնուրույն։',
'chat.emptyFilter': 'Ոչինչ չգտանք․ փորձեք այլ ֆիլտր։',
'chat.pick': 'Ընտրեք զրույցը',
'chat.pickHint': 'Վերևում նրանք են, ովքեր ամենաերկար են սպասում։',
'chat.waiting': 'սպասում է {t}',
'chat.you': 'Դուք',
'chat.provider': 'Կատարող',
'chat.unassigned': 'Պատասխանատու չկա',
'chat.take': 'Վերցնել ինձ',
'chat.transfer': 'Փոխանցել',
'chat.transferTo': 'Փոխանցել գործընկերոջը',
'chat.unassign': 'Հանել պատասխանատուին',
'chat.close': 'Փակել',
'chat.reopen': 'Կրկին բացել',
'chat.closedBanner': 'Զրույցը փակ է։ Կբացվի, երբ հաճախորդը գրի․ կարող եք պատասխանել նաև հիմա։',
'chat.moveTo': 'Տեղափոխել «{channel}»',
'chat.moved': 'Տեղափոխվեց «{channel}»',
'chat.movedAway': 'Տեղափոխվեց «{channel}»։ Դուք մուտք չունեք այդ ալիքին։',
'chat.other': 'Կա նաև զրույց «{channel}» ալիքում',
'chat.otherOpen': 'Բացել',
'chat.bot': 'Զրույցը վարում է ԱԲ կոնսիերժը։',
'chat.botTake': 'Վերցնել',
'chat.reply': 'Պատասխան',
'chat.note': 'Նշում',
'chat.replyPh': 'Պատասխան հաճախորդին՝ կգա Telegram-ում',
'chat.notePh': 'Նշում թիմի համար՝ հաճախորդը չի տեսնի',
'chat.send': 'Ուղարկել',
'chat.saveNote': 'Պահել նշումը',
'chat.attach': 'Կցել ֆայլ',
'chat.templates': 'Ձևանմուշներ',
'chat.tplManage': 'Կարգավորել ձևանմուշները',
'chat.tplEmpty': 'Ձևանմուշներ դեռ չկան։',
'chat.tplNew': 'Նոր ձևանմուշ',
'chat.tplTitle': 'Անվանում',
'chat.tplText': 'Տեքստ',
'chat.tplTextHint': '{name}-ը կփոխարինվի հաճախորդի անունով',
'chat.tplChannel': 'Ալիք',
'chat.tplBoth': 'Երկու ալիքները',
'chat.tplDelete': 'Ջնջել',
'chat.tplSaved': 'Ձևանմուշը պահպանված է',
'chat.tplSearch': 'Գտնել ձևանմուշ',
'chat.noteLabel': 'Նշում · տեսանելի է միայն թիմին',
'chat.fromApp': 'հավելվածից',
'chat.undelivered': 'Չի առաքվել․ հաճախորդը արգելափակել է բոտը կամ երբեք չի բացել',
'chat.fileTooBig': 'Ֆայլը 10 ՄԲ-ից մեծ է',
'chat.enterHint': 'Enter՝ ուղարկել · Shift+Enter՝ նոր տող',
'chat.providerNotLinked': 'Կատարողը դեռ չի բացել աշխատանքային բոտը․ առայժմ կարող եք թողնել միայն նշում։',
'chat.client': 'Հաճախորդի մասին',
'chat.aboutProvider': 'Կատարողի մասին',
'chat.openCard': 'Հաճախորդի քարտ',
'chat.write': 'Գրել',
'chat.back': 'Բոլոր զրույցները',
'chat.sys.took': '{name}-ը վերցրեց զրույցը',
'chat.sys.transferred': '{name}-ը զրույցը փոխանցեց {param}-ին',
'chat.sys.unassigned': '{name}-ը հանեց պատասխանատուին',
'chat.sys.closed': '{name}-ը փակեց զրույցը',
'chat.sys.reopened': '{name}-ը կրկին բացեց զրույցը',
'chat.sys.moved': '{name}-ը զրույցը տեղափոխեց «{param}»',
'chat.ago.now': 'հենց հիմա',
'chat.ago.min': '{n} ր',
'chat.ago.h': '{n} ժ',
'chat.ago.d': '{n} օր',
'chat.today': 'Այսօր',
'chat.yesterday': 'Երեկ',
'chat.image': 'Լուսանկար',
'chat.file': 'Ֆայլ',
'chat.subUntil': 'մինչև {date}',
'chat.orders': 'Պատվերներ',
'chat.phone': 'Հեռախոս',
'chat.lang': 'Լեզու',
'chat.source': 'Աղբյուր',
'chat.linked': 'Աշխատանքային բոտը բացված է',
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ',
'err.CONCIERGE_SUBSCRIBERS_ONLY': 'Կոնսիերժը միայն բաժանորդների համար է։ Նախ տրամադրեք բաժանորդագրություն։',
'err.ASSIGNEE_NO_ACCESS': 'Այս աշխատակիցը մուտք չունի այս ալիքին։',
'err.PROVIDER_NOT_LINKED': 'Կատարողը դեռ չի բացել աշխատանքային բոտը։',
'err.BAD_FILE_TYPE': 'Այս ֆայլը չի համապատասխանում։ Կարելի է լուսանկար, PDF, Word, Excel, տեքստ կամ ZIP։',
'audit.bo.chats.send': 'պատասխանեց զրույցում',
'audit.bo.chats.sendFile': 'ֆայլ ուղարկեց զրույցում',
'audit.bo.chats.assign': 'նշանակեց զրույցի պատասխանատու',
'audit.bo.chats.close': 'փակեց զրույցը',
'audit.bo.chats.reopen': 'կրկին բացեց զրույցը',
'audit.bo.chats.move': 'զրույցը տեղափոխեց այլ ալիք',
'audit.bo.chats.openFor': 'բացեց զրույց հաճախորդի հետ',
'audit.bo.chats.templates.save': 'փոխեց պատասխանի ձևանմուշը',
'audit.bo.chats.templates.remove': 'ջնջեց պատասխանի ձևանմուշը',
};
const DICTS: Record<BoLocale, Dict> = { ru, en, hy };
+1
View File
@@ -1,5 +1,6 @@
import '@barev/ui/styles.css';
import './styles.css';
import './chats.css';
import { ApiProvider, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+945
View File
@@ -0,0 +1,945 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useToast, useTRPC } from '@barev/web';
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT, type T } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton, sourceLabel, type IconName } from '../components/kit.tsx';
import { SubDialog } from './SubDialog.tsx';
type Channel = 'SUPPORT' | 'CONCIERGE';
type Filter = 'waiting' | 'mine' | 'unassigned' | 'open' | 'closed' | 'all';
const FILTERS: Filter[] = ['open', 'waiting', 'mine', 'unassigned', 'closed'];
const MAX_FILE = 10 * 1024 * 1024;
const MIN = 60_000;
/** Каналы, доступные сотруднику: поддержка, консьерж или оба (суперадмин) */
export function myChannels(me: Me): Channel[] {
if (me.roles.includes('SUPERADMIN')) return ['SUPPORT', 'CONCIERGE'];
return (['SUPPORT', 'CONCIERGE'] as const).filter((c) => me.roles.includes(c));
}
const store = {
get(key: string) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
},
set(key: string, v: string) {
try {
localStorage.setItem(key, v);
} catch {
/* приватный режим */
}
},
};
/** «12 мин», «3 ч» — сколько прошло */
function ago(t: T, d: Date, now = Date.now()) {
const m = Math.floor((now - d.getTime()) / MIN);
if (m < 1) return t('chat.ago.now');
if (m < 60) return t('chat.ago.min', { n: m });
if (m < 60 * 24) return t('chat.ago.h', { n: Math.floor(m / 60) });
return t('chat.ago.d', { n: Math.floor(m / 1440) });
}
/** Насколько срочно: до 15 минут — спокойно, до часа — пора, дольше — горит */
const urgency = (d: Date) => {
const m = (Date.now() - d.getTime()) / MIN;
return m < 15 ? 'calm' : m < 60 ? 'warm' : 'hot';
};
/** Перерисовывать «N мин» раз в полминуты */
function useTick(ms = 30_000) {
const [, set] = useState(0);
useEffect(() => {
const id = setInterval(() => set((n) => n + 1), ms);
return () => clearInterval(id);
}, [ms]);
}
/** Черновики по диалогам — переключился и вернулся, текст на месте */
const drafts = new Map<string, string>();
export function Chats({ me, threadId }: { me: Me; threadId?: string }) {
const { t } = useT();
const trpc = useTRPC();
const channels = myChannels(me);
const [channel, setChannelState] = useState<Channel | 'ALL'>(() => {
const saved = store.get('barev.bo.chats.channel') as Channel | 'ALL' | null;
if (saved === 'ALL' && channels.length > 1) return 'ALL';
if (saved && channels.includes(saved as Channel)) return saved as Channel;
return channels.length > 1 ? 'ALL' : channels[0]!;
});
const setChannel = (c: Channel | 'ALL') => {
setChannelState(c);
store.set('barev.bo.chats.channel', c);
};
const [filter, setFilter] = useState<Filter>('open');
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 250);
return () => clearTimeout(id);
}, [search]);
// «о клиенте» открыт сразу, только если для него есть место третьей колонкой
const [side, setSide] = useState(() => window.matchMedia('(min-width: 1560px)').matches);
useTick();
const counts = useQuery({ ...trpc.bo.chats.counts.queryOptions(), refetchInterval: 15_000 });
const list = useQuery({
...trpc.bo.chats.list.queryOptions({ channel: channel === 'ALL' ? undefined : channel, filter, search: q || undefined }),
refetchInterval: 8_000,
placeholderData: keepPreviousData,
});
const waitingIn = (c: Channel | 'ALL') => (counts.data ? (c === 'ALL' ? counts.data.total : counts.data.waiting[c]) : 0);
const filterCount = (f: Filter) => {
if (!counts.data) return 0;
const sum = (r: Record<Channel, number>) => (channel === 'ALL' ? channels.reduce((s, c) => s + r[c], 0) : r[channel]);
return f === 'waiting' ? sum(counts.data.waiting) : f === 'mine' ? sum(counts.data.mine) : f === 'unassigned' ? sum(counts.data.unassigned) : 0;
};
return (
<div className={`bo-chat${threadId ? ' bo-chat--thread' : ''}${threadId && side ? ' bo-chat--side' : ''}`}>
<section className="bo-chat__list" aria-label={t('chat.title')}>
<div className="bo-chat__listhead">
<h1 className="bo-title bo-chat__title">{t('chat.title')}</h1>
{channels.length > 1 && (
<div className="bo-chtabs" role="group">
{(['ALL', ...channels] as const).map((c) => (
<button key={c} type="button" aria-pressed={channel === c} onClick={() => setChannel(c)}>
{c === 'ALL' ? t('chat.all') : t(`channel.${c}`)}
{waitingIn(c) > 0 && <span className="bo-chat__dot">{waitingIn(c)}</span>}
</button>
))}
</div>
)}
<label className="bo-search bo-chat__search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('chat.search')} aria-label={t('chat.search')} />
</label>
<div className="bo-chat__filters" role="group">
{FILTERS.map((f) => {
const n = filterCount(f);
return (
<button key={f} type="button" className="bo-pill" aria-pressed={filter === f} onClick={() => setFilter(f)}>
{t(`chat.f.${f}`)}
{n > 0 && <span className="bo-chat__pillnum">{n}</span>}
</button>
);
})}
</div>
</div>
<ThreadList items={list.data} loading={list.isPending} activeId={threadId} showChannel={channel === 'ALL'} meId={me.id} filtered={filter !== 'open' || !!q} />
</section>
<section className="bo-chat__conv">
{threadId ? (
<Conversation key={threadId} id={threadId} me={me} channels={channels} side={side} onToggleSide={() => setSide((s) => !s)} />
) : (
<div className="bo-chat__pick">
<span className="bo-chat__pickicon">
<I name="chats" size={30} />
</span>
<h2 className="bo-h2">{t('chat.pick')}</h2>
<p className="bo-muted">{t('chat.pickHint')}</p>
</div>
)}
</section>
{threadId && side && <PeerPanel id={threadId} me={me} onClose={() => setSide(false)} />}
</div>
);
}
// ───── список диалогов ─────
type ListItem = {
id: string;
channel: Channel;
status: 'OPEN' | 'CLOSED';
name: string;
plan: 'BAREV_PLUS' | 'ELITE' | null;
providerRole: string | null;
assignee: { id: string; name: string } | null;
botActive: boolean;
waitingSince: Date | null;
lastMessageAt: Date;
last: { author: string; text: string; file: string | null } | null;
unread: number;
};
function ThreadList({ items, loading, activeId, showChannel, meId, filtered }: { items?: ListItem[]; loading: boolean; activeId?: string; showChannel: boolean; meId: string; filtered: boolean }) {
const { t } = useT();
if (loading && !items)
return (
<div className="bo-chat__items" style={{ padding: 12, display: 'grid', gap: 8 }}>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} h={68} />
))}
</div>
);
if (!items?.length) return <p className="bo-chat__none">{filtered ? t('chat.emptyFilter') : t('chat.emptyList')}</p>;
return (
<ul className="bo-chat__items">
{items.map((it) => {
const lastText = it.last ? (it.last.file ? `📎 ${it.last.file === 'image' ? t('chat.image') : it.last.file}${it.last.text ? ` · ${it.last.text}` : ''}` : it.last.text) : '';
const mine = it.last?.author === 'OPERATOR';
return (
<li key={it.id}>
<a href={`#/chats/${it.id}`} className="bo-thread" aria-current={it.id === activeId ? 'true' : undefined} data-closed={it.status === 'CLOSED' || undefined}>
<Avatar name={it.name} id={it.id} size={42} />
<span className="bo-thread__body">
<span className="bo-thread__top">
<b className="bo-thread__name">{it.name}</b>
{it.providerRole ? <span className="bo-chip bo-chip--provider">{t('chat.provider')}</span> : it.plan && <PlanBadge plan={it.plan} t={t} />}
<span className="bo-thread__time">{ago(t, it.lastMessageAt)}</span>
</span>
<span className="bo-thread__last">
{mine && <span className="bo-faint">{t('chat.you')}: </span>}
{lastText}
</span>
<span className="bo-thread__meta">
{showChannel && <span className={`bo-chip bo-chip--${it.channel}`}>{t(`channel.${it.channel}`)}</span>}
{it.waitingSince && <span className={`bo-wait-chip bo-wait-chip--${urgency(it.waitingSince)}`}>{t('chat.waiting', { t: ago(t, it.waitingSince) })}</span>}
{it.botActive && (
<span className="bo-chip">
<I name="bot" size={13} />
</span>
)}
<span className="bo-grow" />
{it.assignee ? (
<span className="bo-thread__who" title={it.assignee.name}>
{it.assignee.id === meId ? t('chat.f.mine') : it.assignee.name.split(' ')[0]}
</span>
) : (
it.status === 'OPEN' && <span className="bo-thread__who bo-thread__who--none">{t('chat.unassigned')}</span>
)}
{it.unread > 0 && <span className="bo-nav__count">{it.unread}</span>}
</span>
</span>
</a>
</li>
);
})}
</ul>
);
}
// ───── диалог ─────
function Conversation({ id, me, channels, side, onToggleSide }: { id: string; me: Me; channels: Channel[]; side: boolean; onToggleSide: () => void }) {
const { t, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const th = useQuery({ ...trpc.bo.chats.get.queryOptions({ id }), refetchInterval: 4_000 });
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.chats.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.chats.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.chats.counts.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.dashboard.attention.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const assign = useMutation(trpc.bo.chats.assign.mutationOptions({ onSuccess: refresh, onError }));
const close = useMutation(trpc.bo.chats.close.mutationOptions({ onSuccess: refresh, onError }));
const reopen = useMutation(trpc.bo.chats.reopen.mutationOptions({ onSuccess: refresh, onError }));
const move = useMutation(
trpc.bo.chats.move.mutationOptions({
onSuccess: (r, v) => {
refresh();
const name = t(`channel.${v.channel}`);
if (r.visible) {
toast(t('chat.moved', { channel: name }));
navigate(`/chats/${r.id}`, { replace: true });
} else {
toast(t('chat.movedAway', { channel: name }));
navigate('/chats', { replace: true });
}
},
onError,
}),
);
if (th.isError)
return (
<div className="bo-chat__pick">
<p className="bo-muted">{errText(t, th.error)}</p>
<a className="bo-btn bo-btn--ghost bo-btn--sm" href="#/chats">
{t('chat.back')}
</a>
</div>
);
if (!th.data)
return (
<div style={{ padding: 20, display: 'grid', gap: 12 }}>
<Skeleton h={56} />
<Skeleton h={320} />
</div>
);
const d = th.data;
const other = d.other ? t(`channel.${d.other.channel}`) : null;
const otherChannel: Channel = d.channel === 'SUPPORT' ? 'CONCIERGE' : 'SUPPORT';
return (
<>
<header className="bo-conv__head">
<a className="bo-conv__back" href="#/chats" aria-label={t('chat.back')}>
<I name="back" size={20} />
</a>
<Avatar name={d.name} id={d.id} size={40} />
<div className="bo-conv__who">
<div className="bo-conv__name">
<span>{d.name}</span>
{d.providerRole ? <span className="bo-chip bo-chip--provider">{t('chat.provider')}</span> : <PlanBadge plan={d.plan} t={t} />}
</div>
<div className="bo-conv__sub">
<span className={`bo-chip bo-chip--${d.channel}`}>{t(`channel.${d.channel}`)}</span>
{d.username && <span>@{d.username}</span>}
<span>{d.locale.toUpperCase()}</span>
</div>
</div>
<div className="bo-conv__actions">
<AssignMenu
channel={d.channel}
assignee={d.assignee}
me={me}
busy={assign.isPending}
onAssign={(assigneeId) => assign.mutate({ id, assigneeId })}
/>
{d.status === 'OPEN' ? (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => close.mutate({ id })} disabled={close.isPending}>
<I name="check" size={16} />
<span className="bo-hide-sm">{t('chat.close')}</span>
</button>
) : (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => reopen.mutate({ id })} disabled={reopen.isPending}>
{t('chat.reopen')}
</button>
)}
<Menu
label={t('chat.more')}
icon="more"
items={[
d.clientId && { key: 'move', icon: 'swap' as const, label: t('chat.moveTo', { channel: t(`channel.${otherChannel}`) }), onClick: () => move.mutate({ id, channel: otherChannel }) },
d.clientId && { key: 'card', icon: 'clients' as const, label: t('chat.openCard'), onClick: () => navigate(`/clients/${d.clientId}`) },
]}
/>
<button type="button" className="bo-iconbtn" aria-pressed={side} onClick={onToggleSide} aria-label={d.clientId ? t('chat.client') : t('chat.aboutProvider')} title={d.clientId ? t('chat.client') : t('chat.aboutProvider')}>
<I name="info" size={20} />
</button>
</div>
</header>
{d.botActive && (
<div className="bo-conv__banner bo-conv__banner--bot">
<I name="bot" size={18} />
<span className="bo-grow">{t('chat.bot')}</span>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => assign.mutate({ id, assigneeId: me.id })}>
{t('chat.botTake')}
</button>
</div>
)}
{d.other && other && (
<div className="bo-conv__banner">
<span className="bo-grow">{t('chat.other', { channel: other })}</span>
{channels.includes(d.other.channel) && (
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`#/chats/${d.other.id}`}>
{t('chat.otherOpen')}
</a>
)}
</div>
)}
<Messages messages={d.messages} locale={locale as Locale} peerName={d.name} />
{d.status === 'CLOSED' && <div className="bo-conv__closed">{t('chat.closedBanner')}</div>}
<Composer
threadId={id}
peerFirstName={d.name.split(' ')[0] ?? ''}
channel={d.channel}
replyDisabled={d.providerLinked === false}
onSent={refresh}
/>
</>
);
}
type Message = {
id: string;
author: 'CLIENT' | 'PROVIDER' | 'OPERATOR' | 'BOT' | 'SYSTEM';
authorName: string | null;
text: string;
internal: boolean;
source: string;
file: { name: string | null; mime: string | null; size: number | null } | null;
param: string | null;
deliveryError: string | null;
createdAt: Date;
};
function Messages({ messages, locale, peerName }: { messages: Message[]; locale: Locale; peerName: string }) {
const { t } = useT();
const box = useRef<HTMLDivElement>(null);
const stick = useRef(true);
const lastId = messages.at(-1)?.id;
// новое сообщение — прокручиваем вниз, если человек и так был внизу
useLayoutEffect(() => {
const el = box.current;
if (el && stick.current) el.scrollTop = el.scrollHeight;
}, [lastId]);
const time = (d: Date) => formatDateTime(locale, d, { hour: '2-digit', minute: '2-digit' });
const dayKey = (d: Date) => formatDateTime(locale, d, { year: 'numeric', month: '2-digit', day: '2-digit' });
const today = dayKey(new Date());
const yesterday = dayKey(new Date(Date.now() - 86_400_000));
const dayLabel = (d: Date) => {
const k = dayKey(d);
return k === today ? t('chat.today') : k === yesterday ? t('chat.yesterday') : formatDateTime(locale, d, { day: 'numeric', month: 'long' });
};
let prevDay = '';
return (
<div
className="bo-msgs"
ref={box}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}}
>
{messages.map((m) => {
const day = dayKey(m.createdAt);
const sep = day !== prevDay ? <div className="bo-msgs__day">{dayLabel(m.createdAt)}</div> : null;
prevDay = day;
if (m.author === 'SYSTEM')
return (
<Fragment key={m.id}>
{sep}
<div className="bo-sys">
{t(`chat.sys.${m.text}`, { name: m.authorName ?? '—', param: m.text === 'moved' && m.param ? t(`channel.${m.param}`) : (m.param ?? '') })}
<span className="bo-faint"> · {time(m.createdAt)}</span>
</div>
</Fragment>
);
const incoming = m.author === 'CLIENT' || m.author === 'PROVIDER';
const kind = incoming ? 'in' : m.internal ? 'note' : 'out';
return (
<Fragment key={m.id}>
{sep}
<div className={`bo-msg bo-msg--${kind}`}>
{kind === 'note' && (
<div className="bo-msg__label">
<I name="lock" size={13} />
{t('chat.noteLabel')}
</div>
)}
{m.file && <FileView id={m.id} file={m.file} />}
{m.text && <div className="bo-msg__text">{m.text}</div>}
<div className="bo-msg__meta">
{incoming ? peerName.split(' ')[0] : m.author === 'BOT' ? 'AI' : (m.authorName ?? '')}
{m.source === 'MINIAPP' && <span> · {t('chat.fromApp')}</span>}
<span> · {time(m.createdAt)}</span>
</div>
{m.deliveryError && (
<div className="bo-msg__err">
<I name="warn" size={14} />
{t('chat.undelivered')}
</div>
)}
</div>
</Fragment>
);
})}
</div>
);
}
function FileView({ id, file }: { id: string; file: { name: string | null; mime: string | null; size: number | null } }) {
const { t } = useT();
const [broken, setBroken] = useState(false);
const url = `/files/bo/chat-file/${id}`;
if (file.mime?.startsWith('image/') && !broken)
return (
<a className="bo-msg__img" href={url} target="_blank" rel="noreferrer">
<img src={url} alt={file.name ?? t('chat.image')} loading="lazy" onError={() => setBroken(true)} />
</a>
);
return (
<a className="bo-msg__file" href={url} target="_blank" rel="noreferrer">
<I name="file" size={22} />
<span style={{ minWidth: 0 }}>
<span className="bo-msg__fname">{file.name ?? t('chat.file')}</span>
{file.size != null && <span className="bo-faint bo-small">{fmtSize(file.size)}</span>}
</span>
</a>
);
}
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
// ───── поле ответа ─────
function Composer({ threadId, peerFirstName, channel, replyDisabled, onSent }: { threadId: string; peerFirstName: string; channel: Channel; replyDisabled: boolean; onSent: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [mode, setMode] = useState<'reply' | 'note'>(replyDisabled ? 'note' : 'reply');
const [text, setTextState] = useState(() => drafts.get(threadId) ?? '');
const setText = (v: string) => {
setTextState(v);
drafts.set(threadId, v);
};
const [uploading, setUploading] = useState(false);
const [tplOpen, setTplOpen] = useState(false);
const area = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const note = mode === 'note';
// поле растёт вместе с текстом
useLayoutEffect(() => {
const el = area.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 220)}px`;
}, [text]);
const send = useMutation(
trpc.bo.chats.send.mutationOptions({
onSuccess: () => {
setText('');
onSent();
area.current?.focus();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const submit = () => {
if (!text.trim() || send.isPending) return;
send.mutate({ id: threadId, text, internal: note });
};
const upload = async (file: File) => {
if (file.size > MAX_FILE) return toast(t('chat.fileTooBig'), 'error');
setUploading(true);
try {
const form = new FormData();
form.set('text', text);
form.set('internal', String(note));
form.set('file', file);
const res = await fetch(`/files/bo/chat/${threadId}`, { method: 'POST', body: form, credentials: 'include' });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? String(res.status));
}
setText('');
onSent();
} catch (e) {
toast(errText(t, e), 'error');
} finally {
setUploading(false);
}
};
return (
<div className={`bo-composer${note ? ' bo-composer--note' : ''}`}>
<div className="bo-composer__tabs" role="tablist">
<button type="button" role="tab" aria-selected={!note} disabled={replyDisabled} onClick={() => setMode('reply')}>
{t('chat.reply')}
</button>
<button type="button" role="tab" aria-selected={note} onClick={() => setMode('note')}>
<I name="lock" size={13} />
{t('chat.note')}
</button>
<span className="bo-grow" />
<span className="bo-composer__hint bo-hide-sm">{t('chat.enterHint')}</span>
</div>
{replyDisabled && <p className="bo-hint" style={{ margin: '0 4px 8px' }}>{t('chat.providerNotLinked')}</p>}
<div className="bo-composer__box">
<textarea
ref={area}
className="bo-composer__input"
rows={1}
value={text}
placeholder={note ? t('chat.notePh') : t('chat.replyPh')}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
}}
maxLength={4000}
aria-label={note ? t('chat.note') : t('chat.reply')}
/>
<div className="bo-composer__tools">
<button type="button" className="bo-iconbtn" onClick={() => fileInput.current?.click()} disabled={uploading} aria-label={t('chat.attach')} title={t('chat.attach')}>
<I name="clip" size={20} />
</button>
<input
ref={fileInput}
type="file"
hidden
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
<div className="bo-pop-anchor">
<button type="button" className="bo-iconbtn" aria-expanded={tplOpen} onClick={() => setTplOpen((o) => !o)} aria-label={t('chat.templates')} title={t('chat.templates')}>
<I name="bolt" size={20} />
</button>
{tplOpen && (
<TemplatePicker
channel={channel}
onPick={(txt) => {
setText(txt.replaceAll('{name}', peerFirstName));
setTplOpen(false);
area.current?.focus();
}}
onClose={() => setTplOpen(false)}
/>
)}
</div>
<button type="button" className={`bo-btn bo-btn--sm ${note ? '' : 'bo-btn--accent'}`} onClick={submit} disabled={!text.trim() || send.isPending || uploading}>
<I name={note ? 'lock' : 'send'} size={16} />
<span className="bo-hide-sm">{note ? t('chat.saveNote') : t('chat.send')}</span>
</button>
</div>
</div>
</div>
);
}
// ───── шаблоны ─────
type Template = { id: string; channel: Channel | null; title: string; text: string };
function TemplatePicker({ channel, onPick, onClose }: { channel: Channel; onPick: (text: string) => void; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const tpl = useQuery(trpc.bo.chats.templates.list.queryOptions());
const [q, setQ] = useState('');
const [manage, setManage] = useState(false);
const ref = useOutside<HTMLDivElement>(() => !manage && onClose());
const items = (tpl.data ?? []).filter((x) => (!x.channel || x.channel === channel) && (`${x.title} ${x.text}`.toLowerCase().includes(q.toLowerCase())));
return (
<>
<div className="bo-pop bo-pop--up bo-tpl" ref={ref} role="dialog" aria-label={t('chat.templates')}>
<input className="bo-input" autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('chat.tplSearch')} style={{ minHeight: 36, padding: '6px 12px' }} />
<div className="bo-tpl__list">
{items.length === 0 && <p className="bo-hint" style={{ padding: '8px 4px' }}>{t('chat.tplEmpty')}</p>}
{items.map((x) => (
<button key={x.id} type="button" className="bo-tpl__item" onClick={() => onPick(x.text)}>
<b>{x.title}</b>
<span>{x.text}</span>
</button>
))}
</div>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: '4px' }} onClick={() => setManage(true)}>
{t('chat.tplManage')}
</button>
</div>
{manage && <TemplatesDialog templates={tpl.data ?? []} onClose={() => setManage(false)} />}
</>
);
}
function TemplatesDialog({ templates, onClose }: { templates: Template[]; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [edit, setEdit] = useState<Partial<Template> | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: trpc.bo.chats.templates.list.queryKey() });
const save = useMutation(
trpc.bo.chats.templates.save.mutationOptions({
onSuccess: () => {
refresh();
setEdit(null);
toast(t('chat.tplSaved'));
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const remove = useMutation(trpc.bo.chats.templates.remove.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
return (
<Dialog
open
onClose={onClose}
title={t('chat.templates')}
footer={
edit ? (
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setEdit(null)}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={!edit.title?.trim() || !edit.text?.trim() || save.isPending}
onClick={() => save.mutate({ id: edit.id, channel: edit.channel ?? null, title: edit.title!, text: edit.text! })}
>
{t('ui.save')}
</button>
</>
) : (
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" onClick={() => setEdit({ channel: null, title: '', text: '' })}>
<I name="plus" size={16} />
{t('chat.tplNew')}
</button>
</>
)
}
>
{edit ? (
<div style={{ display: 'grid', gap: 14 }}>
<Field label={t('chat.tplTitle')}>
<input className="bo-input" value={edit.title ?? ''} maxLength={80} onChange={(e) => setEdit({ ...edit, title: e.target.value })} />
</Field>
<Field label={t('chat.tplText')} hint={t('chat.tplTextHint')}>
<textarea className="bo-textarea" value={edit.text ?? ''} maxLength={4000} onChange={(e) => setEdit({ ...edit, text: e.target.value })} />
</Field>
<Field label={t('chat.tplChannel')}>
<select className="bo-select" value={edit.channel ?? ''} onChange={(e) => setEdit({ ...edit, channel: (e.target.value || null) as Channel | null })}>
<option value="">{t('chat.tplBoth')}</option>
<option value="SUPPORT">{t('channel.SUPPORT')}</option>
<option value="CONCIERGE">{t('channel.CONCIERGE')}</option>
</select>
</Field>
</div>
) : templates.length === 0 ? (
<p className="bo-hint">{t('chat.tplEmpty')}</p>
) : (
<ul className="bo-list">
{templates.map((x) => (
<li key={x.id} style={{ alignItems: 'flex-start' }}>
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
<b>{x.title}</b>
{x.channel && <span className={`bo-chip bo-chip--${x.channel}`} style={{ marginLeft: 8 }}>{t(`channel.${x.channel}`)}</span>}
<span className="bo-muted bo-small" style={{ display: 'block', marginTop: 2 }}>
{x.text}
</span>
</span>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit(x)}>
{t('chat.details')}
</button>
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => remove.mutate({ id: x.id })} aria-label={`${t('chat.tplDelete')}: ${x.title}`}>
<I name="close" size={14} />
</button>
</li>
))}
</ul>
)}
</Dialog>
);
}
// ───── меню ─────
function useOutside<E extends HTMLElement>(onOutside: () => void) {
const ref = useRef<E>(null);
const cb = useRef(onOutside);
cb.current = onOutside;
useEffect(() => {
const down = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node) && !(e.target as HTMLElement).closest?.('[data-pop-trigger]')) cb.current();
};
const key = (e: KeyboardEvent) => e.key === 'Escape' && cb.current();
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('mousedown', down);
document.removeEventListener('keydown', key);
};
}, []);
return ref;
}
type MenuItem = { key: string; icon?: IconName; label: ReactNode; onClick: () => void; current?: boolean };
function Menu({ label, icon, items, button }: { label: string; icon?: IconName; items: (MenuItem | null | undefined | false | '')[]; button?: ReactNode }) {
const [open, setOpen] = useState(false);
const ref = useOutside<HTMLDivElement>(() => setOpen(false));
const list = items.filter((x): x is MenuItem => !!x);
if (list.length === 0) return null;
return (
<div className="bo-pop-anchor">
{button ? (
<span data-pop-trigger onClick={() => setOpen((o) => !o)}>
{button}
</span>
) : (
<button type="button" data-pop-trigger className="bo-iconbtn" aria-expanded={open} aria-label={label} title={label} onClick={() => setOpen((o) => !o)}>
<I name={icon ?? 'more'} size={20} />
</button>
)}
{open && (
<div className="bo-pop" ref={ref} role="menu">
{list.map((it) => (
<button
key={it.key}
type="button"
role="menuitem"
className="bo-pop__item"
aria-current={it.current || undefined}
onClick={() => {
setOpen(false);
it.onClick();
}}
>
{it.icon && <I name={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
)}
</div>
);
}
function AssignMenu({ channel, assignee, me, busy, onAssign }: { channel: Channel; assignee: { id: string; name: string } | null; me: Me; busy: boolean; onAssign: (id: string | null) => void }) {
const { t } = useT();
const trpc = useTRPC();
const colleagues = useQuery(trpc.bo.chats.colleagues.queryOptions({ channel }));
const others = (colleagues.data ?? []).filter((c) => c.id !== me.id);
// никто не ведёт — кнопка сразу берёт диалог себе
if (!assignee)
return (
<button type="button" className="bo-btn bo-btn--sm" disabled={busy} onClick={() => onAssign(me.id)}>
<I name="userPlus" size={16} />
<span className="bo-hide-sm">{t('chat.take')}</span>
</button>
);
return (
<Menu
label={t('chat.transfer')}
button={
<button type="button" className="bo-assignee" disabled={busy} aria-haspopup="menu" title={t('chat.transfer')}>
<Avatar name={assignee.name} id={assignee.id} size={26} />
<span className="bo-hide-sm">{assignee.id === me.id ? t('chat.f.mine') : assignee.name}</span>
</button>
}
items={[
assignee.id !== me.id && { key: 'me', icon: 'userPlus', label: t('chat.take'), onClick: () => onAssign(me.id) },
...others.map((c) => ({ key: c.id, label: <span>{t('chat.transferTo')}: <b>{c.name}</b></span>, onClick: () => onAssign(c.id), current: c.id === assignee.id })),
{ key: 'none', icon: 'close' as const, label: t('chat.unassign'), onClick: () => onAssign(null) },
]}
/>
);
}
// ───── панель «о клиенте» ─────
function PeerPanel({ id, me, onClose }: { id: string; me: Me; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const th = useQuery(trpc.bo.chats.get.queryOptions({ id }));
const clientId = th.data?.clientId;
return (
<aside className="bo-chat__side">
<div className="bo-side-panel__head">
<h2 className="bo-h3">{clientId ? t('chat.client') : t('chat.aboutProvider')}</h2>
<button type="button" className="bo-iconbtn" onClick={onClose} aria-label={t('ui.close')}>
<I name="close" size={18} />
</button>
</div>
{clientId ? (
<ClientSummary clientId={clientId} me={me} />
) : th.data ? (
<dl className="bo-kv">
<dt>{t('chat.lang')}</dt>
<dd>{th.data.locale.toUpperCase()}</dd>
<dt>Telegram</dt>
<dd>{th.data.providerLinked ? t('chat.linked') : t('chat.notLinked')}</dd>
</dl>
) : (
<Skeleton h={200} />
)}
</aside>
);
}
function ClientSummary({ clientId, me }: { clientId: string; me: Me }) {
const { t, date, shared } = useT();
const trpc = useTRPC();
const c = useQuery(trpc.bo.clients.get.queryOptions({ id: clientId }));
const [gift, setGift] = useState(false);
const canManage = me.roles.includes('SUPERADMIN') || me.roles.includes('SUPPORT');
const current = useMemo(() => {
const subs = c.data?.subscriptions ?? [];
return subs.find((s) => s.active && s.plan === 'ELITE') ?? subs.find((s) => s.active);
}, [c.data]);
if (!c.data) return <Skeleton h={260} />;
const d = c.data;
return (
<div className="bo-col" style={{ gap: 16 }}>
<div className={`bo-minipass bo-minipass--${current?.plan ?? 'none'}`}>
<span className="bo-minipass__plan">{current ? t(`plan.${current.plan}`) : t('plan.none')}</span>
{current && <span>{t('chat.subUntil', { date: date(current.endsAt) })}</span>}
</div>
<dl className="bo-kv">
{d.phone && (
<>
<dt>{t('chat.phone')}</dt>
<dd>
<a href={`tel:${d.phone.replace(/\s/g, '')}`}>{d.phone}</a>
</dd>
</>
)}
<dt>{t('chat.lang')}</dt>
<dd>{d.locale.toUpperCase()}</dd>
<dt>{t('chat.source')}</dt>
<dd>{sourceLabel(t, d)}</dd>
</dl>
{d.tags.length > 0 && (
<div className="bo-tags">
{d.tags.map((tg) => (
<span key={tg} className="bo-tag" style={{ background: 'var(--bc-learn)', color: 'var(--bc-learn-ink)' }}>
{tg}
</span>
))}
</div>
)}
{d.adminNote && <p className="bo-side-panel__note">{d.adminNote}</p>}
{d.orders.length > 0 && (
<div>
<h3 className="bo-h3" style={{ marginBottom: 6 }}>
{t('chat.orders')} · {d.orders.length}
</h3>
<ul className="bo-side-panel__orders">
{d.orders.slice(0, 3).map((o) => (
<li key={o.id}>
<span className="bo-faint bo-num">№{o.number}</span> {shared(`services.${o.service}.name`)}
<span className="bo-faint bo-small" style={{ display: 'block' }}>
{date(o.scheduledAt)}
</span>
</li>
))}
</ul>
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`#/clients/${d.id}`}>
{t('chat.openCard')}
</a>
{canManage && (
<button type="button" className="bo-btn bo-btn--accent bo-btn--sm" onClick={() => setGift(true)}>
<I name="gift" size={16} />
{t('act.gift')}
</button>
)}
</div>
{gift && <SubDialog clientId={d.id} mode="gift" current={current} onClose={() => setGift(false)} />}
</div>
);
}
+26 -7
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useToast, useTRPC } from '@barev/web';
import { navigate, useToast, useTRPC } from '@barev/web';
import { myChannels } from './Chats.tsx';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT, type BoKey } from '../i18n.tsx';
@@ -17,6 +18,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
const isSuper = me.roles.includes('SUPERADMIN');
const canManage = isSuper || me.roles.includes('SUPPORT');
const canChat = myChannels(me).length > 0;
if (c.isError)
return (
@@ -66,12 +68,15 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<span>{t('card.since', { date: date(d.createdAt) })}</span>
</div>
</div>
{d.username && (
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
<I name="telegram" size={16} />
{t('card.telegram')}
</a>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{canChat && <WriteButton clientId={d.id} />}
{d.username && (
<a className="bo-btn bo-btn--ghost" href={`https://t.me/${d.username}`} target="_blank" rel="noreferrer">
<I name="telegram" size={16} />
{t('card.telegram')}
</a>
)}
</div>
</header>
<div className="bo-client-grid">
@@ -323,6 +328,20 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
);
}
/** Открыть переписку с клиентом (или начать её) — в канале, куда он сейчас пишет */
function WriteButton({ clientId }: { clientId: string }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const open = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError: (e) => toast(errText(t, e), 'error') }));
return (
<button type="button" className="bo-btn bo-btn--accent" onClick={() => open.mutate({ clientId })} disabled={open.isPending}>
<I name="chats" size={16} />
{t('chat.write')}
</button>
);
}
function TagsAndNote({ id, tags, note }: { id: string; tags: string[]; note: string }) {
const { t } = useT();
const trpc = useTRPC();
+3 -1
View File
@@ -2,12 +2,14 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// Новая админка. API проксируется — cookie-сессия на том же origin.
// BO_API — другой локальный API (например, второй экземпляр на соседнем порту)
const api = process.env.BO_API ?? 'http://localhost:4000';
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000' },
proxy: { '/trpc': api, '/files': api },
},
build: { target: 'es2022', sourcemap: true },
});
+56 -10
View File
@@ -5,7 +5,20 @@
import { getDb } from '@barev/db';
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
import { decodeStartPayload } from '@barev/core';
import { addClientMessage, applyAgencyCode, DomainError, getClientByTelegramId, giveConsent, linkWebRequest, setLocale, startClient, type Ctx } from '@barev/services';
import {
addClientMessage,
applyAgencyCode,
DomainError,
getClientByTelegramId,
giveConsent,
linkWebRequest,
setClientChannel,
setLocale,
startClient,
type ChatChannel,
type ChatFile,
type Ctx,
} from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier } from '@barev/services/queue';
import { Bot, InlineKeyboard, type Context } from 'grammy';
@@ -140,19 +153,50 @@ bot.command('app', async (c) => {
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
/** ONB-6: ручной ввод кода агентства; любой другой текст — консьержу */
/** Сообщение в чат команды: подписчик пишет консьержу, остальные — поддержке. Ответ придёт сюда же */
async function toTeam(c: Context, text: string, file?: ChatFile) {
const locale = await localeOf(c.from);
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client?.consentAt) return askConsent(c, locale);
awaitingCode.delete(c.chat!.id);
const res = await addClientMessage(ctx, client.id, text, file);
// диалог только что открылся — подтверждаем, что сообщение дошло
if (res?.opened) await c.reply(createT(locale)(res.channel === 'CONCIERGE' ? 'bot.client.conciergeReceived' : 'bot.client.supportReceived'));
}
/** Переключить канал: /support — поддержка, /concierge — консьерж (только с подпиской) */
async function switchChannel(c: Context, channel: ChatChannel) {
const locale = await localeOf(c.from);
const t = createT(locale);
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
if (!client?.consentAt) return askConsent(c, locale);
try {
await setClientChannel(ctx, client.id, channel);
await c.reply(t(channel === 'CONCIERGE' ? 'bot.client.channelConcierge' : 'bot.client.channelSupport'));
} catch (e) {
if (e instanceof DomainError) await c.reply(t(`errors.${e.code}`));
else throw e;
}
}
bot.command('support', (c) => switchChannel(c, 'SUPPORT'));
bot.command('concierge', (c) => switchChannel(c, 'CONCIERGE'));
/** Фото и файлы — тоже в чат команды; берём самое крупное превью фото */
bot.on('message:photo', async (c) => {
const photo = c.message.photo.at(-1)!;
await toTeam(c, c.message.caption ?? '', { tgFileId: photo.file_id, tgBot: 'client', fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null });
});
bot.on('message:document', async (c) => {
const d = c.message.document;
await toTeam(c, c.message.caption ?? '', { tgFileId: d.file_id, tgBot: 'client', fileName: d.file_name ?? 'file', mime: d.mime_type ?? 'application/octet-stream', size: d.file_size ?? null });
});
/** ONB-6: ручной ввод кода агентства; любой другой текст — в чат команды */
bot.on('message:text', async (c) => {
const locale = await localeOf(c.from);
const t = createT(locale);
const client = await getClientByTelegramId(db, BigInt(c.from.id));
if (!awaitingCode.has(c.chat.id)) {
if (!client?.consentAt) return askConsent(c, locale);
// чат с консьержем: сообщение видит оператор в админке, ответ придёт сюда же
const first = !(await db.conciergeMessage.count({ where: { clientId: client.id } }));
await addClientMessage(ctx, client.id, c.message.text);
if (first) await c.reply(t('bot.client.conciergeReceived'));
return;
}
if (!awaitingCode.has(c.chat.id)) return toTeam(c, c.message.text);
if (!client) return;
try {
const res = await applyAgencyCode(ctx, client.id, c.message.text);
@@ -180,6 +224,8 @@ async function main() {
{ command: 'start', description: t('bot.client.cmdStart') },
{ command: 'app', description: t('bot.client.cmdApp') },
{ command: 'code', description: t('bot.client.cmdCode') },
{ command: 'support', description: t('bot.client.cmdSupport') },
{ command: 'concierge', description: t('bot.client.cmdConcierge') },
{ command: 'language', description: t('bot.client.cmdLanguage') },
{ command: 'help', description: t('bot.client.cmdHelp') },
],
+52 -13
View File
@@ -4,7 +4,21 @@
*/
import { getDb } from '@barev/db';
import { createT, formatDateTime, type Locale } from '@barev/i18n';
import { acceptPartnerInvite, addProviderMessage, checkCard, DomainError, linkByInvite, proIdentity, redeemOffer, replyTo, respondToOffer, type Ctx, type Peer } from '@barev/services';
import {
acceptPartnerInvite,
addProviderMessage,
checkCard,
DomainError,
linkByInvite,
peerName,
proIdentity,
redeemOffer,
replyTo,
respondToOffer,
staffSend,
type Ctx,
type Peer,
} from '@barev/services';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier } from '@barev/services/queue';
import { Bot, InlineKeyboard, type Context } from 'grammy';
@@ -53,7 +67,7 @@ const adminOf = (c: Context) => (c.from ? db.adminUser.findFirst({ where: { tele
const adminT = createT('ru');
/** Оператор нажал «Ответить» — следующий его текст уйдёт этому клиенту или исполнителю */
const replyingTo = new Map<number, Peer & { name: string }>();
const replyingTo = new Map<number, { threadId?: string; peer?: Peer; name: string }>();
const LEVEL_NAME: Record<string, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
type Check = Awaited<ReturnType<typeof checkCard>>;
@@ -198,19 +212,31 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
});
/** «Ответить» под уведомлением о сообщении */
bot.callbackQuery(/^ar:(c|p):(\w+)$/, async (c) => {
bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
const admin = await adminOf(c);
if (!admin) {
await c.answerCallbackQuery({ text: adminT('bot.admin.notAdmin'), show_alert: true });
return;
}
const [, kind, id] = c.match;
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
const name =
kind === 'c'
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
replyingTo.set(c.chat!.id, { ...peer, name });
let name = '';
if (kind === 't') {
// ответ в конкретный диалог (поддержка или консьерж)
const thread = await db.chatThread.findUnique({ where: { id: id! }, include: { client: { include: { user: true } }, provider: true } });
if (!thread) {
await c.answerCallbackQuery({ text: adminT('errors.NOT_FOUND'), show_alert: true });
return;
}
name = peerName(thread);
replyingTo.set(c.chat!.id, { threadId: thread.id, name });
} else {
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
name =
kind === 'c'
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
replyingTo.set(c.chat!.id, { peer, name });
}
await c.answerCallbackQuery();
await c.reply(adminT('bot.admin.replyPrompt', { name }), { reply_markup: { force_reply: true, input_field_placeholder: adminT('bot.admin.replyPlaceholder') } });
});
@@ -226,7 +252,8 @@ bot.on('message:text', async (c) => {
const admin = await adminOf(c);
if (target && admin) {
try {
await replyTo(ctx, { ...(target.clientId ? { clientId: target.clientId } : { providerId: target.providerId! }), adminUserId: admin.id, text: c.message.text });
if (target.threadId) await staffSend(ctx, { threadId: target.threadId, adminUserId: admin.id, text: c.message.text });
else await replyTo(ctx, { ...target.peer!, adminUserId: admin.id, text: c.message.text });
replyingTo.delete(c.chat.id);
await c.reply(adminT('bot.admin.replySent', { name: target.name }));
} catch (e) {
@@ -238,9 +265,8 @@ bot.on('message:text', async (c) => {
// исполнитель пишет оператору — сообщение появится в админке
const id = await identity(c);
if (id?.provider) {
const first = !(await db.conciergeMessage.count({ where: { providerId: id.provider.id } }));
await addProviderMessage(ctx, id.provider.id, c.message.text);
if (first) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
const res = await addProviderMessage(ctx, id.provider.id, c.message.text);
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
return;
}
if (admin) {
@@ -250,6 +276,19 @@ bot.on('message:text', async (c) => {
await greet(c);
});
/** Исполнитель прислал фото или файл — тоже в поддержку */
bot.on(['message:photo', 'message:document'], async (c, next) => {
const id = await identity(c);
if (!id?.provider) return next();
const photo = c.message.photo?.at(-1);
const doc = c.message.document;
const file = photo
? { tgFileId: photo.file_id, tgBot: 'pro' as const, fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null }
: { tgFileId: doc!.file_id, tgBot: 'pro' as const, fileName: doc!.file_name ?? 'file', mime: doc!.mime_type ?? 'application/octet-stream', size: doc!.file_size ?? null };
const res = await addProviderMessage(ctx, id.provider.id, c.message.caption ?? '', file);
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
});
bot.on('message', greet);
bot.catch((err) => console.error('[bot-pro]', err.error));
+9 -7
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { I18nProvider, match, openTelegram, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import { I18nProvider, match, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import type { ReactNode } from 'react';
import { Consent } from './screens/Consent.tsx';
import { Docs } from './screens/Docs.tsx';
@@ -15,6 +15,7 @@ import { Orders } from './screens/Orders.tsx';
import { Perks } from './screens/Perks.tsx';
import { Profile } from './screens/Profile.tsx';
import { Services } from './screens/Services.tsx';
import { Chat } from './screens/Chat.tsx';
const ROOTS = ['/', '/services', '/perks', '/profile', '/docs'];
@@ -38,7 +39,7 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
) : !me.data.segment ? (
<Segment />
) : (
<Routes segment={me.data.segment} conciergeUrl={me.data.conciergeUrl} />
<Routes segment={me.data.segment} />
)}
</I18nProvider>
);
@@ -48,7 +49,7 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
* Два сценария: MOVING — план переезда, документы, консьерж; LIVING — услуги со скидками, партнёры, заказы.
* Остальные экраны (услуги, подписка, заказы) доступны из обоих.
*/
function Routes({ segment, conciergeUrl }: { segment: 'MOVING' | 'LIVING'; conciergeUrl: string }) {
function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
const { path } = useLocation();
useTelegramBack(path, ROOTS);
let screen: ReactNode;
@@ -63,18 +64,19 @@ function Routes({ segment, conciergeUrl }: { segment: 'MOVING' | 'LIVING'; conci
else if (path === '/perks') screen = <Perks />;
else if (path === '/profile') screen = <Profile />;
else if (path === '/membership') screen = <Membership />;
else if (path === '/chat') screen = <Chat />;
else screen = segment === 'MOVING' ? <Move /> : <Home />;
const withNav = ROOTS.includes(path) || path === '/orders';
return (
<>
{screen}
{withNav && (segment === 'MOVING' ? <MoveNav path={path} conciergeUrl={conciergeUrl} /> : <BottomNav path={path} />)}
{withNav && (segment === 'MOVING' ? <MoveNav path={path} /> : <BottomNav path={path} />)}
</>
);
}
/** Меню переезда: План / Документы / Консьерж (чат с ботом) / Услуги / Профиль */
function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string }) {
function MoveNav({ path }: { path: string }) {
const { t } = useI18n();
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
<a href={`#${href}`} aria-current={path === href ? 'page' : undefined}>
@@ -86,9 +88,9 @@ function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string })
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
{item('/', 'globe', t('client.nav.plan'))}
{item('/docs', 'docs', t('client.nav.docs'))}
<button type="button" className="bc-bottomnav__plus" onClick={() => openTelegram(conciergeUrl)} aria-label={t('client.concierge.title')}>
<a href="#/chat" className="bc-bottomnav__plus" aria-label={t('client.concierge.title')}>
<Icon name="chat" size={24} strokeWidth={2.4} />
</button>
</a>
{item('/services', 'grid', t('client.nav.services'))}
{item('/profile', 'user', t('client.nav.profile'))}
</nav>
+154
View File
@@ -779,3 +779,157 @@
background: var(--bc-bg);
color: var(--bc-ink-3);
}
/* ───── чат с командой ───── */
.chat-screen {
max-width: 560px;
margin: 0 auto;
height: 100dvh;
display: flex;
flex-direction: column;
}
.chat-screen__head {
padding: 16px 16px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-switch {
display: flex;
padding: 4px;
gap: 4px;
border-radius: 999px;
background: var(--bc-surface);
}
.chat-switch button {
flex: 1;
height: 36px;
border: 0;
border-radius: 999px;
background: none;
font: inherit;
font-weight: 600;
font-size: 14px;
color: var(--bc-ink-2);
}
.chat-switch button[aria-pressed='true'] {
background: var(--bc-plum);
color: #f6e9dc;
}
.chat-upsell {
align-self: flex-start;
padding: 6px 12px;
border-radius: 999px;
background: var(--bc-pink);
color: var(--bc-plum);
font-weight: 600;
text-decoration: none;
}
.chat-list {
flex: 1;
overflow-y: auto;
padding: 8px 16px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-hint {
text-align: center;
margin: 4px 12px 8px;
}
.chat-day {
align-self: center;
margin: 10px 0 4px;
padding: 3px 12px;
border-radius: 999px;
background: var(--bc-surface);
font-size: 12px;
font-weight: 600;
color: var(--bc-ink-3);
}
.chat-msg {
align-self: flex-start;
max-width: 82%;
padding: 9px 13px 7px;
border-radius: 20px 20px 20px 6px;
background: var(--bc-surface);
line-height: 1.4;
overflow-wrap: anywhere;
}
.chat-msg--mine {
align-self: flex-end;
border-radius: 20px 20px 6px 20px;
background: var(--bc-plum);
color: #f6e9dc;
}
.chat-msg__who {
font-size: 12px;
font-weight: 700;
color: var(--bc-accent);
margin-bottom: 2px;
}
.chat-msg__text {
white-space: pre-wrap;
}
.chat-msg__time {
font-size: 11px;
opacity: 0.6;
text-align: right;
margin-top: 2px;
}
.chat-msg__img {
display: block;
max-width: 100%;
max-height: 260px;
border-radius: 14px;
margin: 2px -5px 6px;
}
.chat-msg__file {
display: block;
font-weight: 600;
color: inherit;
margin-bottom: 4px;
}
.chat-compose {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px 12px calc(10px + env(safe-area-inset-bottom));
background: var(--bc-bg);
}
.chat-compose__input {
flex: 1;
min-height: 44px;
max-height: 140px;
padding: 11px 16px;
border-radius: 22px;
border: 1.5px solid var(--bc-line);
background: var(--bc-surface);
font: inherit;
font-size: 16px;
resize: none;
field-sizing: content;
}
.chat-compose__input:focus {
outline: none;
border-color: var(--bc-accent);
}
.chat-compose__btn,
.chat-compose__send {
width: 44px;
height: 44px;
flex: none;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: var(--bc-surface);
color: var(--bc-ink);
}
.chat-compose__send {
background: var(--bc-accent);
color: #fff;
}
.chat-compose__send:disabled {
opacity: 0.4;
}
+162
View File
@@ -0,0 +1,162 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { errorText, getInitData, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { PageHead } from '../lib.tsx';
const MAX_FILE = 10 * 1024 * 1024;
/**
* Чат с командой Barev Club: та же переписка, что в чате с ботом.
* Подписчик пишет личному консьержу, остальные — в поддержку; ответ приходит и сюда, и в Telegram.
*/
export function Chat() {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const chat = useQuery({ ...trpc.client.chat.get.queryOptions(), refetchInterval: 5_000 });
const [text, setText] = useState('');
const [uploading, setUploading] = useState(false);
const list = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const refresh = () => qc.invalidateQueries({ queryKey: trpc.client.chat.get.queryKey() });
const send = useMutation(
trpc.client.chat.send.mutationOptions({
onMutate: () => haptic.tap(),
onSuccess: () => {
setText('');
void refresh();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const setChannel = useMutation(trpc.client.chat.setChannel.mutationOptions({ onSuccess: () => void refresh(), onError: (e) => toast(errorText(t, e), 'error') }));
const lastId = chat.data?.messages.at(-1)?.id;
useLayoutEffect(() => {
list.current?.scrollTo({ top: list.current.scrollHeight });
}, [lastId]);
const upload = async (file: File) => {
if (file.size > MAX_FILE) return toast(t('client.chat.fileTooBig'), 'error');
setUploading(true);
try {
const form = new FormData();
form.set('text', text);
form.set('file', file);
const res = await fetch('/files/client/chat', { method: 'POST', body: form, headers: { authorization: `tma ${getInitData()}` } });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setText('');
void refresh();
} catch (e) {
toast(errorText(t, e), 'error');
} finally {
setUploading(false);
}
};
if (!chat.data)
return (
<Center>
<Spinner />
</Center>
);
const d = chat.data;
let prevDay = '';
return (
<main className="chat-screen">
<div className="chat-screen__head">
<PageHead title={t('client.chat.title')} />
{d.canConcierge ? (
<div className="chat-switch" role="group">
{(['CONCIERGE', 'SUPPORT'] as const).map((c) => (
<button key={c} type="button" aria-pressed={d.channel === c} disabled={setChannel.isPending} onClick={() => d.channel !== c && setChannel.mutate({ channel: c })}>
{t(c === 'CONCIERGE' ? 'client.chat.concierge' : 'client.chat.support')}
</button>
))}
</div>
) : (
<a href="#/membership" className="chat-upsell bc-small">
{t('client.chat.conciergeOnly')} →
</a>
)}
</div>
<div className="chat-list" ref={list}>
<p className="chat-hint bc-small bc-muted">{t('client.chat.hint')}</p>
{d.messages.length === 0 && <p className="chat-hint">{t('client.chat.empty')}</p>}
{d.messages.map((m) => {
const day = dt(m.createdAt, { day: 'numeric', month: 'long' });
const sep = day !== prevDay ? <div className="chat-day">{day}</div> : null;
prevDay = day;
return (
<Fragment key={m.id}>
{sep}
<div className={`chat-msg ${m.mine ? 'chat-msg--mine' : ''}`}>
{!m.mine && <div className="chat-msg__who">{t(m.channel === 'CONCIERGE' ? 'client.chat.concierge' : 'client.chat.support')}</div>}
{m.file && <ChatFile id={m.id} file={m.file} />}
{m.text && <div className="chat-msg__text">{m.text}</div>}
<div className="chat-msg__time">{dt(m.createdAt, { hour: '2-digit', minute: '2-digit' })}</div>
</div>
</Fragment>
);
})}
</div>
<form
className="chat-compose"
onSubmit={(e) => {
e.preventDefault();
if (text.trim() && !send.isPending) send.mutate({ text });
}}
>
<button type="button" className="chat-compose__btn" onClick={() => fileInput.current?.click()} disabled={uploading} aria-label={t('client.chat.attach')}>
{uploading ? <Spinner /> : <Icon name="camera" size={22} />}
</button>
<input
ref={fileInput}
type="file"
hidden
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
<textarea className="chat-compose__input" rows={1} value={text} maxLength={4000} placeholder={t('client.chat.placeholder')} onChange={(e) => setText(e.target.value)} aria-label={t('client.chat.placeholder')} />
<button type="submit" className="chat-compose__send" disabled={!text.trim() || send.isPending} aria-label={t('client.chat.send')}>
<Icon name="arrow" size={22} strokeWidth={2.4} />
</button>
</form>
</main>
);
}
/** Файл из переписки: картинку показываем, остальное — ссылкой. Доступ по initData, поэтому через fetch */
function ChatFile({ id, file }: { id: string; file: { name: string | null; mime: string | null } }) {
const { t } = useI18n();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let revoke: string | null = null;
fetch(`/files/client/chat-file/${id}`, { headers: { authorization: `tma ${getInitData()}` } })
.then((r) => (r.ok ? r.blob() : null))
.then((b) => {
if (!b) return;
revoke = URL.createObjectURL(b);
setUrl(revoke);
})
.catch(() => {});
return () => {
if (revoke) URL.revokeObjectURL(revoke);
};
}, [id]);
if (file.mime?.startsWith('image/') && url) return <img className="chat-msg__img" src={url} alt={file.name ?? ''} />;
return (
<a className="chat-msg__file" href={url ?? undefined} download={file.name ?? undefined} target="_blank" rel="noreferrer">
📎 {file.name ?? t('client.chat.file')}
</a>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { errorText, haptic, openTelegram, useI18n, useToast, useTRPC } from '@barev/web';
import { errorText, haptic, navigate, useI18n, useToast, useTRPC } from '@barev/web';
import { itemHint, itemName, PageHead } from '../lib.tsx';
/** Чек-лист документов, которые лучше собрать до переезда; отметки сохраняются в заявке на переезд */
@@ -60,7 +60,7 @@ export function Docs() {
</ul>
</Card>
{me.data && (
<button className="bc-btn bc-btn--light bc-btn--block" onClick={() => openTelegram(me.data.conciergeUrl)}>
<button className="bc-btn bc-btn--light bc-btn--block" onClick={() => navigate('/chat')}>
<Icon name="chat" size={18} />
{t('client.docs.ask')}
</button>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { openTelegram, useI18n, useTRPC } from '@barev/web';
import { navigate, useI18n, useTRPC } from '@barev/web';
import { itemName, LevelBadge, type Level } from '../lib.tsx';
import { MoveForm } from './MoveForm.tsx';
@@ -86,7 +86,7 @@ export function Move() {
<b>{t('client.docs.title')}</b>
<span className="bc-small">{t('client.docs.progress', { done: docsDone, total: lead.docs.length })}</span>
</a>
<button className="move-action bc-tone-learn" onClick={() => openTelegram(me.data.conciergeUrl)}>
<button className="move-action bc-tone-learn" onClick={() => navigate('/chat')}>
<Icon name="chat" size={24} />
<b>{t('client.concierge.title')}</b>
<span className="bc-small">{t('client.concierge.short')}</span>
+3 -1
View File
@@ -2,12 +2,14 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
// API_PROXY — другой локальный API (например, второй экземпляр на соседнем порту)
const api = process.env.API_PROXY ?? 'http://localhost:4000';
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
proxy: { '/trpc': api, '/files': api, '/client-log': api },
},
build: { target: 'es2022', sourcemap: true },
});
+50 -11
View File
@@ -4,7 +4,8 @@
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
import { chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
import { compactRows, escapeHtml, TelegramSender, webAppButton, type InlineButton } from '@barev/services/telegram';
@@ -30,6 +31,12 @@ async function toAdmins(text: string, buttons: InlineButton[][] = []) {
const adminT = createT('ru');
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null);
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
BACKOFFICE_URL.startsWith('https://') && threadId
? { text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/chats/${threadId}` }
: adminButton(`/concierge?${peer.startsWith('c:') ? 'client' : 'provider'}=${peer.slice(2)}`);
const who = (u: { firstName: string | null; lastName?: string | null; username: string | null }) =>
`${escapeHtml(`${u.firstName ?? ''} ${u.lastName ?? ''}`.trim())}${u.username ? ` (@${escapeHtml(u.username)})` : ''}`;
@@ -204,23 +211,55 @@ async function handle(event: DomainEvent) {
return;
}
case 'concierge.reply': {
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
if (!msg) return;
if (msg.client) {
const t = createT(msg.client.user.locale as Locale);
await clientBot.send({ chatId: msg.client.user.telegramId, text: `<b>${t('notify.concierge')}</b>\n${escapeHtml(msg.text)}` });
} else if (msg.provider?.user) {
const t = createT(msg.provider.user.locale as Locale);
await proBot.send({ chatId: msg.provider.user.telegramId, text: `<b>${t('notify.operator')}</b>\n${escapeHtml(msg.text)}` });
const msg = await db.conciergeMessage.findUnique({
where: { id: event.messageId },
include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: { select: { channel: true } } },
});
if (!msg || msg.internal) return;
const to = msg.client ? { bot: clientBot, user: msg.client.user } : msg.provider?.user ? { bot: proBot, user: msg.provider.user } : null;
if (!to) return;
const t = createT(to.user.locale as Locale);
// клиенту — от кого ответ: консьерж или поддержка; исполнителю — оператор
const head = msg.client ? t(msg.thread?.channel === 'CONCIERGE' ? 'notify.concierge' : 'notify.support') : t('notify.operator');
const text = `<b>${head}</b>${msg.text ? `\n${escapeHtml(msg.text)}` : ''}`;
let delivered: boolean;
if (msg.fileKey) {
const body = await getFile(msg.fileKey);
delivered = await to.bot.sendFile({ chatId: to.user.telegramId, caption: text, body, fileName: msg.fileName ?? 'file', mime: msg.mime ?? 'application/octet-stream' });
} else {
delivered = await to.bot.send({ chatId: to.user.telegramId, text });
}
if (!delivered) await db.conciergeMessage.update({ where: { id: msg.id }, data: { deliveryError: 'UNREACHABLE' } });
return;
}
case 'concierge.message': {
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: true } });
if (!msg) return;
const from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`;
const peer = msg.clientId ? `c:${msg.clientId}` : `p:${msg.providerId}`;
await toAdmins(`💬 <b>${from}</b>\n${escapeHtml(msg.text)}`, compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:${peer}` }, adminButton(`/concierge?${msg.clientId ? 'client' : 'provider'}=${msg.clientId ?? msg.providerId}`)]]));
const channel = msg.thread ? `${adminT(msg.thread.channel === 'CONCIERGE' ? 'bot.admin.channelConcierge' : 'bot.admin.channelSupport')} · ` : '';
const body = msg.text ? escapeHtml(msg.text) : '';
const file = msg.tgFileId || msg.fileKey ? `📎 ${escapeHtml(msg.fileName ?? adminT('bot.admin.file'))}` : '';
const text = `💬 ${channel}<b>${from}</b>\n${[file, body].filter(Boolean).join('\n')}`;
const buttons = compactRows([[{ text: adminT('bot.admin.reply'), callback_data: msg.threadId ? `ar:t:${msg.threadId}` : `ar:${peer}` }, chatButton(msg.threadId, peer)]]);
const recipients = msg.thread ? await chatRecipients(ctx, msg.thread) : await db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
return;
}
case 'chat.assigned': {
const [thread, assignee, by] = await Promise.all([
db.chatThread.findUnique({ where: { id: event.threadId }, include: { client: { include: { user: true } }, provider: true } }),
db.adminUser.findUnique({ where: { id: event.assigneeId } }),
db.adminUser.findUnique({ where: { id: event.byAdminId }, select: { name: true } }),
]);
if (!thread || !assignee?.telegramId || !assignee.tgNotify) return;
const name = thread.client ? who(thread.client.user) : escapeHtml(thread.provider?.name ?? '');
const peer = thread.clientId ? `c:${thread.clientId}` : `p:${thread.providerId}`;
await proBot.send({
chatId: assignee.telegramId,
text: `👋 ${adminT('bot.admin.chatAssigned', { by: escapeHtml(by?.name ?? ''), name })}`,
buttons: compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:t:${thread.id}` }, chatButton(thread.id, peer)]]),
});
return;
}
case 'lead.submitted': {
+2
View File
@@ -16,6 +16,8 @@ LANDING_URL=https://barevclub.com
CLIENT_MINIAPP_URL=https://app.barevclub.com
PRO_MINIAPP_URL=https://pro.barevclub.com
ADMIN_URL=https://admin.barevclub.com
# новая админка — когда выложим: тогда «Открыть» в уведомлениях о сообщениях ведёт в её чаты
# BACKOFFICE_URL=https://team.barevclub.com
# серверный адрес API для лендинга (внутри docker-сети)
API_URL=http://barev-app:4000
API_PORT=4000
@@ -0,0 +1,130 @@
-- CreateEnum
CREATE TYPE "ChatChannel" AS ENUM ('SUPPORT', 'CONCIERGE');
-- CreateEnum
CREATE TYPE "ChatStatus" AS ENUM ('OPEN', 'CLOSED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ConciergeAuthor" ADD VALUE 'BOT';
ALTER TYPE "ConciergeAuthor" ADD VALUE 'SYSTEM';
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "chatChannel" "ChatChannel";
-- AlterTable
ALTER TABLE "ConciergeMessage" ADD COLUMN "deliveryError" TEXT,
ADD COLUMN "fileKey" TEXT,
ADD COLUMN "fileName" TEXT,
ADD COLUMN "internal" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "mime" TEXT,
ADD COLUMN "size" INTEGER,
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'BOT',
ADD COLUMN "tgBot" TEXT,
ADD COLUMN "tgFileId" TEXT,
ADD COLUMN "threadId" TEXT;
-- CreateTable
CREATE TABLE "ChatThread" (
"id" TEXT NOT NULL,
"channel" "ChatChannel" NOT NULL,
"clientId" TEXT,
"providerId" TEXT,
"status" "ChatStatus" NOT NULL DEFAULT 'OPEN',
"assigneeId" TEXT,
"botActive" BOOLEAN NOT NULL DEFAULT false,
"lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"waitingSince" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatThread_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatTemplate" (
"id" TEXT NOT NULL,
"channel" "ChatChannel",
"title" TEXT NOT NULL,
"text" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChatTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ChatThread_channel_status_lastMessageAt_idx" ON "ChatThread"("channel", "status", "lastMessageAt");
-- CreateIndex
CREATE INDEX "ChatThread_assigneeId_status_idx" ON "ChatThread"("assigneeId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "ChatThread_clientId_channel_key" ON "ChatThread"("clientId", "channel");
-- CreateIndex
CREATE UNIQUE INDEX "ChatThread_providerId_channel_key" ON "ChatThread"("providerId", "channel");
-- CreateIndex
CREATE INDEX "ConciergeMessage_threadId_createdAt_idx" ON "ConciergeMessage"("threadId", "createdAt");
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ChatThread"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatTemplate" ADD CONSTRAINT "ChatTemplate_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ───── Перенос старой переписки: у каждого собеседника появляется диалог ─────
-- клиент с активной подпиской → консьерж, остальные → поддержка; есть непрочитанные — диалог открыт
INSERT INTO "ChatThread" ("id", "channel", "clientId", "status", "lastMessageAt", "waitingSince", "closedAt", "createdAt")
SELECT
'cht' || substr(md5('c:' || m."clientId"), 1, 22),
CASE WHEN EXISTS (
SELECT 1 FROM "Subscription" s
WHERE s."clientId" = m."clientId" AND s."status" = 'ACTIVE' AND s."startsAt" <= now() AND s."endsAt" > now()
) THEN 'CONCIERGE'::"ChatChannel" ELSE 'SUPPORT'::"ChatChannel" END,
m."clientId",
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN 'OPEN'::"ChatStatus" ELSE 'CLOSED'::"ChatStatus" END,
max(m."createdAt"),
min(m."createdAt") FILTER (WHERE m."author" <> 'OPERATOR' AND m."readAt" IS NULL),
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN NULL ELSE max(m."createdAt") END,
min(m."createdAt")
FROM "ConciergeMessage" m
WHERE m."clientId" IS NOT NULL
GROUP BY m."clientId";
-- исполнители пишут в поддержку
INSERT INTO "ChatThread" ("id", "channel", "providerId", "status", "lastMessageAt", "waitingSince", "closedAt", "createdAt")
SELECT
'cht' || substr(md5('p:' || m."providerId"), 1, 22),
'SUPPORT'::"ChatChannel",
m."providerId",
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN 'OPEN'::"ChatStatus" ELSE 'CLOSED'::"ChatStatus" END,
max(m."createdAt"),
min(m."createdAt") FILTER (WHERE m."author" <> 'OPERATOR' AND m."readAt" IS NULL),
CASE WHEN bool_or(m."author" <> 'OPERATOR' AND m."readAt" IS NULL) THEN NULL ELSE max(m."createdAt") END,
min(m."createdAt")
FROM "ConciergeMessage" m
WHERE m."providerId" IS NOT NULL
GROUP BY m."providerId";
UPDATE "ConciergeMessage" m SET "threadId" = t."id"
FROM "ChatThread" t
WHERE (m."clientId" IS NOT NULL AND t."clientId" = m."clientId") OR (m."providerId" IS NOT NULL AND t."providerId" = m."providerId");
+85 -2
View File
@@ -226,6 +226,10 @@ model Client {
leads Lead[]
requests ClientRequest[]
concierge ConciergeMessage[]
chatThreads ChatThread[]
/// куда сейчас пишет клиент: SUPPORT или CONCIERGE (переключается командой бота или командой поддержки);
/// null — по умолчанию: подписчик → консьерж, остальные → поддержка
chatChannel ChatChannel?
@@index([agencyId])
}
@@ -298,6 +302,7 @@ model Provider {
@@index([role, active])
concierge ConciergeMessage[]
chatThreads ChatThread[]
}
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts)
@@ -855,6 +860,8 @@ model AdminUser {
audit AuditLog[]
loginRequests AdminLoginRequest[]
segments SavedSegment[]
chatThreads ChatThread[]
chatTemplates ChatTemplate[]
}
/// Вход в новую админку через Telegram: страница показывает ссылку t.me/<рабочий бот>?start=lg-<code>,
@@ -1110,19 +1117,41 @@ enum ConciergeAuthor {
CLIENT
PROVIDER
OPERATOR
/// ИИ-консьерж (на будущее): отвечает сам, человек может перехватить диалог
BOT
/// служебная строка в ленте: «взял диалог», «передал», «закрыл»
SYSTEM
}
/// Переписка из админки: с клиентом (клиентский бот) или с исполнителем (рабочий бот); ответ приходит в тот же чат с ботом
/// Сообщение в чате. Клиент пишет боту или в мини-аппе — всё попадает в один диалог (ChatThread).
/// Ответ команды приходит клиенту в чат с ботом (клиентский бот, для исполнителя — рабочий).
model ConciergeMessage {
id String @id @default(cuid())
/// диалог; у старых сообщений до этапа 2 проставлен миграцией
threadId String?
thread ChatThread? @relation(fields: [threadId], references: [id], onDelete: Cascade)
clientId String?
client Client? @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
author ConciergeAuthor
/// кто из операторов ответил
/// кто из команды написал (author = OPERATOR / SYSTEM)
adminUserId String?
text String
/// внутренняя заметка команды — клиент её не видит
internal Boolean @default(false)
/// откуда пришло: BOT | MINIAPP | ADMIN
source String @default("BOT")
/// файл: ключ в приватном хранилище (загрузила команда / мини-апп)…
fileKey String?
/// …или file_id в Telegram (прислал клиент боту); бот — client | pro
tgFileId String?
tgBot String?
fileName String?
mime String?
size Int?
/// не удалось доставить в Telegram (клиент заблокировал бота и т.п.)
deliveryError String?
/// оператор прочитал сообщение клиента
readAt DateTime?
createdAt DateTime @default(now())
@@ -1130,4 +1159,58 @@ model ConciergeMessage {
@@index([clientId, createdAt])
@@index([providerId, createdAt])
@@index([author, readAt])
@@index([threadId, createdAt])
}
enum ChatChannel {
/// поддержка — все клиенты и исполнители
SUPPORT
/// консьерж — только подписчики Barev+ / Elite
CONCIERGE
}
enum ChatStatus {
OPEN
CLOSED
}
/// Диалог: один на собеседника в каждом канале. Закрытый диалог открывается заново, когда клиент пишет снова.
model ChatThread {
id String @id @default(cuid())
channel ChatChannel
clientId String?
client Client? @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
status ChatStatus @default(OPEN)
/// кто из команды ведёт диалог
assigneeId String?
assignee AdminUser? @relation(fields: [assigneeId], references: [id])
/// диалог ведёт ИИ-консьерж (задел на будущее); человек может перехватить
botActive Boolean @default(false)
lastMessageAt DateTime @default(now())
/// с какого момента клиент ждёт ответа; null — ответили
waitingSince DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
messages ConciergeMessage[]
@@unique([clientId, channel])
@@unique([providerId, channel])
@@index([channel, status, lastMessageAt])
@@index([assigneeId, status])
}
/// Шаблон быстрого ответа в чате. {name} подставляется именем клиента.
model ChatTemplate {
id String @id @default(cuid())
/// для какого канала; null — для обоих
channel ChatChannel?
title String
text String
createdById String?
createdBy AdminUser? @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "The provider hasn’t opened the work bot via the invite yet — nowhere to send messages.",
"CARD_EXPIRED": "The QR is out of date or invalid — ask the client to reopen the card.",
"OFFER_NOT_AVAILABLE": "This offer isn’t available at the client’s level.",
"OFFER_LIMIT_REACHED": "The limit for this offer is already used up."
"OFFER_LIMIT_REACHED": "The limit for this offer is already used up.",
"CONCIERGE_SUBSCRIBERS_ONLY": "The personal concierge is for Barev+ and Barev Elite members. For now, write to support — we will help too.",
"ASSIGNEE_NO_ACCESS": "This team member has no access to this channel."
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "already used",
"used_WEEK": "again next week",
"used_MONTH": "again next month"
},
"chat": {
"title": "Chat with Barev Club",
"support": "Support",
"concierge": "Personal concierge",
"hint": "This is the same conversation as in the bot chat: replies arrive here and in Telegram.",
"empty": "Tell us how we can help — we’ll reply here.",
"placeholder": "Message…",
"send": "Send",
"attach": "Attach a photo or file",
"conciergeOnly": "The personal concierge comes with Barev+ and Barev Elite",
"team": "Barev Club",
"fileTooBig": "The file is larger than 10 MB",
"file": "File"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Help",
"webRequestOrdered": "Request No. {{request}} received and booked as order No. {{order}} for {{date}}. Track the status in the app.",
"webRequestLinked": "Request No. {{request}} received. An operator will contact you to confirm the time.",
"conciergeReceived": "Passed to the concierge — we'll reply here, in this chat."
"conciergeReceived": "Passed to the concierge — we'll reply here, in this chat.",
"supportReceived": "Passed to support — we'll reply here, in this chat.",
"channelSupport": "You are now writing to support. Just send a message, photo or file.",
"channelConcierge": "You are now writing to your personal concierge. Just send a message, photo or file.",
"cmdSupport": "Write to support",
"cmdConcierge": "Write to the concierge (Barev+)"
},
"pro": {
"notLinked": "This bot is for Barev Club specialists. Ask the operator for an invite link.",
@@ -844,7 +865,11 @@
"newRequest": "App request #{{number}}",
"newWebRequest": "Site request #{{number}}",
"loginOk": "Sign-in confirmed — go back to your browser.",
"loginExpired": "The sign-in link has expired — reload the admin page."
"loginExpired": "The sign-in link has expired — reload the admin page.",
"channelSupport": "Support",
"channelConcierge": "Concierge",
"file": "file",
"chatAssigned": "{{by}} passed you a conversation: {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, card is valid",
@@ -948,7 +973,8 @@
"openPlan": "Open the app",
"concierge": "Barev Club concierge",
"subscriptionActivated": "{{plan}} is on until {{date}}. Discounts are already in service prices.",
"operator": "Barev Club operator"
"operator": "Barev Club operator",
"support": "Barev Club support"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "El profesional aún no ha abierto el bot con la invitación: no hay dónde escribirle.",
"CARD_EXPIRED": "El QR caducó o no es válido: pide al cliente que vuelva a abrir la tarjeta.",
"OFFER_NOT_AVAILABLE": "Esta oferta no está disponible en el nivel del cliente.",
"OFFER_LIMIT_REACHED": "Ya se agotó el límite de esta oferta."
"OFFER_LIMIT_REACHED": "Ya se agotó el límite de esta oferta.",
"CONCIERGE_SUBSCRIBERS_ONLY": "El conserje personal es para miembros de Barev+ y Barev Elite. De momento, escribe a soporte: también te ayudaremos.",
"ASSIGNEE_NO_ACCESS": "Esta persona del equipo no tiene acceso a este canal."
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "ya usado",
"used_WEEK": "de nuevo la próxima semana",
"used_MONTH": "de nuevo el próximo mes"
},
"chat": {
"title": "Chat con Barev Club",
"support": "Soporte",
"concierge": "Conserje personal",
"hint": "Es la misma conversación que en el chat del bot: la respuesta llegará aquí y a Telegram.",
"empty": "Cuéntanos en qué podemos ayudarte: te responderemos aquí.",
"placeholder": "Mensaje…",
"send": "Enviar",
"attach": "Adjuntar foto o archivo",
"conciergeOnly": "El conserje personal está incluido en Barev+ y Barev Elite",
"team": "Barev Club",
"fileTooBig": "El archivo supera los 10 MB",
"file": "Archivo"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Ayuda",
"webRequestOrdered": "Solicitud n.º {{request}} recibida y registrada como pedido n.º {{order}} para el {{date}}. El estado está en la app.",
"webRequestLinked": "Solicitud n.º {{request}} recibida. Un operador te contactará para confirmar la hora.",
"conciergeReceived": "Lo hemos pasado al conserje: te responderemos aquí, en este chat."
"conciergeReceived": "Lo hemos pasado al conserje: te responderemos aquí, en este chat.",
"supportReceived": "Lo hemos pasado a soporte: te responderemos aquí, en este chat.",
"channelSupport": "Ahora escribes a soporte. Envía un mensaje, una foto o un archivo.",
"channelConcierge": "Ahora escribes a tu conserje personal. Envía un mensaje, una foto o un archivo.",
"cmdSupport": "Escribir a soporte",
"cmdConcierge": "Escribir al conserje (Barev+)"
},
"pro": {
"notLinked": "Este bot es para los especialistas de Barev Club. Pide al operador un enlace de invitación.",
@@ -844,7 +865,11 @@
"newRequest": "Solicitud de la app n.º {{number}}",
"newWebRequest": "Solicitud de la web n.º {{number}}",
"loginOk": "Inicio de sesión confirmado: vuelve al navegador.",
"loginExpired": "El enlace de acceso ha caducado: recarga la página de administración."
"loginExpired": "El enlace de acceso ha caducado: recarga la página de administración.",
"channelSupport": "Soporte",
"channelConcierge": "Conserje",
"file": "archivo",
"chatAssigned": "{{by}} te ha pasado una conversación: {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, tarjeta válida",
@@ -948,7 +973,8 @@
"openPlan": "Abrir la app",
"concierge": "Conserje de Barev Club",
"subscriptionActivated": "{{plan}} activo hasta el {{date}}. Los descuentos ya están en los precios.",
"operator": "Operador de Barev Club"
"operator": "Operador de Barev Club",
"support": "Soporte de Barev Club"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "Կատարողը դեռ չի բացել աշխատանքային բոտը հրավերով՝ գրելու տեղ չկա։",
"CARD_EXPIRED": "QR-ը հնացել է կամ անվավեր է՝ խնդրեք հաճախորդին նորից բացել քարտը։",
"OFFER_NOT_AVAILABLE": "Այս առաջարկը հասանելի չէ հաճախորդի մակարդակում։",
"OFFER_LIMIT_REACHED": "Այս առաջարկի սահմանաչափն արդեն սպառված է։"
"OFFER_LIMIT_REACHED": "Այս առաջարկի սահմանաչափն արդեն սպառված է։",
"CONCIERGE_SUBSCRIBERS_ONLY": "Անձնական կոնսիերժը Barev+ և Barev Elite բաժանորդների համար է։ Առայժմ գրեք աջակցությանը՝ մենք էլ կօգնենք։",
"ASSIGNEE_NO_ACCESS": "Այս աշխատակիցը մուտք չունի այս ալիքին։"
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "արդեն ստացել եք",
"used_WEEK": "կրկին հաջորդ շաբաթից",
"used_MONTH": "կրկին հաջորդ ամսից"
},
"chat": {
"title": "Զրույց Barev Club-ի հետ",
"support": "Աջակցություն",
"concierge": "Անձնական կոնսիերժ",
"hint": "Սա նույն զրույցն է, ինչ բոտի չատում․ պատասխանը կգա և՛ այստեղ, և՛ Telegram-ում։",
"empty": "Գրեք, թե ինչով օգնել՝ կպատասխանենք այստեղ։",
"placeholder": "Հաղորդագրություն…",
"send": "Ուղարկել",
"attach": "Կցել լուսանկար կամ ֆայլ",
"conciergeOnly": "Անձնական կոնսիերժը՝ Barev+ և Barev Elite-ում",
"team": "Barev Club",
"fileTooBig": "Ֆայլը 10 ՄԲ-ից մեծ է",
"file": "Ֆայլ"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Օգնություն",
"webRequestOrdered": "Հայտ №{{request}}-ն ընդունված է և ձևակերպված որպես պատվեր №{{order}}՝ {{date}}։ Կարգավիճակը՝ հավելվածում։",
"webRequestLinked": "Հայտ №{{request}}-ն ընդունված է։ Օպերատորը կկապվի քեզ հետ և կհաստատի ժամը։",
"conciergeReceived": "Փոխանցեցինք կոնսիերժին՝ կպատասխանենք այստեղ, այս չատում։"
"conciergeReceived": "Փոխանցեցինք կոնսիերժին՝ կպատասխանենք այստեղ, այս չատում։",
"supportReceived": "Փոխանցեցինք աջակցությանը՝ կպատասխանենք այստեղ, այս չատում։",
"channelSupport": "Այժմ գրում եք աջակցությանը։ Պարզապես ուղարկեք հաղորդագրություն, լուսանկար կամ ֆայլ։",
"channelConcierge": "Այժմ գրում եք ձեր անձնական կոնսիերժին։ Պարզապես ուղարկեք հաղորդագրություն, լուսանկար կամ ֆայլ։",
"cmdSupport": "Գրել աջակցությանը",
"cmdConcierge": "Գրել կոնսիերժին (Barev+)"
},
"pro": {
"notLinked": "Այս բոտը Barev Club-ի կատարողների համար է։ Խնդրեք օպերատորից հրավերի հղում։",
@@ -844,7 +865,11 @@
"newRequest": "Հայտ հավելվածից №{{number}}",
"newWebRequest": "Հայտ կայքից №{{number}}",
"loginOk": "Մուտքը հաստատված է — վերադարձեք բրաուզեր։",
"loginExpired": "Մուտքի հղումը հնացել է — թարմացրեք ադմին էջը։"
"loginExpired": "Մուտքի հղումը հնացել է — թարմացրեք ադմին էջը։",
"channelSupport": "Աջակցություն",
"channelConcierge": "Կոնսիերժ",
"file": "ֆայլ",
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, քարտը գործում է",
@@ -948,7 +973,8 @@
"openPlan": "Բացել հավելվածը",
"concierge": "Barev Club կոնսիերժ",
"subscriptionActivated": "{{plan}}-ը միացված է մինչև {{date}}։ Զեղչերն արդեն ծառայությունների գներում են։",
"operator": "Barev Club օպերատոր"
"operator": "Barev Club օպերատոր",
"support": "Barev Club աջակցություն"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "Исполнитель ещё не открыл рабочий бот по инвайту — написать ему пока некуда.",
"CARD_EXPIRED": "QR устарел или недействителен — попросите клиента открыть карту заново.",
"OFFER_NOT_AVAILABLE": "Это предложение недоступно на уровне клиента.",
"OFFER_LIMIT_REACHED": "Лимит по этому предложению уже исчерпан."
"OFFER_LIMIT_REACHED": "Лимит по этому предложению уже исчерпан.",
"CONCIERGE_SUBSCRIBERS_ONLY": "Личный консьерж — для подписчиков Barev+ и Barev Elite. Пока пишите в поддержку — мы тоже поможем.",
"ASSIGNEE_NO_ACCESS": "У этого сотрудника нет доступа к этому каналу."
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "уже получено",
"used_WEEK": "снова со следующей недели",
"used_MONTH": "снова со следующего месяца"
},
"chat": {
"title": "Чат с Barev Club",
"support": "Поддержка",
"concierge": "Личный консьерж",
"hint": "Это та же переписка, что в чате с ботом: ответ придёт и сюда, и в Telegram.",
"empty": "Напишите, чем помочь, — ответим здесь.",
"placeholder": "Сообщение…",
"send": "Отправить",
"attach": "Прикрепить фото или файл",
"conciergeOnly": "Личный консьерж — в Barev+ и Barev Elite",
"team": "Barev Club",
"fileTooBig": "Файл больше 10 МБ",
"file": "Файл"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "Помощь",
"webRequestOrdered": "Заявка №{{request}} принята и оформлена как заказ №{{order}} на {{date}}. Статус — в приложении.",
"webRequestLinked": "Заявка №{{request}} принята. Оператор свяжется с тобой и подтвердит время.",
"conciergeReceived": "Передали консьержу — ответим здесь, в этом чате."
"conciergeReceived": "Передали консьержу — ответим здесь, в этом чате.",
"supportReceived": "Передали в поддержку — ответим здесь, в этом чате.",
"channelSupport": "Теперь вы пишете в поддержку. Просто отправьте сообщение, фото или файл.",
"channelConcierge": "Теперь вы пишете личному консьержу. Просто отправьте сообщение, фото или файл.",
"cmdSupport": "Написать в поддержку",
"cmdConcierge": "Написать консьержу (Barev+)"
},
"pro": {
"notLinked": "Этот бот — для исполнителей Barev Club. Попроси у оператора ссылку-приглашение.",
@@ -844,7 +865,11 @@
"newRequest": "Заявка из приложения №{{number}}",
"newWebRequest": "Заявка с сайта №{{number}}",
"loginOk": "Вход в админку подтверждён — вернитесь в браузер.",
"loginExpired": "Ссылка для входа устарела — обновите страницу админки."
"loginExpired": "Ссылка для входа устарела — обновите страницу админки.",
"channelSupport": "Поддержка",
"channelConcierge": "Консьерж",
"file": "файл",
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}"
},
"partner": {
"valid": "{{name}} — {{level}}, карта действует",
@@ -948,7 +973,8 @@
"openPlan": "Открыть приложение",
"concierge": "Консьерж Barev Club",
"subscriptionActivated": "{{plan}} включён до {{date}}. Скидки уже в ценах услуг.",
"operator": "Оператор Barev Club"
"operator": "Оператор Barev Club",
"support": "Поддержка Barev Club"
},
"admin": {
"nav": {
+30 -4
View File
@@ -442,7 +442,9 @@
"PROVIDER_NOT_LINKED": "服务人员尚未通过邀请打开工作机器人,暂时无法发送消息。",
"CARD_EXPIRED": "二维码已过期或无效——请客户重新打开会员卡。",
"OFFER_NOT_AVAILABLE": "该优惠不适用于客户当前等级。",
"OFFER_LIMIT_REACHED": "该优惠次数已用完。"
"OFFER_LIMIT_REACHED": "该优惠次数已用完。",
"CONCIERGE_SUBSCRIBERS_ONLY": "专属礼宾仅面向 Barev+ 和 Barev Elite 会员。目前请联系客服,我们同样会帮你。",
"ASSIGNEE_NO_ACCESS": "该成员无权访问此频道。"
},
"client": {
"nav": {
@@ -654,6 +656,20 @@
"used_ONCE": "已使用",
"used_WEEK": "下周可再次使用",
"used_MONTH": "下月可再次使用"
},
"chat": {
"title": "与 Barev Club 聊天",
"support": "客服",
"concierge": "专属礼宾",
"hint": "这与机器人聊天是同一个对话:回复会同时出现在这里和 Telegram。",
"empty": "告诉我们需要什么帮助,我们会在这里回复。",
"placeholder": "输入消息…",
"send": "发送",
"attach": "附加照片或文件",
"conciergeOnly": "专属礼宾包含在 Barev+ 和 Barev Elite 中",
"team": "Barev Club",
"fileTooBig": "文件超过 10 MB",
"file": "文件"
}
},
"pro": {
@@ -815,7 +831,12 @@
"cmdHelp": "帮助",
"webRequestOrdered": "申请 {{request}} 号已收到,并已生成订单 {{order}} 号,时间:{{date}}。状态请在应用中查看。",
"webRequestLinked": "申请 {{request}} 号已收到。客服会联系你确认时间。",
"conciergeReceived": "已转给礼宾,我们会在这个聊天中回复你。"
"conciergeReceived": "已转给礼宾,我们会在这个聊天中回复你。",
"supportReceived": "已转给客服,我们会在这个聊天中回复你。",
"channelSupport": "现在你在给客服写信。直接发送消息、照片或文件即可。",
"channelConcierge": "现在你在给专属礼宾写信。直接发送消息、照片或文件即可。",
"cmdSupport": "联系客服",
"cmdConcierge": "联系礼宾(Barev+)"
},
"pro": {
"notLinked": "此机器人供 Barev Club 服务人员使用。请向客服索取邀请链接。",
@@ -844,7 +865,11 @@
"newRequest": "应用申请 #{{number}}",
"newWebRequest": "网站申请 #{{number}}",
"loginOk": "登录已确认,请返回浏览器。",
"loginExpired": "登录链接已过期,请刷新管理页面。"
"loginExpired": "登录链接已过期,请刷新管理页面。",
"channelSupport": "客服",
"channelConcierge": "礼宾",
"file": "文件",
"chatAssigned": "{{by}} 将对话转交给你:{{name}}"
},
"partner": {
"valid": "{{name}} — {{level}},会员卡有效",
@@ -948,7 +973,8 @@
"openPlan": "打开应用",
"concierge": "Barev Club 礼宾",
"subscriptionActivated": "{{plan}} 已开通,有效期至 {{date}}。服务价格已含折扣。",
"operator": "Barev Club 客服"
"operator": "Barev Club 客服",
"support": "Barev Club 客服支持"
},
"admin": {
"nav": {
+7 -5
View File
@@ -7,23 +7,25 @@
".": "./src/index.ts",
"./queue": "./src/queue.ts",
"./env": "./src/env.ts",
"./telegram": "./src/telegram.ts"
"./telegram": "./src/telegram.ts",
"./storage": "./src/storage.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/i18n": "workspace:*",
"bullmq": "^5.81.5",
"dotenv": "^17.0.0",
"@barev/i18n": "workspace:*"
"dotenv": "^17.0.0"
},
"devDependencies": {
"@types/pg": "^8.15.0",
"dotenv": "^17.0.0",
"vitest": "^4.1.0",
"pg": "^8.16.0",
"@types/pg": "^8.15.0"
"vitest": "^4.1.0"
}
}
+447
View File
@@ -0,0 +1,447 @@
/**
* Чаты (этап 2 новой админки): два канала — поддержка (все клиенты и исполнители) и консьерж (только подписчики).
* Клиент пишет боту или в мини-аппе — сообщения попадают в один диалог; ответ команды приходит в чат с ботом.
* У собеседника по одному диалогу в каждом канале: закрытый диалог открывается заново, когда клиент пишет снова.
*/
import { activeSubscription } from '@barev/core';
import type { Prisma } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
export type ChatChannel = 'SUPPORT' | 'CONCIERGE';
export const CHAT_CHANNELS: ChatChannel[] = ['SUPPORT', 'CONCIERGE'];
export type ChatSource = 'BOT' | 'MINIAPP' | 'ADMIN';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
/** Файл в сообщении: из Telegram (file_id) или из нашего хранилища (ключ) */
export interface ChatFile {
tgFileId?: string | null;
tgBot?: 'client' | 'pro' | null;
fileKey?: string | null;
fileName?: string | null;
mime?: string | null;
size?: number | null;
}
const MAX_TEXT = 4000;
const clean = (s: string | null | undefined) => (s ?? '').trim().slice(0, MAX_TEXT);
const INCOMING = { in: ['CLIENT', 'PROVIDER'] as ('CLIENT' | 'PROVIDER')[] };
async function hasActiveSubscription(ctx: Ctx, clientId: string) {
const subs = await ctx.db.subscription.findMany({ where: { clientId, status: 'ACTIVE' } });
return !!activeSubscription(subs, ctx.now());
}
/** Куда сейчас пишет клиент: выбранный канал, иначе подписчик → консьерж, остальные → поддержка */
export async function clientChannel(ctx: Ctx, clientId: string): Promise<ChatChannel> {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, select: { chatChannel: true } });
const subscriber = await hasActiveSubscription(ctx, clientId);
if (client.chatChannel === 'SUPPORT') return 'SUPPORT';
return subscriber ? 'CONCIERGE' : 'SUPPORT';
}
/** Клиент сам выбирает канал (/support, /concierge в боте). Консьерж — только с подпиской */
export async function setClientChannel(ctx: Ctx, clientId: string, channel: ChatChannel) {
if (channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
await ctx.db.client.update({ where: { id: clientId }, data: { chatChannel: channel } });
return channel;
}
function threadKey(peer: Peer, channel: ChatChannel): Prisma.ChatThreadWhereUniqueInput {
return peer.clientId ? { clientId_channel: { clientId: peer.clientId, channel } } : { providerId_channel: { providerId: peer.providerId!, channel } };
}
async function ensureThread(ctx: Ctx, peer: Peer, channel: ChatChannel) {
return ctx.db.chatThread.upsert({
where: threadKey(peer, channel),
create: { ...peerWhere(peer), channel, status: 'CLOSED', lastMessageAt: ctx.now() },
update: {},
});
}
/**
* Входящее сообщение клиента или исполнителя (бот / мини-апп).
* opened = диалог был закрыт или новый — бот подтверждает «передали, ответим здесь».
*/
export async function receiveMessage(ctx: Ctx, peer: Peer, input: { text?: string | null; file?: ChatFile | null; source?: ChatSource }) {
const text = clean(input.text);
if (!text && !input.file) return null;
const channel: ChatChannel = peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT';
const before = await ensureThread(ctx, peer, channel);
const now = ctx.now();
const message = await ctx.db.conciergeMessage.create({
data: {
...peerWhere(peer),
threadId: before.id,
author: peer.clientId ? 'CLIENT' : 'PROVIDER',
text,
source: input.source ?? 'BOT',
...fileData(input.file),
},
});
const thread = await ctx.db.chatThread.update({
where: { id: before.id },
data: { status: 'OPEN', closedAt: null, lastMessageAt: now, waitingSince: before.waitingSince ?? now },
});
await ctx.notify.emit({ type: 'concierge.message', messageId: message.id });
return { message, thread, channel, opened: before.status === 'CLOSED' };
}
function fileData(f: ChatFile | null | undefined) {
if (!f) return {};
return { tgFileId: f.tgFileId ?? null, tgBot: f.tgBot ?? null, fileKey: f.fileKey ?? null, fileName: f.fileName?.slice(0, 200) ?? null, mime: f.mime ?? null, size: f.size ?? null };
}
async function staffName(ctx: Ctx, id: string) {
return (await ctx.db.adminUser.findUnique({ where: { id }, select: { name: true } }))?.name ?? '';
}
/** Служебная строка в ленте: кто взял, передал, закрыл. В text — код события, в fileName — параметр */
async function systemLine(ctx: Ctx, thread: { id: string; clientId: string | null; providerId: string | null }, adminUserId: string, code: string, param?: string) {
await ctx.db.conciergeMessage.create({
data: { threadId: thread.id, clientId: thread.clientId, providerId: thread.providerId, author: 'SYSTEM', adminUserId, text: code, fileName: param ?? null, internal: true, source: 'ADMIN', readAt: ctx.now() },
});
}
async function getThread(ctx: Ctx, id: string) {
const t = await ctx.db.chatThread.findUnique({ where: { id } });
if (!t) throw new DomainError('NOT_FOUND');
return t;
}
const markRead = (ctx: Ctx, threadId: string) =>
ctx.db.conciergeMessage.updateMany({ where: { threadId, author: INCOMING, readAt: null }, data: { readAt: ctx.now() } });
/**
* Сообщение команды: ответ клиенту (уходит в Telegram через воркер) или внутренняя заметка.
* Ответил первым в неназначенном диалоге — диалог за тобой.
*/
export async function staffSend(ctx: Ctx, input: { threadId: string; adminUserId: string; text?: string | null; internal?: boolean; file?: ChatFile | null }) {
const thread = await getThread(ctx, input.threadId);
const text = clean(input.text);
if (!text && !input.file) throw new DomainError('INVALID_PARAMS');
if (!input.internal && thread.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: thread.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const message = await ctx.db.conciergeMessage.create({
data: {
threadId: thread.id,
clientId: thread.clientId,
providerId: thread.providerId,
author: 'OPERATOR',
adminUserId: input.adminUserId,
text,
internal: !!input.internal,
source: 'ADMIN',
...fileData(input.file),
},
});
if (!input.internal) {
await ctx.db.chatThread.update({
where: { id: thread.id },
data: { waitingSince: null, lastMessageAt: ctx.now(), status: 'OPEN', closedAt: null, assigneeId: thread.assigneeId ?? input.adminUserId, botActive: false },
});
await markRead(ctx, thread.id);
await ctx.notify.emit({ type: 'concierge.reply', messageId: message.id });
}
return message;
}
/** Взять диалог себе / передать коллеге / снять ответственного (assigneeId = null) */
export async function assignThread(ctx: Ctx, input: { threadId: string; assigneeId: string | null; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.assigneeId === input.assigneeId) return thread;
if (input.assigneeId) {
const a = await ctx.db.adminUser.findUnique({ where: { id: input.assigneeId } });
if (!a?.active) throw new DomainError('NOT_FOUND');
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
if (!a.roles.includes(role) && !a.roles.includes('SUPERADMIN')) throw new DomainError('ASSIGNEE_NO_ACCESS');
}
const updated = await ctx.db.chatThread.update({ where: { id: thread.id }, data: { assigneeId: input.assigneeId, botActive: false } });
if (!input.assigneeId) await systemLine(ctx, thread, input.byAdminId, 'unassigned');
else if (input.assigneeId === input.byAdminId) await systemLine(ctx, thread, input.byAdminId, 'took');
else {
await systemLine(ctx, thread, input.byAdminId, 'transferred', await staffName(ctx, input.assigneeId));
await ctx.notify.emit({ type: 'chat.assigned', threadId: thread.id, assigneeId: input.assigneeId, byAdminId: input.byAdminId });
}
return updated;
}
/** Закрыть: вопрос решён. Клиент напишет снова — диалог откроется сам */
export async function closeThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.status === 'CLOSED') return thread;
await markRead(ctx, thread.id);
await systemLine(ctx, thread, input.byAdminId, 'closed');
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'CLOSED', closedAt: ctx.now(), waitingSince: null, botActive: false } });
}
export async function reopenThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.status === 'OPEN') return thread;
await systemLine(ctx, thread, input.byAdminId, 'reopened');
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'OPEN', closedAt: null } });
}
/**
* Перенести диалог клиента в другой канал (например, вопрос подписчика — к поддержке).
* Переписка переезжает целиком, дальше клиент пишет в новый канал.
*/
export async function moveThread(ctx: Ctx, input: { threadId: string; channel: ChatChannel; byAdminId: string }) {
const source = await getThread(ctx, input.threadId);
if (source.channel === input.channel) return source;
if (!source.clientId) throw new DomainError('INVALID_PARAMS');
if (input.channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, source.clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
const peer: Peer = { clientId: source.clientId };
const target = await ensureThread(ctx, peer, input.channel);
await ctx.db.conciergeMessage.updateMany({ where: { threadId: source.id }, data: { threadId: target.id } });
const waiting = [source.waitingSince, target.waitingSince].filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime())[0] ?? null;
const last = source.lastMessageAt > target.lastMessageAt ? source.lastMessageAt : target.lastMessageAt;
await ctx.db.chatThread.delete({ where: { id: source.id } });
const moved = await ctx.db.chatThread.update({
where: { id: target.id },
data: { status: 'OPEN', closedAt: null, waitingSince: waiting, lastMessageAt: last, assigneeId: target.assigneeId, botActive: false },
});
await ctx.db.client.update({ where: { id: source.clientId }, data: { chatChannel: input.channel } });
await systemLine(ctx, moved, input.byAdminId, 'moved', input.channel);
return moved;
}
/** Открыть (или создать пустой) диалог с клиентом — чтобы написать первым из карточки */
export async function openThreadFor(ctx: Ctx, peer: Peer, channel?: ChatChannel) {
const ch = channel ?? (peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT');
return ensureThread(ctx, peer, ch);
}
// ───── списки для команды ─────
export type ThreadFilter = 'mine' | 'unassigned' | 'waiting' | 'open' | 'closed' | 'all';
function filterWhere(filter: ThreadFilter, adminUserId: string): Prisma.ChatThreadWhereInput {
switch (filter) {
case 'mine':
return { assigneeId: adminUserId, status: 'OPEN' };
case 'unassigned':
return { assigneeId: null, status: 'OPEN' };
case 'waiting':
return { status: 'OPEN', waitingSince: { not: null } };
case 'open':
return { status: 'OPEN' };
case 'closed':
return { status: 'CLOSED' };
default:
return {};
}
}
const peerInclude = {
client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } }, subscriptions: { where: { status: 'ACTIVE' as const } } } },
provider: { include: { user: { select: { firstName: true, username: true, locale: true } } } },
assignee: { select: { id: true, name: true } },
} satisfies Prisma.ChatThreadInclude;
export function peerName(t: { client?: { user: { firstName: string | null; lastName: string | null; username: string | null } } | null; provider?: { name: string } | null }) {
if (t.client) {
const u = t.client.user;
return `${u.firstName ?? ''} ${u.lastName ?? ''}`.trim() || (u.username ? `@${u.username}` : '—');
}
return t.provider?.name ?? '—';
}
/** Короткая строка для списка: текст, «📎 файл» или код события */
function preview(m: { author: string; text: string; fileName: string | null; mime: string | null; tgFileId: string | null; fileKey: string | null } | undefined) {
if (!m) return null;
const hasFile = !!(m.tgFileId || m.fileKey);
return { author: m.author, text: m.text, file: hasFile ? (m.fileName ?? (m.mime?.startsWith('image/') ? 'image' : 'file')) : null };
}
export async function listThreads(
ctx: Ctx,
input: { channels: ChatChannel[]; channel?: ChatChannel; filter: ThreadFilter; adminUserId: string; search?: string; limit?: number },
) {
const channels = input.channel ? input.channels.filter((c) => c === input.channel) : input.channels;
const q = input.search?.trim();
const where: Prisma.ChatThreadWhereInput = {
channel: { in: channels },
...filterWhere(input.filter, input.adminUserId),
// пустой диалог (открыли из карточки и ничего не написали) в списке не нужен
messages: { some: {} },
...(q
? {
OR: [
{
client: {
user: {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ username: { contains: q.replace(/^@/, ''), mode: 'insensitive' } },
{ phone: { contains: q } },
],
},
},
},
{ provider: { name: { contains: q, mode: 'insensitive' } } },
{ messages: { some: { text: { contains: q, mode: 'insensitive' }, author: { not: 'SYSTEM' } } } },
],
}
: {}),
};
const rows = await ctx.db.chatThread.findMany({
where,
include: { ...peerInclude, messages: { where: { author: { not: 'SYSTEM' } }, orderBy: { createdAt: 'desc' }, take: 1 } },
// ждущие ответа — сверху, дольше всех ждущий — первым; остальные — по свежести
orderBy: [{ lastMessageAt: 'desc' }],
take: input.limit ?? 100,
});
const unread = rows.length
? await ctx.db.conciergeMessage.groupBy({ by: ['threadId'], where: { threadId: { in: rows.map((r) => r.id) }, author: INCOMING, readAt: null }, _count: true })
: [];
const unreadBy = new Map(unread.map((u) => [u.threadId, u._count]));
const now = ctx.now();
return rows
.map((t) => ({
id: t.id,
channel: t.channel,
status: t.status,
clientId: t.clientId,
providerId: t.providerId,
name: peerName(t),
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
providerRole: t.provider?.role ?? null,
assignee: t.assignee,
botActive: t.botActive,
waitingSince: t.status === 'OPEN' ? t.waitingSince : null,
lastMessageAt: t.lastMessageAt,
last: preview(t.messages[0]),
unread: unreadBy.get(t.id) ?? 0,
}))
.sort((a, b) => {
if (!!a.waitingSince !== !!b.waitingSince) return a.waitingSince ? -1 : 1;
if (a.waitingSince && b.waitingSince) return a.waitingSince.getTime() - b.waitingSince.getTime();
return b.lastMessageAt.getTime() - a.lastMessageAt.getTime();
});
}
/** Счётчики для вкладок и меню: ждут ответа / без ответственного / мои — по каждому каналу */
export async function threadCounts(ctx: Ctx, input: { channels: ChatChannel[]; adminUserId: string }) {
const base = { channel: { in: input.channels }, status: 'OPEN' as const };
const [waiting, unassigned, mine] = await Promise.all([
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, waitingSince: { not: null } }, _count: true }),
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: null, messages: { some: {} } }, _count: true }),
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: input.adminUserId }, _count: true }),
]);
const by = (rows: { channel: ChatChannel; _count: number }[]) => Object.fromEntries(CHAT_CHANNELS.map((c) => [c, rows.find((r) => r.channel === c)?._count ?? 0])) as Record<ChatChannel, number>;
const w = by(waiting);
return { waiting: w, unassigned: by(unassigned), mine: by(mine), total: input.channels.reduce((s, c) => s + w[c], 0) };
}
/** Диалог целиком для команды; открыл — входящие прочитаны */
export async function getThreadForStaff(ctx: Ctx, id: string, opts: { markRead?: boolean } = {}) {
const t = await ctx.db.chatThread.findUnique({ where: { id }, include: peerInclude });
if (!t) throw new DomainError('NOT_FOUND');
if (opts.markRead !== false) await markRead(ctx, id);
const messages = await ctx.db.conciergeMessage.findMany({ where: { threadId: id }, orderBy: { createdAt: 'asc' }, take: 500 });
const authorIds = [...new Set(messages.map((m) => m.adminUserId).filter((x): x is string => !!x))];
const authors = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: authorIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
// второй канал этого же клиента — чтобы показать «есть переписка в поддержке/консьерже»
const other = t.clientId ? await ctx.db.chatThread.findFirst({ where: { clientId: t.clientId, id: { not: t.id }, messages: { some: {} } }, select: { id: true, channel: true, status: true } }) : null;
const now = ctx.now();
return {
id: t.id,
channel: t.channel,
status: t.status,
clientId: t.clientId,
providerId: t.providerId,
name: peerName(t),
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
locale: t.client?.user.locale ?? t.provider?.user?.locale ?? 'ru',
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
providerRole: t.provider?.role ?? null,
providerLinked: t.provider ? !!t.provider.userId : null,
assignee: t.assignee,
botActive: t.botActive,
waitingSince: t.waitingSince,
other,
messages: messages.map((m) => ({
id: m.id,
author: m.author,
authorName: m.adminUserId ? (authors.get(m.adminUserId) ?? null) : null,
text: m.text,
internal: m.internal,
source: m.source,
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime, size: m.size } : null,
param: m.author === 'SYSTEM' ? m.fileName : null,
deliveryError: m.deliveryError,
readAt: m.readAt,
createdAt: m.createdAt,
})),
};
}
// ───── мини-апп клиента ─────
/** Переписка клиента со всеми каналами — без заметок команды и служебных строк */
export async function clientConversation(ctx: Ctx, clientId: string) {
const [messages, channel, subscriber] = await Promise.all([
ctx.db.conciergeMessage.findMany({
where: { clientId, internal: false, author: { in: ['CLIENT', 'OPERATOR', 'BOT'] } },
orderBy: { createdAt: 'asc' },
take: 300,
include: { thread: { select: { channel: true } } },
}),
clientChannel(ctx, clientId),
hasActiveSubscription(ctx, clientId),
]);
return {
channel,
canConcierge: subscriber,
messages: messages.map((m) => ({
id: m.id,
mine: m.author === 'CLIENT',
channel: m.thread?.channel ?? 'SUPPORT',
text: m.text,
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime } : null,
createdAt: m.createdAt,
})),
};
}
// ───── шаблоны ответов ─────
export function listTemplates(ctx: Ctx, channels: ChatChannel[]) {
return ctx.db.chatTemplate.findMany({ where: { OR: [{ channel: null }, { channel: { in: channels } }] }, orderBy: [{ title: 'asc' }] });
}
export async function saveTemplate(ctx: Ctx, input: { id?: string; channel: ChatChannel | null; title: string; text: string; adminUserId: string }) {
const data = { channel: input.channel, title: input.title.trim().slice(0, 80), text: clean(input.text) };
if (!data.title || !data.text) throw new DomainError('INVALID_PARAMS');
if (input.id) return ctx.db.chatTemplate.update({ where: { id: input.id }, data });
return ctx.db.chatTemplate.create({ data: { ...data, createdById: input.adminUserId } });
}
export async function deleteTemplate(ctx: Ctx, id: string) {
await ctx.db.chatTemplate.delete({ where: { id } });
}
/** Подставить имя клиента в шаблон */
export function fillTemplate(text: string, vars: { name: string }) {
return text.replaceAll('{name}', vars.name);
}
/** Кому из команды сообщить о новом сообщении в Telegram */
export async function chatRecipients(ctx: Ctx, thread: { channel: ChatChannel; assigneeId: string | null }) {
const staff = await ctx.db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
if (thread.assigneeId) {
const a = staff.find((s) => s.id === thread.assigneeId);
if (a) return [a];
}
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
// без ролей — сотрудник только старой админки, ему приходят все уведомления, как раньше
return staff.filter((s) => s.roles.length === 0 || s.roles.includes('SUPERADMIN') || s.roles.includes(role));
}
+47 -38
View File
@@ -1,26 +1,27 @@
/**
* Переписка для старой админки и кнопки «Ответить» в рабочем боте.
* Данные — те же диалоги, что в новой админке (chats.ts); заметки команды и служебные строки здесь не видны.
*/
import { DomainError, type Ctx } from './context.ts';
import { clientChannel, receiveMessage, staffSend, type ChatFile, type Peer } from './chats.ts';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId });
export type { Peer } from './chats.ts';
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
/** что видит старая админка: переписка без заметок и служебных строк */
const VISIBLE = { internal: false, author: { in: ['CLIENT', 'PROVIDER', 'OPERATOR', 'BOT'] as ('CLIENT' | 'PROVIDER' | 'OPERATOR' | 'BOT')[] } };
const withFileText = <M extends { text: string; fileName: string | null; tgFileId: string | null; fileKey: string | null }>(m: M) =>
m.text || !(m.tgFileId || m.fileKey) ? m : { ...m, text: `📎 ${m.fileName ?? 'file'}` };
async function incoming(ctx: Ctx, peer: Peer, text: string) {
const body = text.trim().slice(0, 4000);
if (!body) return null;
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: peer.clientId ? 'CLIENT' : 'PROVIDER', text: body } });
await ctx.notify.emit({ type: 'concierge.message', messageId: msg.id });
return msg;
}
/** Сообщение клиента боту — в чат консьержа в админке и уведомление операторам */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string) => incoming(ctx, { clientId }, text);
/** Сообщение исполнителя рабочему боту */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string) => incoming(ctx, { providerId }, text);
/** Сообщение клиента боту — в чат поддержки или консьержа и уведомление команде */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { clientId }, { text, file });
/** Сообщение исполнителя рабочему боту — в поддержку */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { providerId }, { text, file });
/** Список диалогов для оператора: последнее сообщение и сколько непрочитанных, свежие сверху */
export async function listConversations(ctx: Ctx) {
const [last, unread] = await Promise.all([
ctx.db.conciergeMessage.findMany({
where: VISIBLE,
distinct: ['clientId', 'providerId'],
orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }],
include: {
@@ -28,30 +29,34 @@ export async function listConversations(ctx: Ctx) {
provider: { include: { user: { select: { username: true, locale: true } } } },
},
}),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { not: 'OPERATOR' }, readAt: null }, _count: true }),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null }, _count: true }),
]);
const key = (m: { clientId: string | null; providerId: string | null }) => (m.clientId ? `c:${m.clientId}` : `p:${m.providerId}`);
const unreadBy = new Map(unread.map((u) => [key(u), u._count]));
return last
.map((m) => ({
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
}))
.map((raw) => {
const m = withFileText(raw);
return {
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
};
})
.sort((a, b) => b.last.createdAt.getTime() - a.last.createdAt.getTime());
}
/** Переписка; открыл — входящие прочитаны. Пустая — можно написать первым */
export async function conversation(ctx: Ctx, peer: Peer) {
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
return ctx.db.conciergeMessage.findMany({ where: peerWhere(peer), orderBy: { createdAt: 'asc' }, take: 500 });
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null }, data: { readAt: ctx.now() } });
const list = await ctx.db.conciergeMessage.findMany({ where: { ...peerWhere(peer), ...VISIBLE }, orderBy: { createdAt: 'asc' }, take: 500 });
return list.map(withFileText);
}
/** Кто это — для заголовка нового диалога, который оператор начинает первым */
@@ -65,22 +70,26 @@ export async function peerInfo(ctx: Ctx, peer: Peer) {
}
export function unreadConciergeCount(ctx: Ctx) {
return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, readAt: null } });
return ctx.db.conciergeMessage.count({ where: { author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null } });
}
/** Ответ оператора: сохраняем и отправляем в чат с ботом (воркер) */
/**
* Ответ оператора по собеседнику (старая админка, «Ответить» в боте):
* в диалог, где клиент писал последним, иначе — в канал, куда он сейчас пишет.
*/
export async function replyTo(ctx: Ctx, input: Peer & { adminUserId: string; text: string }) {
const peer: Peer = input.clientId ? { clientId: input.clientId } : { providerId: input.providerId! };
if (peer.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: peer.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: 'OPERATOR', adminUserId: input.adminUserId, text: input.text.trim().slice(0, 4000) } });
// ответили — входящие этого диалога прочитаны
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
await ctx.notify.emit({ type: 'concierge.reply', messageId: msg.id });
return msg;
const latest = await ctx.db.chatThread.findFirst({ where: peerWhere(peer), orderBy: { lastMessageAt: 'desc' } });
const thread =
latest ??
(await ctx.db.chatThread.create({
data: { ...peerWhere(peer), channel: peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT', status: 'OPEN', lastMessageAt: ctx.now() },
}));
return staffSend(ctx, { threadId: thread.id, adminUserId: input.adminUserId, text: input.text });
}
/** Совместимость: ответ клиенту */
+5 -1
View File
@@ -23,6 +23,8 @@ export type DomainEvent =
| { type: 'subscription.activated'; subscriptionId: string }
/** уведомления операторам в рабочий бот */
| { type: 'concierge.message'; messageId: string }
/** диалог передали коллеге — ему уведомление в рабочий бот */
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
@@ -77,7 +79,9 @@ export type DomainErrorCode =
| 'PROVIDER_NOT_LINKED'
| 'CARD_EXPIRED'
| 'OFFER_NOT_AVAILABLE'
| 'OFFER_LIMIT_REACHED';
| 'OFFER_LIMIT_REACHED'
| 'CONCIERGE_SUBSCRIBERS_ONLY'
| 'ASSIGNEE_NO_ACCESS';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+1
View File
@@ -14,4 +14,5 @@ export * from './leads.ts';
export * from './relocation.ts';
export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './chats.ts';
export * from './partners.ts';
+43
View File
@@ -0,0 +1,43 @@
/**
* Приватное объектное хранилище (S3-совместимое) для файлов чатов — общее для API и воркера.
* Настройки — из S3_* в .env.
*/
import { randomUUID } from 'node:crypto';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
let client: S3Client | null = null;
function s3() {
client ??= new S3Client({
endpoint: process.env.S3_ENDPOINT ?? 'http://localhost:9000',
region: process.env.S3_REGION ?? 'us-east-1',
forcePathStyle: true,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY ?? '', secretAccessKey: process.env.S3_SECRET_KEY ?? '' },
});
return client;
}
const bucket = () => process.env.S3_BUCKET ?? 'barev-private';
/** Расширение из имени файла (только буквы/цифры), иначе bin */
function extOf(name: string | null | undefined) {
const m = name?.match(/\.([a-z0-9]{1,8})$/i);
return m ? m[1]!.toLowerCase() : 'bin';
}
export async function putFile(prefix: string, body: Buffer, mime: string, fileName?: string | null) {
const key = `${prefix}/${randomUUID()}.${extOf(fileName)}`;
await s3().send(
new PutObjectCommand({
Bucket: bucket(),
Key: key,
Body: body,
ContentType: mime,
...(process.env.NODE_ENV === 'production' ? { ServerSideEncryption: 'AES256' as const } : {}),
}),
);
return key;
}
export async function getFile(key: string): Promise<Buffer> {
const res = await s3().send(new GetObjectCommand({ Bucket: bucket(), Key: key }));
return Buffer.from(await res.Body!.transformToByteArray());
}
+37 -3
View File
@@ -25,11 +25,12 @@ export class TelegramSender {
private label: string,
) {}
async send(msg: OutgoingMessage): Promise<void> {
/** false — получатель недоступен (заблокировал бота / бот ему не писал) */
async send(msg: OutgoingMessage): Promise<boolean> {
if (!this.token) {
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
return;
return true;
}
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: 'POST',
@@ -48,10 +49,43 @@ export class TelegramSender {
// (например, тестовые пользователи seed); повторять бессмысленно
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
return;
return false;
}
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Файл с подписью: картинки — как фото (видно сразу), остальное — документом. false — получатель недоступен */
async sendFile(msg: { chatId: bigint | number | string; caption: string; body: Buffer; fileName: string; mime: string }): Promise<boolean> {
const asPhoto = /^image\/(jpeg|png|webp)$/.test(msg.mime) && msg.body.length <= 10 * 1024 * 1024;
if (!this.token) {
console.log(`[${this.label} → ${msg.chatId}] 📎 ${msg.fileName} (${msg.body.length} B) ${msg.caption.replace(/<[^>]+>/g, '')}`);
return true;
}
const form = new FormData();
form.set('chat_id', String(msg.chatId));
form.set('caption', msg.caption.slice(0, 1024));
form.set('parse_mode', 'HTML');
form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(msg.body)], { type: msg.mime }), msg.fileName);
const res = await fetch(`https://api.telegram.org/bot${this.token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { method: 'POST', body: form });
if (!res.ok) {
const body = await res.text();
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) return false;
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Скачать файл, который прислали боту (до 20 МБ — ограничение Bot API) */
async download(fileId: string): Promise<Response | null> {
if (!this.token) return null;
const info = await fetch(`https://api.telegram.org/bot${this.token}/getFile?file_id=${encodeURIComponent(fileId)}`);
if (!info.ok) return null;
const json = (await info.json()) as { ok: boolean; result?: { file_path?: string } };
if (!json.result?.file_path) return null;
const file = await fetch(`https://api.telegram.org/file/bot${this.token}/${json.result.file_path}`);
return file.ok ? file : null;
}
}
+189
View File
@@ -0,0 +1,189 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import {
addClientMessage,
assignThread,
chatRecipients,
clientConversation,
closeThread,
conversation,
DomainError,
getThreadForStaff,
giveConsent,
grantSubscription,
listThreads,
moveThread,
openThreadFor,
receiveMessage,
replyTo,
setClientChannel,
staffSend,
startClient,
threadCounts,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, tgUser } from './helpers.ts';
beforeEach(async () => {
await fixtures();
});
afterAll(() => db.$disconnect());
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, subscriber = false) {
const s = await startClient(ctx, tgUser(id));
await giveConsent(ctx, s.clientId);
if (subscriber) await grantSubscription(ctx, { clientId: s.clientId, plan: 'BAREV_PLUS', days: 30 });
return s.clientId;
}
const staff = (name: string, roles: ('SUPERADMIN' | 'SUPPORT' | 'CONCIERGE')[], telegramId?: number) =>
db.adminUser.create({ data: { name, roles, telegramId: telegramId ? BigInt(telegramId) : undefined } });
describe('чаты: поддержка и консьерж', () => {
it('подписчик пишет консьержу, остальные — в поддержку; бот и мини-апп — одна переписка', async () => {
const { ctx, events } = makeCtx(NOW);
const plain = await client(ctx, 701);
const member = await client(ctx, 702, true);
const a = await addClientMessage(ctx, plain, 'Где мой заказ?');
expect(a).toMatchObject({ channel: 'SUPPORT', opened: true });
const b = await addClientMessage(ctx, member, 'Нужен нотариус');
expect(b).toMatchObject({ channel: 'CONCIERGE', opened: true });
// второе сообщение из мини-аппа — в тот же диалог, уже открытый
const c = await receiveMessage(ctx, { clientId: member }, { text: 'И переводчик', source: 'MINIAPP' });
expect(c).toMatchObject({ opened: false });
expect(c!.thread.id).toBe(b!.thread.id);
expect(c!.message.source).toBe('MINIAPP');
expect(events.filter((e) => e.type === 'concierge.message')).toHaveLength(3);
// пустое сообщение без файла не сохраняется
expect(await receiveMessage(ctx, { clientId: plain }, { text: ' ' })).toBeNull();
});
it('клиент сам выбирает канал; консьерж — только с подпиской', async () => {
const { ctx } = makeCtx(NOW);
const plain = await client(ctx, 711);
const member = await client(ctx, 712, true);
expect(await code(setClientChannel(ctx, plain, 'CONCIERGE'))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
await setClientChannel(ctx, member, 'SUPPORT');
expect((await addClientMessage(ctx, member, 'Вопрос по оплате'))!.channel).toBe('SUPPORT');
await setClientChannel(ctx, member, 'CONCIERGE');
expect((await addClientMessage(ctx, member, 'Столик на субботу'))!.channel).toBe('CONCIERGE');
});
it('ответ: уходит клиенту, снимает «ждёт ответа», диалог за ответившим; заметка клиенту не уходит', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await client(ctx, 721);
const op = await staff('Гор', ['SUPPORT']);
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
expect(thread.waitingSince).toEqual(NOW);
const note = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'VIP', internal: true });
expect(events).not.toContainEqual({ type: 'concierge.reply', messageId: note.id });
expect((await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } })).waitingSince).toEqual(NOW);
const reply = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'Здравствуйте!' });
expect(events).toContainEqual({ type: 'concierge.reply', messageId: reply.id });
const t = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
expect(t).toMatchObject({ waitingSince: null, assigneeId: op.id });
expect(await code(staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: ' ' }))).toBe('INVALID_PARAMS');
// клиент видит ответ, но не заметку; старая админка — тоже без заметок
const mine = await clientConversation(ctx, clientId);
expect(mine.messages.map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
expect((await conversation(ctx, { clientId })).map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
});
it('взять, передать коллеге с доступом, закрыть; закрытый открывается, когда клиент пишет снова', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await client(ctx, 731, true);
const anna = await staff('Анна', ['CONCIERGE']);
const gor = await staff('Гор', ['SUPPORT']);
const boss = await staff('Босс', ['SUPERADMIN']);
const { thread } = (await addClientMessage(ctx, clientId, 'Нужен столик'))!;
expect(thread.channel).toBe('CONCIERGE');
await assignThread(ctx, { threadId: thread.id, assigneeId: boss.id, byAdminId: boss.id });
// у поддержки нет доступа к консьержу
expect(await code(assignThread(ctx, { threadId: thread.id, assigneeId: gor.id, byAdminId: boss.id }))).toBe('ASSIGNEE_NO_ACCESS');
await assignThread(ctx, { threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
expect(events).toContainEqual({ type: 'chat.assigned', threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
await closeThread(ctx, { threadId: thread.id, byAdminId: anna.id });
const closed = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
expect(closed).toMatchObject({ status: 'CLOSED', waitingSince: null });
const again = await addClientMessage(ctx, clientId, 'Ещё вопрос');
expect(again).toMatchObject({ opened: true });
expect(again!.thread).toMatchObject({ id: thread.id, status: 'OPEN', assigneeId: anna.id });
// служебные строки видны команде, но не клиенту
const full = await getThreadForStaff(ctx, thread.id);
expect(full.messages.filter((m) => m.author === 'SYSTEM').map((m) => m.text)).toEqual(['took', 'transferred', 'closed']);
expect((await clientConversation(ctx, clientId)).messages).toHaveLength(2);
});
it('перенос в другой канал: переписка переезжает, клиент дальше пишет туда; в консьерж — только подписчика', async () => {
const { ctx } = makeCtx(NOW);
const member = await client(ctx, 741, true);
const plain = await client(ctx, 742);
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
const t1 = (await addClientMessage(ctx, member, 'Вопрос про оплату подписки'))!.thread;
const moved = await moveThread(ctx, { threadId: t1.id, channel: 'SUPPORT', byAdminId: op.id });
expect(moved.channel).toBe('SUPPORT');
expect(await db.chatThread.findUnique({ where: { id: t1.id } })).toBeNull();
expect(await db.conciergeMessage.count({ where: { threadId: moved.id, author: 'CLIENT' } })).toBe(1);
expect((await addClientMessage(ctx, member, 'Спасибо'))!.thread.id).toBe(moved.id);
const t2 = (await addClientMessage(ctx, plain, 'Хочу консьержа'))!.thread;
expect(await code(moveThread(ctx, { threadId: t2.id, channel: 'CONCIERGE', byAdminId: op.id }))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
});
it('списки и счётчики: только свои каналы, ждущие ответа — сверху; пустой диалог из карточки не виден', async () => {
const { ctx, clock } = makeCtx(NOW);
const op = await staff('Гор', ['SUPPORT']);
const a = await client(ctx, 751);
const b = await client(ctx, 752);
const m = await client(ctx, 753, true);
await addClientMessage(ctx, a, 'первый');
clock.now = new Date(NOW.getTime() + 60_000);
const tb = (await addClientMessage(ctx, b, 'второй'))!.thread;
await staffSend(ctx, { threadId: tb.id, adminUserId: op.id, text: 'ответили' });
await addClientMessage(ctx, m, 'консьержу');
await openThreadFor(ctx, { clientId: a }, 'CONCIERGE');
const list = await listThreads(ctx, { channels: ['SUPPORT'], filter: 'open', adminUserId: op.id });
expect(list.map((x) => x.clientId)).toEqual([a, b]);
expect(list[0]).toMatchObject({ unread: 1, waitingSince: NOW });
expect(await listThreads(ctx, { channels: ['SUPPORT'], filter: 'mine', adminUserId: op.id })).toHaveLength(1);
expect(await listThreads(ctx, { channels: ['SUPPORT', 'CONCIERGE'], filter: 'open', adminUserId: op.id, search: 'консьерж' })).toHaveLength(1);
const counts = await threadCounts(ctx, { channels: ['SUPPORT', 'CONCIERGE'], adminUserId: op.id });
expect(counts).toMatchObject({ waiting: { SUPPORT: 1, CONCIERGE: 1 }, mine: { SUPPORT: 1, CONCIERGE: 0 }, unassigned: { SUPPORT: 1, CONCIERGE: 1 }, total: 2 });
});
it('уведомления: ответственному, иначе — сотрудникам канала', async () => {
const { ctx } = makeCtx(NOW);
const anna = await staff('Анна', ['CONCIERGE'], 9001);
const gor = await staff('Гор', ['SUPPORT'], 9002);
const boss = await staff('Босс', ['SUPERADMIN'], 9003);
const ids = async (t: { channel: 'SUPPORT' | 'CONCIERGE'; assigneeId: string | null }) => (await chatRecipients(ctx, t)).map((s) => s.name).sort();
expect(await ids({ channel: 'CONCIERGE', assigneeId: null })).toEqual(['Анна', 'Босс']);
expect(await ids({ channel: 'SUPPORT', assigneeId: null })).toEqual(['Босс', 'Гор']);
expect(await ids({ channel: 'SUPPORT', assigneeId: gor.id })).toEqual(['Гор']);
void anna;
void boss;
});
it('ответ из старой админки / из Telegram попадает в последний диалог клиента', async () => {
const { ctx } = makeCtx(NOW);
const clientId = await client(ctx, 761, true);
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
const msg = await replyTo(ctx, { clientId, adminUserId: op.id, text: 'Добрый день' });
expect(msg.threadId).toBe(thread.id);
// клиент без переписки — диалог создаётся в его канале
const fresh = await client(ctx, 762);
const first = await replyTo(ctx, { clientId: fresh, adminUserId: op.id, text: 'Здравствуйте, это поддержка' });
expect((await db.chatThread.findUniqueOrThrow({ where: { id: first.threadId! } })).channel).toBe('SUPPORT');
});
});
+4 -4
View File
@@ -554,7 +554,7 @@ describe('приложение: переезд, Elite, заявки операт
const clientId = await onboardedClient(ctx, 504);
await addClientMessage(ctx, clientId, 'Как продлить визу?');
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
await conversation(ctx, clientId);
await conversation(ctx, { clientId });
expect((await listConversations(ctx))[0]!.unread).toBe(0);
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
@@ -582,12 +582,12 @@ describe('шаблоны переезда и переписка с исполн
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
const { ctx, events } = makeCtx(NOW);
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
const msg = await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь');
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg!.id });
const msg = (await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь'))!.message;
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg.id });
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg!.id } })).readAt).not.toBeNull();
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg.id } })).readAt).not.toBeNull();
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
});
+5 -3
View File
@@ -8,11 +8,13 @@ export default async function setup() {
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
const base = new URL(process.env.DATABASE_URL!);
const testUrl = new URL(base);
testUrl.pathname = '/barev_test';
// TEST_DB_NAME — своя тестовая БД (например, для второй рабочей копии)
const name = process.env.TEST_DB_NAME ?? 'barev_test';
testUrl.pathname = `/${name}`;
const admin = new pg.Client({ connectionString: base.toString() });
await admin.connect();
const exists = await admin.query(`SELECT 1 FROM pg_database WHERE datname = 'barev_test'`);
if (!exists.rowCount) await admin.query('CREATE DATABASE barev_test');
const exists = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [name]);
if (!exists.rowCount) await admin.query(`CREATE DATABASE "${name}"`);
await admin.end();
execSync('npx prisma migrate deploy', {
cwd: path.resolve(import.meta.dirname, '../../db'),
+1 -1
View File
@@ -8,7 +8,7 @@ config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true }
function testUrl() {
const u = new URL(process.env.DATABASE_URL!);
u.pathname = '/barev_test';
u.pathname = `/${process.env.TEST_DB_NAME ?? 'barev_test'}`;
return u.toString();
}
+3
View File
@@ -570,6 +570,9 @@ importers:
packages/services:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.1152.0
version: 3.1152.0
'@barev/core':
specifier: workspace:*
version: link:../core