Backoffice stage 2: chats (support + concierge, files)
- ChatThread per client/provider and channel; messages get files, internal notes, source (bot/mini app) and delivery errors; reply templates. Migration moves old concierge messages into threads. - Routing: subscribers write to the concierge, others to support; /support and /concierge in the client bot; staff can take, transfer, close and move threads. - Telegram: photos/documents from clients and providers, replies with the channel header, files sent from storage, notifications only to the assignee or the channel's staff, "Reply" into a specific thread. - New admin: /chats with list, conversation, notes, files, templates, client panel; "Message" button on the client card; dashboard counts threads awaiting reply. - Client mini app: in-app chat screen sharing the same conversation. - Old admin keeps working on the same data (notes hidden there). - Tests for routing, notes, assign/close/move, lists, recipients; separate test DB via TEST_DB_NAME; dev:chats-demo script. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4156
-124
No files matched your search
@@ -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
|
||||
|
||||
# ---- Админка ----
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
@@ -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)),
|
||||
}),
|
||||
});
|
||||
@@ -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,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,
|
||||
});
|
||||
@@ -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
@@ -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) || {};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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();
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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') },
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
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! };
|
||||
const name =
|
||||
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 });
|
||||
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));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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': {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,23 +7,25 @@
|
||||
".": "./src/index.ts",
|
||||
"./queue": "./src/queue.ts",
|
||||
"./env": "./src/env.ts",
|
||||
"./telegram": "./src/telegram.ts"
|
||||
"./telegram": "./src/telegram.ts",
|
||||
"./storage": "./src/storage.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1152.0",
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/db": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"bullmq": "^5.81.5",
|
||||
"dotenv": "^17.0.0",
|
||||
"@barev/i18n": "workspace:*"
|
||||
"dotenv": "^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.0",
|
||||
"dotenv": "^17.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
"pg": "^8.16.0",
|
||||
"@types/pg": "^8.15.0"
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Чаты (этап 2 новой админки): два канала — поддержка (все клиенты и исполнители) и консьерж (только подписчики).
|
||||
* Клиент пишет боту или в мини-аппе — сообщения попадают в один диалог; ответ команды приходит в чат с ботом.
|
||||
* У собеседника по одному диалогу в каждом канале: закрытый диалог открывается заново, когда клиент пишет снова.
|
||||
*/
|
||||
import { activeSubscription } from '@barev/core';
|
||||
import type { Prisma } from '@barev/db';
|
||||
import { DomainError, type Ctx } from './context.ts';
|
||||
|
||||
export type ChatChannel = 'SUPPORT' | 'CONCIERGE';
|
||||
export const CHAT_CHANNELS: ChatChannel[] = ['SUPPORT', 'CONCIERGE'];
|
||||
export type ChatSource = 'BOT' | 'MINIAPP' | 'ADMIN';
|
||||
|
||||
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
|
||||
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
|
||||
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
|
||||
|
||||
/** Файл в сообщении: из Telegram (file_id) или из нашего хранилища (ключ) */
|
||||
export interface ChatFile {
|
||||
tgFileId?: string | null;
|
||||
tgBot?: 'client' | 'pro' | null;
|
||||
fileKey?: string | null;
|
||||
fileName?: string | null;
|
||||
mime?: string | null;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
const MAX_TEXT = 4000;
|
||||
const clean = (s: string | null | undefined) => (s ?? '').trim().slice(0, MAX_TEXT);
|
||||
const INCOMING = { in: ['CLIENT', 'PROVIDER'] as ('CLIENT' | 'PROVIDER')[] };
|
||||
|
||||
async function hasActiveSubscription(ctx: Ctx, clientId: string) {
|
||||
const subs = await ctx.db.subscription.findMany({ where: { clientId, status: 'ACTIVE' } });
|
||||
return !!activeSubscription(subs, ctx.now());
|
||||
}
|
||||
|
||||
/** Куда сейчас пишет клиент: выбранный канал, иначе подписчик → консьерж, остальные → поддержка */
|
||||
export async function clientChannel(ctx: Ctx, clientId: string): Promise<ChatChannel> {
|
||||
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, select: { chatChannel: true } });
|
||||
const subscriber = await hasActiveSubscription(ctx, clientId);
|
||||
if (client.chatChannel === 'SUPPORT') return 'SUPPORT';
|
||||
return subscriber ? 'CONCIERGE' : 'SUPPORT';
|
||||
}
|
||||
|
||||
/** Клиент сам выбирает канал (/support, /concierge в боте). Консьерж — только с подпиской */
|
||||
export async function setClientChannel(ctx: Ctx, clientId: string, channel: ChatChannel) {
|
||||
if (channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
|
||||
await ctx.db.client.update({ where: { id: clientId }, data: { chatChannel: channel } });
|
||||
return channel;
|
||||
}
|
||||
|
||||
function threadKey(peer: Peer, channel: ChatChannel): Prisma.ChatThreadWhereUniqueInput {
|
||||
return peer.clientId ? { clientId_channel: { clientId: peer.clientId, channel } } : { providerId_channel: { providerId: peer.providerId!, channel } };
|
||||
}
|
||||
|
||||
async function ensureThread(ctx: Ctx, peer: Peer, channel: ChatChannel) {
|
||||
return ctx.db.chatThread.upsert({
|
||||
where: threadKey(peer, channel),
|
||||
create: { ...peerWhere(peer), channel, status: 'CLOSED', lastMessageAt: ctx.now() },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Входящее сообщение клиента или исполнителя (бот / мини-апп).
|
||||
* opened = диалог был закрыт или новый — бот подтверждает «передали, ответим здесь».
|
||||
*/
|
||||
export async function receiveMessage(ctx: Ctx, peer: Peer, input: { text?: string | null; file?: ChatFile | null; source?: ChatSource }) {
|
||||
const text = clean(input.text);
|
||||
if (!text && !input.file) return null;
|
||||
const channel: ChatChannel = peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT';
|
||||
const before = await ensureThread(ctx, peer, channel);
|
||||
const now = ctx.now();
|
||||
const message = await ctx.db.conciergeMessage.create({
|
||||
data: {
|
||||
...peerWhere(peer),
|
||||
threadId: before.id,
|
||||
author: peer.clientId ? 'CLIENT' : 'PROVIDER',
|
||||
text,
|
||||
source: input.source ?? 'BOT',
|
||||
...fileData(input.file),
|
||||
},
|
||||
});
|
||||
const thread = await ctx.db.chatThread.update({
|
||||
where: { id: before.id },
|
||||
data: { status: 'OPEN', closedAt: null, lastMessageAt: now, waitingSince: before.waitingSince ?? now },
|
||||
});
|
||||
await ctx.notify.emit({ type: 'concierge.message', messageId: message.id });
|
||||
return { message, thread, channel, opened: before.status === 'CLOSED' };
|
||||
}
|
||||
|
||||
function fileData(f: ChatFile | null | undefined) {
|
||||
if (!f) return {};
|
||||
return { tgFileId: f.tgFileId ?? null, tgBot: f.tgBot ?? null, fileKey: f.fileKey ?? null, fileName: f.fileName?.slice(0, 200) ?? null, mime: f.mime ?? null, size: f.size ?? null };
|
||||
}
|
||||
|
||||
async function staffName(ctx: Ctx, id: string) {
|
||||
return (await ctx.db.adminUser.findUnique({ where: { id }, select: { name: true } }))?.name ?? '';
|
||||
}
|
||||
|
||||
/** Служебная строка в ленте: кто взял, передал, закрыл. В text — код события, в fileName — параметр */
|
||||
async function systemLine(ctx: Ctx, thread: { id: string; clientId: string | null; providerId: string | null }, adminUserId: string, code: string, param?: string) {
|
||||
await ctx.db.conciergeMessage.create({
|
||||
data: { threadId: thread.id, clientId: thread.clientId, providerId: thread.providerId, author: 'SYSTEM', adminUserId, text: code, fileName: param ?? null, internal: true, source: 'ADMIN', readAt: ctx.now() },
|
||||
});
|
||||
}
|
||||
|
||||
async function getThread(ctx: Ctx, id: string) {
|
||||
const t = await ctx.db.chatThread.findUnique({ where: { id } });
|
||||
if (!t) throw new DomainError('NOT_FOUND');
|
||||
return t;
|
||||
}
|
||||
|
||||
const markRead = (ctx: Ctx, threadId: string) =>
|
||||
ctx.db.conciergeMessage.updateMany({ where: { threadId, author: INCOMING, readAt: null }, data: { readAt: ctx.now() } });
|
||||
|
||||
/**
|
||||
* Сообщение команды: ответ клиенту (уходит в Telegram через воркер) или внутренняя заметка.
|
||||
* Ответил первым в неназначенном диалоге — диалог за тобой.
|
||||
*/
|
||||
export async function staffSend(ctx: Ctx, input: { threadId: string; adminUserId: string; text?: string | null; internal?: boolean; file?: ChatFile | null }) {
|
||||
const thread = await getThread(ctx, input.threadId);
|
||||
const text = clean(input.text);
|
||||
if (!text && !input.file) throw new DomainError('INVALID_PARAMS');
|
||||
if (!input.internal && thread.providerId) {
|
||||
const p = await ctx.db.provider.findUnique({ where: { id: thread.providerId } });
|
||||
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
|
||||
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
|
||||
}
|
||||
const message = await ctx.db.conciergeMessage.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
clientId: thread.clientId,
|
||||
providerId: thread.providerId,
|
||||
author: 'OPERATOR',
|
||||
adminUserId: input.adminUserId,
|
||||
text,
|
||||
internal: !!input.internal,
|
||||
source: 'ADMIN',
|
||||
...fileData(input.file),
|
||||
},
|
||||
});
|
||||
if (!input.internal) {
|
||||
await ctx.db.chatThread.update({
|
||||
where: { id: thread.id },
|
||||
data: { waitingSince: null, lastMessageAt: ctx.now(), status: 'OPEN', closedAt: null, assigneeId: thread.assigneeId ?? input.adminUserId, botActive: false },
|
||||
});
|
||||
await markRead(ctx, thread.id);
|
||||
await ctx.notify.emit({ type: 'concierge.reply', messageId: message.id });
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Взять диалог себе / передать коллеге / снять ответственного (assigneeId = null) */
|
||||
export async function assignThread(ctx: Ctx, input: { threadId: string; assigneeId: string | null; byAdminId: string }) {
|
||||
const thread = await getThread(ctx, input.threadId);
|
||||
if (thread.assigneeId === input.assigneeId) return thread;
|
||||
if (input.assigneeId) {
|
||||
const a = await ctx.db.adminUser.findUnique({ where: { id: input.assigneeId } });
|
||||
if (!a?.active) throw new DomainError('NOT_FOUND');
|
||||
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
|
||||
if (!a.roles.includes(role) && !a.roles.includes('SUPERADMIN')) throw new DomainError('ASSIGNEE_NO_ACCESS');
|
||||
}
|
||||
const updated = await ctx.db.chatThread.update({ where: { id: thread.id }, data: { assigneeId: input.assigneeId, botActive: false } });
|
||||
if (!input.assigneeId) await systemLine(ctx, thread, input.byAdminId, 'unassigned');
|
||||
else if (input.assigneeId === input.byAdminId) await systemLine(ctx, thread, input.byAdminId, 'took');
|
||||
else {
|
||||
await systemLine(ctx, thread, input.byAdminId, 'transferred', await staffName(ctx, input.assigneeId));
|
||||
await ctx.notify.emit({ type: 'chat.assigned', threadId: thread.id, assigneeId: input.assigneeId, byAdminId: input.byAdminId });
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Закрыть: вопрос решён. Клиент напишет снова — диалог откроется сам */
|
||||
export async function closeThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
|
||||
const thread = await getThread(ctx, input.threadId);
|
||||
if (thread.status === 'CLOSED') return thread;
|
||||
await markRead(ctx, thread.id);
|
||||
await systemLine(ctx, thread, input.byAdminId, 'closed');
|
||||
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'CLOSED', closedAt: ctx.now(), waitingSince: null, botActive: false } });
|
||||
}
|
||||
|
||||
export async function reopenThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
|
||||
const thread = await getThread(ctx, input.threadId);
|
||||
if (thread.status === 'OPEN') return thread;
|
||||
await systemLine(ctx, thread, input.byAdminId, 'reopened');
|
||||
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'OPEN', closedAt: null } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Перенести диалог клиента в другой канал (например, вопрос подписчика — к поддержке).
|
||||
* Переписка переезжает целиком, дальше клиент пишет в новый канал.
|
||||
*/
|
||||
export async function moveThread(ctx: Ctx, input: { threadId: string; channel: ChatChannel; byAdminId: string }) {
|
||||
const source = await getThread(ctx, input.threadId);
|
||||
if (source.channel === input.channel) return source;
|
||||
if (!source.clientId) throw new DomainError('INVALID_PARAMS');
|
||||
if (input.channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, source.clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
|
||||
const peer: Peer = { clientId: source.clientId };
|
||||
const target = await ensureThread(ctx, peer, input.channel);
|
||||
await ctx.db.conciergeMessage.updateMany({ where: { threadId: source.id }, data: { threadId: target.id } });
|
||||
const waiting = [source.waitingSince, target.waitingSince].filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime())[0] ?? null;
|
||||
const last = source.lastMessageAt > target.lastMessageAt ? source.lastMessageAt : target.lastMessageAt;
|
||||
await ctx.db.chatThread.delete({ where: { id: source.id } });
|
||||
const moved = await ctx.db.chatThread.update({
|
||||
where: { id: target.id },
|
||||
data: { status: 'OPEN', closedAt: null, waitingSince: waiting, lastMessageAt: last, assigneeId: target.assigneeId, botActive: false },
|
||||
});
|
||||
await ctx.db.client.update({ where: { id: source.clientId }, data: { chatChannel: input.channel } });
|
||||
await systemLine(ctx, moved, input.byAdminId, 'moved', input.channel);
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Открыть (или создать пустой) диалог с клиентом — чтобы написать первым из карточки */
|
||||
export async function openThreadFor(ctx: Ctx, peer: Peer, channel?: ChatChannel) {
|
||||
const ch = channel ?? (peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT');
|
||||
return ensureThread(ctx, peer, ch);
|
||||
}
|
||||
|
||||
// ───── списки для команды ─────
|
||||
|
||||
export type ThreadFilter = 'mine' | 'unassigned' | 'waiting' | 'open' | 'closed' | 'all';
|
||||
|
||||
function filterWhere(filter: ThreadFilter, adminUserId: string): Prisma.ChatThreadWhereInput {
|
||||
switch (filter) {
|
||||
case 'mine':
|
||||
return { assigneeId: adminUserId, status: 'OPEN' };
|
||||
case 'unassigned':
|
||||
return { assigneeId: null, status: 'OPEN' };
|
||||
case 'waiting':
|
||||
return { status: 'OPEN', waitingSince: { not: null } };
|
||||
case 'open':
|
||||
return { status: 'OPEN' };
|
||||
case 'closed':
|
||||
return { status: 'CLOSED' };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const peerInclude = {
|
||||
client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } }, subscriptions: { where: { status: 'ACTIVE' as const } } } },
|
||||
provider: { include: { user: { select: { firstName: true, username: true, locale: true } } } },
|
||||
assignee: { select: { id: true, name: true } },
|
||||
} satisfies Prisma.ChatThreadInclude;
|
||||
|
||||
export function peerName(t: { client?: { user: { firstName: string | null; lastName: string | null; username: string | null } } | null; provider?: { name: string } | null }) {
|
||||
if (t.client) {
|
||||
const u = t.client.user;
|
||||
return `${u.firstName ?? ''} ${u.lastName ?? ''}`.trim() || (u.username ? `@${u.username}` : '—');
|
||||
}
|
||||
return t.provider?.name ?? '—';
|
||||
}
|
||||
|
||||
/** Короткая строка для списка: текст, «📎 файл» или код события */
|
||||
function preview(m: { author: string; text: string; fileName: string | null; mime: string | null; tgFileId: string | null; fileKey: string | null } | undefined) {
|
||||
if (!m) return null;
|
||||
const hasFile = !!(m.tgFileId || m.fileKey);
|
||||
return { author: m.author, text: m.text, file: hasFile ? (m.fileName ?? (m.mime?.startsWith('image/') ? 'image' : 'file')) : null };
|
||||
}
|
||||
|
||||
export async function listThreads(
|
||||
ctx: Ctx,
|
||||
input: { channels: ChatChannel[]; channel?: ChatChannel; filter: ThreadFilter; adminUserId: string; search?: string; limit?: number },
|
||||
) {
|
||||
const channels = input.channel ? input.channels.filter((c) => c === input.channel) : input.channels;
|
||||
const q = input.search?.trim();
|
||||
const where: Prisma.ChatThreadWhereInput = {
|
||||
channel: { in: channels },
|
||||
...filterWhere(input.filter, input.adminUserId),
|
||||
// пустой диалог (открыли из карточки и ничего не написали) в списке не нужен
|
||||
messages: { some: {} },
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
client: {
|
||||
user: {
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
{ username: { contains: q.replace(/^@/, ''), mode: 'insensitive' } },
|
||||
{ phone: { contains: q } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provider: { name: { contains: q, mode: 'insensitive' } } },
|
||||
{ messages: { some: { text: { contains: q, mode: 'insensitive' }, author: { not: 'SYSTEM' } } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const rows = await ctx.db.chatThread.findMany({
|
||||
where,
|
||||
include: { ...peerInclude, messages: { where: { author: { not: 'SYSTEM' } }, orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
// ждущие ответа — сверху, дольше всех ждущий — первым; остальные — по свежести
|
||||
orderBy: [{ lastMessageAt: 'desc' }],
|
||||
take: input.limit ?? 100,
|
||||
});
|
||||
const unread = rows.length
|
||||
? await ctx.db.conciergeMessage.groupBy({ by: ['threadId'], where: { threadId: { in: rows.map((r) => r.id) }, author: INCOMING, readAt: null }, _count: true })
|
||||
: [];
|
||||
const unreadBy = new Map(unread.map((u) => [u.threadId, u._count]));
|
||||
const now = ctx.now();
|
||||
return rows
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
channel: t.channel,
|
||||
status: t.status,
|
||||
clientId: t.clientId,
|
||||
providerId: t.providerId,
|
||||
name: peerName(t),
|
||||
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
|
||||
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
|
||||
providerRole: t.provider?.role ?? null,
|
||||
assignee: t.assignee,
|
||||
botActive: t.botActive,
|
||||
waitingSince: t.status === 'OPEN' ? t.waitingSince : null,
|
||||
lastMessageAt: t.lastMessageAt,
|
||||
last: preview(t.messages[0]),
|
||||
unread: unreadBy.get(t.id) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (!!a.waitingSince !== !!b.waitingSince) return a.waitingSince ? -1 : 1;
|
||||
if (a.waitingSince && b.waitingSince) return a.waitingSince.getTime() - b.waitingSince.getTime();
|
||||
return b.lastMessageAt.getTime() - a.lastMessageAt.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/** Счётчики для вкладок и меню: ждут ответа / без ответственного / мои — по каждому каналу */
|
||||
export async function threadCounts(ctx: Ctx, input: { channels: ChatChannel[]; adminUserId: string }) {
|
||||
const base = { channel: { in: input.channels }, status: 'OPEN' as const };
|
||||
const [waiting, unassigned, mine] = await Promise.all([
|
||||
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, waitingSince: { not: null } }, _count: true }),
|
||||
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: null, messages: { some: {} } }, _count: true }),
|
||||
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: input.adminUserId }, _count: true }),
|
||||
]);
|
||||
const by = (rows: { channel: ChatChannel; _count: number }[]) => Object.fromEntries(CHAT_CHANNELS.map((c) => [c, rows.find((r) => r.channel === c)?._count ?? 0])) as Record<ChatChannel, number>;
|
||||
const w = by(waiting);
|
||||
return { waiting: w, unassigned: by(unassigned), mine: by(mine), total: input.channels.reduce((s, c) => s + w[c], 0) };
|
||||
}
|
||||
|
||||
/** Диалог целиком для команды; открыл — входящие прочитаны */
|
||||
export async function getThreadForStaff(ctx: Ctx, id: string, opts: { markRead?: boolean } = {}) {
|
||||
const t = await ctx.db.chatThread.findUnique({ where: { id }, include: peerInclude });
|
||||
if (!t) throw new DomainError('NOT_FOUND');
|
||||
if (opts.markRead !== false) await markRead(ctx, id);
|
||||
const messages = await ctx.db.conciergeMessage.findMany({ where: { threadId: id }, orderBy: { createdAt: 'asc' }, take: 500 });
|
||||
const authorIds = [...new Set(messages.map((m) => m.adminUserId).filter((x): x is string => !!x))];
|
||||
const authors = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: authorIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
|
||||
// второй канал этого же клиента — чтобы показать «есть переписка в поддержке/консьерже»
|
||||
const other = t.clientId ? await ctx.db.chatThread.findFirst({ where: { clientId: t.clientId, id: { not: t.id }, messages: { some: {} } }, select: { id: true, channel: true, status: true } }) : null;
|
||||
const now = ctx.now();
|
||||
return {
|
||||
id: t.id,
|
||||
channel: t.channel,
|
||||
status: t.status,
|
||||
clientId: t.clientId,
|
||||
providerId: t.providerId,
|
||||
name: peerName(t),
|
||||
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
|
||||
locale: t.client?.user.locale ?? t.provider?.user?.locale ?? 'ru',
|
||||
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
|
||||
providerRole: t.provider?.role ?? null,
|
||||
providerLinked: t.provider ? !!t.provider.userId : null,
|
||||
assignee: t.assignee,
|
||||
botActive: t.botActive,
|
||||
waitingSince: t.waitingSince,
|
||||
other,
|
||||
messages: messages.map((m) => ({
|
||||
id: m.id,
|
||||
author: m.author,
|
||||
authorName: m.adminUserId ? (authors.get(m.adminUserId) ?? null) : null,
|
||||
text: m.text,
|
||||
internal: m.internal,
|
||||
source: m.source,
|
||||
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime, size: m.size } : null,
|
||||
param: m.author === 'SYSTEM' ? m.fileName : null,
|
||||
deliveryError: m.deliveryError,
|
||||
readAt: m.readAt,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ───── мини-апп клиента ─────
|
||||
|
||||
/** Переписка клиента со всеми каналами — без заметок команды и служебных строк */
|
||||
export async function clientConversation(ctx: Ctx, clientId: string) {
|
||||
const [messages, channel, subscriber] = await Promise.all([
|
||||
ctx.db.conciergeMessage.findMany({
|
||||
where: { clientId, internal: false, author: { in: ['CLIENT', 'OPERATOR', 'BOT'] } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 300,
|
||||
include: { thread: { select: { channel: true } } },
|
||||
}),
|
||||
clientChannel(ctx, clientId),
|
||||
hasActiveSubscription(ctx, clientId),
|
||||
]);
|
||||
return {
|
||||
channel,
|
||||
canConcierge: subscriber,
|
||||
messages: messages.map((m) => ({
|
||||
id: m.id,
|
||||
mine: m.author === 'CLIENT',
|
||||
channel: m.thread?.channel ?? 'SUPPORT',
|
||||
text: m.text,
|
||||
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime } : null,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ───── шаблоны ответов ─────
|
||||
|
||||
export function listTemplates(ctx: Ctx, channels: ChatChannel[]) {
|
||||
return ctx.db.chatTemplate.findMany({ where: { OR: [{ channel: null }, { channel: { in: channels } }] }, orderBy: [{ title: 'asc' }] });
|
||||
}
|
||||
|
||||
export async function saveTemplate(ctx: Ctx, input: { id?: string; channel: ChatChannel | null; title: string; text: string; adminUserId: string }) {
|
||||
const data = { channel: input.channel, title: input.title.trim().slice(0, 80), text: clean(input.text) };
|
||||
if (!data.title || !data.text) throw new DomainError('INVALID_PARAMS');
|
||||
if (input.id) return ctx.db.chatTemplate.update({ where: { id: input.id }, data });
|
||||
return ctx.db.chatTemplate.create({ data: { ...data, createdById: input.adminUserId } });
|
||||
}
|
||||
|
||||
export async function deleteTemplate(ctx: Ctx, id: string) {
|
||||
await ctx.db.chatTemplate.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/** Подставить имя клиента в шаблон */
|
||||
export function fillTemplate(text: string, vars: { name: string }) {
|
||||
return text.replaceAll('{name}', vars.name);
|
||||
}
|
||||
|
||||
/** Кому из команды сообщить о новом сообщении в Telegram */
|
||||
export async function chatRecipients(ctx: Ctx, thread: { channel: ChatChannel; assigneeId: string | null }) {
|
||||
const staff = await ctx.db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
|
||||
if (thread.assigneeId) {
|
||||
const a = staff.find((s) => s.id === thread.assigneeId);
|
||||
if (a) return [a];
|
||||
}
|
||||
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
|
||||
// без ролей — сотрудник только старой админки, ему приходят все уведомления, как раньше
|
||||
return staff.filter((s) => s.roles.length === 0 || s.roles.includes('SUPERADMIN') || s.roles.includes(role));
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
/**
|
||||
* Переписка для старой админки и кнопки «Ответить» в рабочем боте.
|
||||
* Данные — те же диалоги, что в новой админке (chats.ts); заметки команды и служебные строки здесь не видны.
|
||||
*/
|
||||
import { DomainError, type Ctx } from './context.ts';
|
||||
import { clientChannel, receiveMessage, staffSend, type ChatFile, type Peer } from './chats.ts';
|
||||
|
||||
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
|
||||
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
|
||||
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId });
|
||||
export type { Peer } from './chats.ts';
|
||||
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
|
||||
/** что видит старая админка: переписка без заметок и служебных строк */
|
||||
const VISIBLE = { internal: false, author: { in: ['CLIENT', 'PROVIDER', 'OPERATOR', 'BOT'] as ('CLIENT' | 'PROVIDER' | 'OPERATOR' | 'BOT')[] } };
|
||||
const withFileText = <M extends { text: string; fileName: string | null; tgFileId: string | null; fileKey: string | null }>(m: M) =>
|
||||
m.text || !(m.tgFileId || m.fileKey) ? m : { ...m, text: `📎 ${m.fileName ?? 'file'}` };
|
||||
|
||||
async function incoming(ctx: Ctx, peer: Peer, text: string) {
|
||||
const body = text.trim().slice(0, 4000);
|
||||
if (!body) return null;
|
||||
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: peer.clientId ? 'CLIENT' : 'PROVIDER', text: body } });
|
||||
await ctx.notify.emit({ type: 'concierge.message', messageId: msg.id });
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Сообщение клиента боту — в чат консьержа в админке и уведомление операторам */
|
||||
export const addClientMessage = (ctx: Ctx, clientId: string, text: string) => incoming(ctx, { clientId }, text);
|
||||
/** Сообщение исполнителя рабочему боту */
|
||||
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string) => incoming(ctx, { providerId }, text);
|
||||
/** Сообщение клиента боту — в чат поддержки или консьержа и уведомление команде */
|
||||
export const addClientMessage = (ctx: Ctx, clientId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { clientId }, { text, file });
|
||||
/** Сообщение исполнителя рабочему боту — в поддержку */
|
||||
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { providerId }, { text, file });
|
||||
|
||||
/** Список диалогов для оператора: последнее сообщение и сколько непрочитанных, свежие сверху */
|
||||
export async function listConversations(ctx: Ctx) {
|
||||
const [last, unread] = await Promise.all([
|
||||
ctx.db.conciergeMessage.findMany({
|
||||
where: VISIBLE,
|
||||
distinct: ['clientId', 'providerId'],
|
||||
orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
@@ -28,12 +29,14 @@ 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) => ({
|
||||
.map((raw) => {
|
||||
const m = withFileText(raw);
|
||||
return {
|
||||
key: key(m),
|
||||
clientId: m.clientId,
|
||||
providerId: m.providerId,
|
||||
@@ -44,14 +47,16 @@ export async function listConversations(ctx: Ctx) {
|
||||
providerRole: m.provider?.role ?? null,
|
||||
last: { text: m.text, author: m.author, createdAt: m.createdAt },
|
||||
unread: unreadBy.get(key(m)) ?? 0,
|
||||
}))
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.last.createdAt.getTime() - a.last.createdAt.getTime());
|
||||
}
|
||||
|
||||
/** Переписка; открыл — входящие прочитаны. Пустая — можно написать первым */
|
||||
export async function conversation(ctx: Ctx, peer: Peer) {
|
||||
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
|
||||
return ctx.db.conciergeMessage.findMany({ where: peerWhere(peer), orderBy: { createdAt: 'asc' }, take: 500 });
|
||||
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null }, data: { readAt: ctx.now() } });
|
||||
const list = await ctx.db.conciergeMessage.findMany({ where: { ...peerWhere(peer), ...VISIBLE }, orderBy: { createdAt: 'asc' }, take: 500 });
|
||||
return list.map(withFileText);
|
||||
}
|
||||
|
||||
/** Кто это — для заголовка нового диалога, который оператор начинает первым */
|
||||
@@ -65,22 +70,26 @@ export async function peerInfo(ctx: Ctx, peer: Peer) {
|
||||
}
|
||||
|
||||
export function unreadConciergeCount(ctx: Ctx) {
|
||||
return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, readAt: null } });
|
||||
return ctx.db.conciergeMessage.count({ where: { author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null } });
|
||||
}
|
||||
|
||||
/** Ответ оператора: сохраняем и отправляем в чат с ботом (воркер) */
|
||||
/**
|
||||
* Ответ оператора по собеседнику (старая админка, «Ответить» в боте):
|
||||
* в диалог, где клиент писал последним, иначе — в канал, куда он сейчас пишет.
|
||||
*/
|
||||
export async function replyTo(ctx: Ctx, input: Peer & { adminUserId: string; text: string }) {
|
||||
const peer: Peer = input.clientId ? { clientId: input.clientId } : { providerId: input.providerId! };
|
||||
if (peer.providerId) {
|
||||
const p = await ctx.db.provider.findUnique({ where: { id: peer.providerId } });
|
||||
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
|
||||
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
|
||||
}
|
||||
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: 'OPERATOR', adminUserId: input.adminUserId, text: input.text.trim().slice(0, 4000) } });
|
||||
// ответили — входящие этого диалога прочитаны
|
||||
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
|
||||
await ctx.notify.emit({ type: 'concierge.reply', messageId: msg.id });
|
||||
return msg;
|
||||
const latest = await ctx.db.chatThread.findFirst({ where: peerWhere(peer), orderBy: { lastMessageAt: 'desc' } });
|
||||
const thread =
|
||||
latest ??
|
||||
(await ctx.db.chatThread.create({
|
||||
data: { ...peerWhere(peer), channel: peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT', status: 'OPEN', lastMessageAt: ctx.now() },
|
||||
}));
|
||||
return staffSend(ctx, { threadId: thread.id, adminUserId: input.adminUserId, text: input.text });
|
||||
}
|
||||
|
||||
/** Совместимость: ответ клиенту */
|
||||
|
||||
@@ -23,6 +23,8 @@ export type DomainEvent =
|
||||
| { type: 'subscription.activated'; subscriptionId: string }
|
||||
/** уведомления операторам в рабочий бот */
|
||||
| { type: 'concierge.message'; messageId: string }
|
||||
/** диалог передали коллеге — ему уведомление в рабочий бот */
|
||||
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
|
||||
| { type: 'lead.submitted'; leadId: string }
|
||||
| { type: 'clientRequest.created'; requestId: string }
|
||||
| { type: 'webRequest.created'; requestId: string };
|
||||
@@ -77,7 +79,9 @@ export type DomainErrorCode =
|
||||
| 'PROVIDER_NOT_LINKED'
|
||||
| 'CARD_EXPIRED'
|
||||
| 'OFFER_NOT_AVAILABLE'
|
||||
| 'OFFER_LIMIT_REACHED';
|
||||
| 'OFFER_LIMIT_REACHED'
|
||||
| 'CONCIERGE_SUBSCRIBERS_ONLY'
|
||||
| 'ASSIGNEE_NO_ACCESS';
|
||||
|
||||
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
|
||||
export const EVENTS_QUEUE = 'barev-events';
|
||||
@@ -14,4 +14,5 @@ export * from './leads.ts';
|
||||
export * from './relocation.ts';
|
||||
export * from './clientRequests.ts';
|
||||
export * from './concierge.ts';
|
||||
export * from './chats.ts';
|
||||
export * from './partners.ts';
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Приватное объектное хранилище (S3-совместимое) для файлов чатов — общее для API и воркера.
|
||||
* Настройки — из S3_* в .env.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
let client: S3Client | null = null;
|
||||
function s3() {
|
||||
client ??= new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT ?? 'http://localhost:9000',
|
||||
region: process.env.S3_REGION ?? 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: process.env.S3_ACCESS_KEY ?? '', secretAccessKey: process.env.S3_SECRET_KEY ?? '' },
|
||||
});
|
||||
return client;
|
||||
}
|
||||
const bucket = () => process.env.S3_BUCKET ?? 'barev-private';
|
||||
|
||||
/** Расширение из имени файла (только буквы/цифры), иначе bin */
|
||||
function extOf(name: string | null | undefined) {
|
||||
const m = name?.match(/\.([a-z0-9]{1,8})$/i);
|
||||
return m ? m[1]!.toLowerCase() : 'bin';
|
||||
}
|
||||
|
||||
export async function putFile(prefix: string, body: Buffer, mime: string, fileName?: string | null) {
|
||||
const key = `${prefix}/${randomUUID()}.${extOf(fileName)}`;
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket(),
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: mime,
|
||||
...(process.env.NODE_ENV === 'production' ? { ServerSideEncryption: 'AES256' as const } : {}),
|
||||
}),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function getFile(key: string): Promise<Buffer> {
|
||||
const res = await s3().send(new GetObjectCommand({ Bucket: bucket(), Key: key }));
|
||||
return Buffer.from(await res.Body!.transformToByteArray());
|
||||
}
|
||||
@@ -25,11 +25,12 @@ export class TelegramSender {
|
||||
private label: string,
|
||||
) {}
|
||||
|
||||
async send(msg: OutgoingMessage): Promise<void> {
|
||||
/** false — получатель недоступен (заблокировал бота / бот ему не писал) */
|
||||
async send(msg: OutgoingMessage): Promise<boolean> {
|
||||
if (!this.token) {
|
||||
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
|
||||
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
@@ -48,10 +49,43 @@ export class TelegramSender {
|
||||
// (например, тестовые пользователи seed); повторять бессмысленно
|
||||
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
|
||||
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Telegram ${res.status}: ${body}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Файл с подписью: картинки — как фото (видно сразу), остальное — документом. false — получатель недоступен */
|
||||
async sendFile(msg: { chatId: bigint | number | string; caption: string; body: Buffer; fileName: string; mime: string }): Promise<boolean> {
|
||||
const asPhoto = /^image\/(jpeg|png|webp)$/.test(msg.mime) && msg.body.length <= 10 * 1024 * 1024;
|
||||
if (!this.token) {
|
||||
console.log(`[${this.label} → ${msg.chatId}] 📎 ${msg.fileName} (${msg.body.length} B) ${msg.caption.replace(/<[^>]+>/g, '')}`);
|
||||
return true;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set('chat_id', String(msg.chatId));
|
||||
form.set('caption', msg.caption.slice(0, 1024));
|
||||
form.set('parse_mode', 'HTML');
|
||||
form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(msg.body)], { type: msg.mime }), msg.fileName);
|
||||
const res = await fetch(`https://api.telegram.org/bot${this.token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) return false;
|
||||
throw new Error(`Telegram ${res.status}: ${body}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Скачать файл, который прислали боту (до 20 МБ — ограничение Bot API) */
|
||||
async download(fileId: string): Promise<Response | null> {
|
||||
if (!this.token) return null;
|
||||
const info = await fetch(`https://api.telegram.org/bot${this.token}/getFile?file_id=${encodeURIComponent(fileId)}`);
|
||||
if (!info.ok) return null;
|
||||
const json = (await info.json()) as { ok: boolean; result?: { file_path?: string } };
|
||||
if (!json.result?.file_path) return null;
|
||||
const file = await fetch(`https://api.telegram.org/file/bot${this.token}/${json.result.file_path}`);
|
||||
return file.ok ? file : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addClientMessage,
|
||||
assignThread,
|
||||
chatRecipients,
|
||||
clientConversation,
|
||||
closeThread,
|
||||
conversation,
|
||||
DomainError,
|
||||
getThreadForStaff,
|
||||
giveConsent,
|
||||
grantSubscription,
|
||||
listThreads,
|
||||
moveThread,
|
||||
openThreadFor,
|
||||
receiveMessage,
|
||||
replyTo,
|
||||
setClientChannel,
|
||||
staffSend,
|
||||
startClient,
|
||||
threadCounts,
|
||||
} from '../src/index.ts';
|
||||
import { db, fixtures, makeCtx, NOW, tgUser } from './helpers.ts';
|
||||
|
||||
beforeEach(async () => {
|
||||
await fixtures();
|
||||
});
|
||||
afterAll(() => db.$disconnect());
|
||||
|
||||
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
|
||||
|
||||
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], id: number, subscriber = false) {
|
||||
const s = await startClient(ctx, tgUser(id));
|
||||
await giveConsent(ctx, s.clientId);
|
||||
if (subscriber) await grantSubscription(ctx, { clientId: s.clientId, plan: 'BAREV_PLUS', days: 30 });
|
||||
return s.clientId;
|
||||
}
|
||||
|
||||
const staff = (name: string, roles: ('SUPERADMIN' | 'SUPPORT' | 'CONCIERGE')[], telegramId?: number) =>
|
||||
db.adminUser.create({ data: { name, roles, telegramId: telegramId ? BigInt(telegramId) : undefined } });
|
||||
|
||||
describe('чаты: поддержка и консьерж', () => {
|
||||
it('подписчик пишет консьержу, остальные — в поддержку; бот и мини-апп — одна переписка', async () => {
|
||||
const { ctx, events } = makeCtx(NOW);
|
||||
const plain = await client(ctx, 701);
|
||||
const member = await client(ctx, 702, true);
|
||||
|
||||
const a = await addClientMessage(ctx, plain, 'Где мой заказ?');
|
||||
expect(a).toMatchObject({ channel: 'SUPPORT', opened: true });
|
||||
const b = await addClientMessage(ctx, member, 'Нужен нотариус');
|
||||
expect(b).toMatchObject({ channel: 'CONCIERGE', opened: true });
|
||||
// второе сообщение из мини-аппа — в тот же диалог, уже открытый
|
||||
const c = await receiveMessage(ctx, { clientId: member }, { text: 'И переводчик', source: 'MINIAPP' });
|
||||
expect(c).toMatchObject({ opened: false });
|
||||
expect(c!.thread.id).toBe(b!.thread.id);
|
||||
expect(c!.message.source).toBe('MINIAPP');
|
||||
expect(events.filter((e) => e.type === 'concierge.message')).toHaveLength(3);
|
||||
// пустое сообщение без файла не сохраняется
|
||||
expect(await receiveMessage(ctx, { clientId: plain }, { text: ' ' })).toBeNull();
|
||||
});
|
||||
|
||||
it('клиент сам выбирает канал; консьерж — только с подпиской', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const plain = await client(ctx, 711);
|
||||
const member = await client(ctx, 712, true);
|
||||
expect(await code(setClientChannel(ctx, plain, 'CONCIERGE'))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
|
||||
await setClientChannel(ctx, member, 'SUPPORT');
|
||||
expect((await addClientMessage(ctx, member, 'Вопрос по оплате'))!.channel).toBe('SUPPORT');
|
||||
await setClientChannel(ctx, member, 'CONCIERGE');
|
||||
expect((await addClientMessage(ctx, member, 'Столик на субботу'))!.channel).toBe('CONCIERGE');
|
||||
});
|
||||
|
||||
it('ответ: уходит клиенту, снимает «ждёт ответа», диалог за ответившим; заметка клиенту не уходит', async () => {
|
||||
const { ctx, events } = makeCtx(NOW);
|
||||
const clientId = await client(ctx, 721);
|
||||
const op = await staff('Гор', ['SUPPORT']);
|
||||
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
|
||||
expect(thread.waitingSince).toEqual(NOW);
|
||||
|
||||
const note = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'VIP', internal: true });
|
||||
expect(events).not.toContainEqual({ type: 'concierge.reply', messageId: note.id });
|
||||
expect((await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } })).waitingSince).toEqual(NOW);
|
||||
|
||||
const reply = await staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: 'Здравствуйте!' });
|
||||
expect(events).toContainEqual({ type: 'concierge.reply', messageId: reply.id });
|
||||
const t = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
|
||||
expect(t).toMatchObject({ waitingSince: null, assigneeId: op.id });
|
||||
expect(await code(staffSend(ctx, { threadId: thread.id, adminUserId: op.id, text: ' ' }))).toBe('INVALID_PARAMS');
|
||||
|
||||
// клиент видит ответ, но не заметку; старая админка — тоже без заметок
|
||||
const mine = await clientConversation(ctx, clientId);
|
||||
expect(mine.messages.map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
|
||||
expect((await conversation(ctx, { clientId })).map((m) => m.text)).toEqual(['Привет', 'Здравствуйте!']);
|
||||
});
|
||||
|
||||
it('взять, передать коллеге с доступом, закрыть; закрытый открывается, когда клиент пишет снова', async () => {
|
||||
const { ctx, events } = makeCtx(NOW);
|
||||
const clientId = await client(ctx, 731, true);
|
||||
const anna = await staff('Анна', ['CONCIERGE']);
|
||||
const gor = await staff('Гор', ['SUPPORT']);
|
||||
const boss = await staff('Босс', ['SUPERADMIN']);
|
||||
const { thread } = (await addClientMessage(ctx, clientId, 'Нужен столик'))!;
|
||||
expect(thread.channel).toBe('CONCIERGE');
|
||||
|
||||
await assignThread(ctx, { threadId: thread.id, assigneeId: boss.id, byAdminId: boss.id });
|
||||
// у поддержки нет доступа к консьержу
|
||||
expect(await code(assignThread(ctx, { threadId: thread.id, assigneeId: gor.id, byAdminId: boss.id }))).toBe('ASSIGNEE_NO_ACCESS');
|
||||
await assignThread(ctx, { threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
|
||||
expect(events).toContainEqual({ type: 'chat.assigned', threadId: thread.id, assigneeId: anna.id, byAdminId: boss.id });
|
||||
|
||||
await closeThread(ctx, { threadId: thread.id, byAdminId: anna.id });
|
||||
const closed = await db.chatThread.findUniqueOrThrow({ where: { id: thread.id } });
|
||||
expect(closed).toMatchObject({ status: 'CLOSED', waitingSince: null });
|
||||
|
||||
const again = await addClientMessage(ctx, clientId, 'Ещё вопрос');
|
||||
expect(again).toMatchObject({ opened: true });
|
||||
expect(again!.thread).toMatchObject({ id: thread.id, status: 'OPEN', assigneeId: anna.id });
|
||||
|
||||
// служебные строки видны команде, но не клиенту
|
||||
const full = await getThreadForStaff(ctx, thread.id);
|
||||
expect(full.messages.filter((m) => m.author === 'SYSTEM').map((m) => m.text)).toEqual(['took', 'transferred', 'closed']);
|
||||
expect((await clientConversation(ctx, clientId)).messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('перенос в другой канал: переписка переезжает, клиент дальше пишет туда; в консьерж — только подписчика', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const member = await client(ctx, 741, true);
|
||||
const plain = await client(ctx, 742);
|
||||
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
|
||||
const t1 = (await addClientMessage(ctx, member, 'Вопрос про оплату подписки'))!.thread;
|
||||
const moved = await moveThread(ctx, { threadId: t1.id, channel: 'SUPPORT', byAdminId: op.id });
|
||||
expect(moved.channel).toBe('SUPPORT');
|
||||
expect(await db.chatThread.findUnique({ where: { id: t1.id } })).toBeNull();
|
||||
expect(await db.conciergeMessage.count({ where: { threadId: moved.id, author: 'CLIENT' } })).toBe(1);
|
||||
expect((await addClientMessage(ctx, member, 'Спасибо'))!.thread.id).toBe(moved.id);
|
||||
|
||||
const t2 = (await addClientMessage(ctx, plain, 'Хочу консьержа'))!.thread;
|
||||
expect(await code(moveThread(ctx, { threadId: t2.id, channel: 'CONCIERGE', byAdminId: op.id }))).toBe('CONCIERGE_SUBSCRIBERS_ONLY');
|
||||
});
|
||||
|
||||
it('списки и счётчики: только свои каналы, ждущие ответа — сверху; пустой диалог из карточки не виден', async () => {
|
||||
const { ctx, clock } = makeCtx(NOW);
|
||||
const op = await staff('Гор', ['SUPPORT']);
|
||||
const a = await client(ctx, 751);
|
||||
const b = await client(ctx, 752);
|
||||
const m = await client(ctx, 753, true);
|
||||
await addClientMessage(ctx, a, 'первый');
|
||||
clock.now = new Date(NOW.getTime() + 60_000);
|
||||
const tb = (await addClientMessage(ctx, b, 'второй'))!.thread;
|
||||
await staffSend(ctx, { threadId: tb.id, adminUserId: op.id, text: 'ответили' });
|
||||
await addClientMessage(ctx, m, 'консьержу');
|
||||
await openThreadFor(ctx, { clientId: a }, 'CONCIERGE');
|
||||
|
||||
const list = await listThreads(ctx, { channels: ['SUPPORT'], filter: 'open', adminUserId: op.id });
|
||||
expect(list.map((x) => x.clientId)).toEqual([a, b]);
|
||||
expect(list[0]).toMatchObject({ unread: 1, waitingSince: NOW });
|
||||
expect(await listThreads(ctx, { channels: ['SUPPORT'], filter: 'mine', adminUserId: op.id })).toHaveLength(1);
|
||||
expect(await listThreads(ctx, { channels: ['SUPPORT', 'CONCIERGE'], filter: 'open', adminUserId: op.id, search: 'консьерж' })).toHaveLength(1);
|
||||
|
||||
const counts = await threadCounts(ctx, { channels: ['SUPPORT', 'CONCIERGE'], adminUserId: op.id });
|
||||
expect(counts).toMatchObject({ waiting: { SUPPORT: 1, CONCIERGE: 1 }, mine: { SUPPORT: 1, CONCIERGE: 0 }, unassigned: { SUPPORT: 1, CONCIERGE: 1 }, total: 2 });
|
||||
});
|
||||
|
||||
it('уведомления: ответственному, иначе — сотрудникам канала', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const anna = await staff('Анна', ['CONCIERGE'], 9001);
|
||||
const gor = await staff('Гор', ['SUPPORT'], 9002);
|
||||
const boss = await staff('Босс', ['SUPERADMIN'], 9003);
|
||||
const ids = async (t: { channel: 'SUPPORT' | 'CONCIERGE'; assigneeId: string | null }) => (await chatRecipients(ctx, t)).map((s) => s.name).sort();
|
||||
expect(await ids({ channel: 'CONCIERGE', assigneeId: null })).toEqual(['Анна', 'Босс']);
|
||||
expect(await ids({ channel: 'SUPPORT', assigneeId: null })).toEqual(['Босс', 'Гор']);
|
||||
expect(await ids({ channel: 'SUPPORT', assigneeId: gor.id })).toEqual(['Гор']);
|
||||
void anna;
|
||||
void boss;
|
||||
});
|
||||
|
||||
it('ответ из старой админки / из Telegram попадает в последний диалог клиента', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const clientId = await client(ctx, 761, true);
|
||||
const op = await staff('Оператор', ['SUPPORT', 'CONCIERGE']);
|
||||
const { thread } = (await addClientMessage(ctx, clientId, 'Привет'))!;
|
||||
const msg = await replyTo(ctx, { clientId, adminUserId: op.id, text: 'Добрый день' });
|
||||
expect(msg.threadId).toBe(thread.id);
|
||||
// клиент без переписки — диалог создаётся в его канале
|
||||
const fresh = await client(ctx, 762);
|
||||
const first = await replyTo(ctx, { clientId: fresh, adminUserId: op.id, text: 'Здравствуйте, это поддержка' });
|
||||
expect((await db.chatThread.findUniqueOrThrow({ where: { id: first.threadId! } })).channel).toBe('SUPPORT');
|
||||
});
|
||||
});
|
||||
@@ -554,7 +554,7 @@ describe('приложение: переезд, Elite, заявки операт
|
||||
const clientId = await onboardedClient(ctx, 504);
|
||||
await addClientMessage(ctx, clientId, 'Как продлить визу?');
|
||||
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
|
||||
await conversation(ctx, clientId);
|
||||
await conversation(ctx, { clientId });
|
||||
expect((await listConversations(ctx))[0]!.unread).toBe(0);
|
||||
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
|
||||
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
|
||||
@@ -582,12 +582,12 @@ describe('шаблоны переезда и переписка с исполн
|
||||
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
|
||||
const { ctx, events } = makeCtx(NOW);
|
||||
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
|
||||
const msg = await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь');
|
||||
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg!.id });
|
||||
const msg = (await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь'))!.message;
|
||||
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg.id });
|
||||
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
|
||||
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
|
||||
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
|
||||
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg!.id } })).readAt).not.toBeNull();
|
||||
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg.id } })).readAt).not.toBeNull();
|
||||
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
|
||||
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
|
||||
});
|
||||
|
||||
@@ -8,11 +8,13 @@ export default async function setup() {
|
||||
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
|
||||
const base = new URL(process.env.DATABASE_URL!);
|
||||
const testUrl = new URL(base);
|
||||
testUrl.pathname = '/barev_test';
|
||||
// TEST_DB_NAME — своя тестовая БД (например, для второй рабочей копии)
|
||||
const name = process.env.TEST_DB_NAME ?? 'barev_test';
|
||||
testUrl.pathname = `/${name}`;
|
||||
const admin = new pg.Client({ connectionString: base.toString() });
|
||||
await admin.connect();
|
||||
const exists = await admin.query(`SELECT 1 FROM pg_database WHERE datname = 'barev_test'`);
|
||||
if (!exists.rowCount) await admin.query('CREATE DATABASE barev_test');
|
||||
const exists = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [name]);
|
||||
if (!exists.rowCount) await admin.query(`CREATE DATABASE "${name}"`);
|
||||
await admin.end();
|
||||
execSync('npx prisma migrate deploy', {
|
||||
cwd: path.resolve(import.meta.dirname, '../../db'),
|
||||
|
||||
@@ -8,7 +8,7 @@ config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true }
|
||||
|
||||
function testUrl() {
|
||||
const u = new URL(process.env.DATABASE_URL!);
|
||||
u.pathname = '/barev_test';
|
||||
u.pathname = `/${process.env.TEST_DB_NAME ?? 'barev_test'}`;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user