diff --git a/.env.example b/.env.example
index 6f0401e..aae09fd 100644
--- a/.env.example
+++ b/.env.example
@@ -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
# ---- Админка ----
diff --git a/apps/api/package.json b/apps/api/package.json
index 6dffab9..39e13de 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -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",
diff --git a/apps/api/scripts/dev-backoffice-demo.ts b/apps/api/scripts/dev-backoffice-demo.ts
index a9e1d72..c109def 100644
--- a/apps/api/scripts/dev-backoffice-demo.ts
+++ b/apps/api/scripts/dev-backoffice-demo.ts
@@ -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'],
diff --git a/apps/api/scripts/dev-chats-demo.ts b/apps/api/scripts/dev-chats-demo.ts
new file mode 100644
index 0000000..9e1065d
--- /dev/null
+++ b/apps/api/scripts/dev-chats-demo.ts
@@ -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<>endobj 2 0 obj<>endobj 3 0 obj<>>>>>endobj 4 0 obj<>stream\nBT /F1 18 Tf 30 70 Td (Barev Club - checklist) Tj ET\nendstream endobj 5 0 obj<>endobj\ntrailer<>\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);
diff --git a/apps/api/src/routers/backoffice/chats.ts b/apps/api/src/routers/backoffice/chats.ts
new file mode 100644
index 0000000..ebcb3a4
--- /dev/null
+++ b/apps/api/src/routers/backoffice/chats.ts
@@ -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)),
+ }),
+});
diff --git a/apps/api/src/routers/backoffice/dashboard.ts b/apps/api/src/routers/backoffice/dashboard.ts
index 80af42d..1a6e174 100644
--- a/apps/api/src/routers/backoffice/dashboard.ts
+++ b/apps/api/src/routers/backoffice/dashboard.ts
@@ -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' } }),
diff --git a/apps/api/src/routers/backoffice/index.ts b/apps/api/src/routers/backoffice/index.ts
index e83d11c..86dc37f 100644
--- a/apps/api/src/routers/backoffice/index.ts
+++ b/apps/api/src/routers/backoffice/index.ts
@@ -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,
});
diff --git a/apps/api/src/routers/client.ts b/apps/api/src/routers/client.ts
index c8ede54..fa6113b 100644
--- a/apps/api/src/routers/client.ts
+++ b/apps/api/src/routers/client.ts
@@ -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)),
+ }),
});
diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index 5875cea..d9f8805 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -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) || {};
diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx
index cc846e6..14e1eda 100644
--- a/apps/backoffice/src/App.tsx
+++ b/apps/backoffice/src/App.tsx
@@ -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 = ;
+ else if ((path === '/chats' || threadId) && canChat) page = ;
else if (path === '/clients' && current) page = ;
else if (clientId && items.some((n) => n.path === '/clients')) page = ;
else if (path === '/team' && isSuper) page = ;
@@ -82,7 +86,7 @@ function Shell({ me }: { me: Me }) {
const tabItems = items.slice(0, 4);
return (
-
+
- {d.username && (
-
-
- {t('card.telegram')}
-
- )}
+
@@ -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 (
+
+ );
+}
+
function TagsAndNote({ id, tags, note }: { id: string; tags: string[]; note: string }) {
const { t } = useT();
const trpc = useTRPC();
diff --git a/apps/backoffice/vite.config.ts b/apps/backoffice/vite.config.ts
index 397f5e1..780ffa1 100644
--- a/apps/backoffice/vite.config.ts
+++ b/apps/backoffice/vite.config.ts
@@ -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 },
});
diff --git a/apps/bot-client/src/index.ts b/apps/bot-client/src/index.ts
index 0eb403d..2b78893 100644
--- a/apps/bot-client/src/index.ts
+++ b/apps/bot-client/src/index.ts
@@ -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') },
],
diff --git a/apps/bot-pro/src/index.ts b/apps/bot-pro/src/index.ts
index c970fd2..cc69ead 100644
--- a/apps/bot-pro/src/index.ts
+++ b/apps/bot-pro/src/index.ts
@@ -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
();
+const replyingTo = new Map();
const LEVEL_NAME: Record = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
type Check = Awaited>;
@@ -198,19 +212,31 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
});
/** «Ответить» под уведомлением о сообщении */
-bot.callbackQuery(/^ar:(c|p):(\w+)$/, async (c) => {
+bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
const admin = await adminOf(c);
if (!admin) {
await c.answerCallbackQuery({ text: adminT('bot.admin.notAdmin'), show_alert: true });
return;
}
const [, kind, id] = c.match;
- const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
- const name =
- kind === 'c'
- ? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
- : ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
- replyingTo.set(c.chat!.id, { ...peer, name });
+ let name = '';
+ if (kind === 't') {
+ // ответ в конкретный диалог (поддержка или консьерж)
+ const thread = await db.chatThread.findUnique({ where: { id: id! }, include: { client: { include: { user: true } }, provider: true } });
+ if (!thread) {
+ await c.answerCallbackQuery({ text: adminT('errors.NOT_FOUND'), show_alert: true });
+ return;
+ }
+ name = peerName(thread);
+ replyingTo.set(c.chat!.id, { threadId: thread.id, name });
+ } else {
+ const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
+ name =
+ kind === 'c'
+ ? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
+ : ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
+ replyingTo.set(c.chat!.id, { peer, name });
+ }
await c.answerCallbackQuery();
await c.reply(adminT('bot.admin.replyPrompt', { name }), { reply_markup: { force_reply: true, input_field_placeholder: adminT('bot.admin.replyPlaceholder') } });
});
@@ -226,7 +252,8 @@ bot.on('message:text', async (c) => {
const admin = await adminOf(c);
if (target && admin) {
try {
- await replyTo(ctx, { ...(target.clientId ? { clientId: target.clientId } : { providerId: target.providerId! }), adminUserId: admin.id, text: c.message.text });
+ if (target.threadId) await staffSend(ctx, { threadId: target.threadId, adminUserId: admin.id, text: c.message.text });
+ else await replyTo(ctx, { ...target.peer!, adminUserId: admin.id, text: c.message.text });
replyingTo.delete(c.chat.id);
await c.reply(adminT('bot.admin.replySent', { name: target.name }));
} catch (e) {
@@ -238,9 +265,8 @@ bot.on('message:text', async (c) => {
// исполнитель пишет оператору — сообщение появится в админке
const id = await identity(c);
if (id?.provider) {
- const first = !(await db.conciergeMessage.count({ where: { providerId: id.provider.id } }));
- await addProviderMessage(ctx, id.provider.id, c.message.text);
- if (first) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
+ const res = await addProviderMessage(ctx, id.provider.id, c.message.text);
+ if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
return;
}
if (admin) {
@@ -250,6 +276,19 @@ bot.on('message:text', async (c) => {
await greet(c);
});
+/** Исполнитель прислал фото или файл — тоже в поддержку */
+bot.on(['message:photo', 'message:document'], async (c, next) => {
+ const id = await identity(c);
+ if (!id?.provider) return next();
+ const photo = c.message.photo?.at(-1);
+ const doc = c.message.document;
+ const file = photo
+ ? { tgFileId: photo.file_id, tgBot: 'pro' as const, fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null }
+ : { tgFileId: doc!.file_id, tgBot: 'pro' as const, fileName: doc!.file_name ?? 'file', mime: doc!.mime_type ?? 'application/octet-stream', size: doc!.file_size ?? null };
+ const res = await addProviderMessage(ctx, id.provider.id, c.message.caption ?? '', file);
+ if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
+});
+
bot.on('message', greet);
bot.catch((err) => console.error('[bot-pro]', err.error));
diff --git a/apps/miniapp-client/src/App.tsx b/apps/miniapp-client/src/App.tsx
index b385a47..3aeca77 100644
--- a/apps/miniapp-client/src/App.tsx
+++ b/apps/miniapp-client/src/App.tsx
@@ -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 ? (
) : (
-
+
)}
);
@@ -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 = ;
else if (path === '/profile') screen = ;
else if (path === '/membership') screen = ;
+ else if (path === '/chat') screen = ;
else screen = segment === 'MOVING' ? : ;
const withNav = ROOTS.includes(path) || path === '/orders';
return (
<>
{screen}
- {withNav && (segment === 'MOVING' ? : )}
+ {withNav && (segment === 'MOVING' ? : )}
>
);
}
/** Меню переезда: План / Документы / Консьерж (чат с ботом) / Услуги / Профиль */
-function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string }) {
+function MoveNav({ path }: { path: string }) {
const { t } = useI18n();
const item = (href: string, icon: Parameters[0]['name'], label: string) => (
@@ -86,9 +88,9 @@ function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string })
{item('/services', 'grid', t('client.nav.services'))}
{item('/profile', 'user', t('client.nav.profile'))}
diff --git a/apps/miniapp-client/src/app.css b/apps/miniapp-client/src/app.css
index 08c6715..38c4452 100644
--- a/apps/miniapp-client/src/app.css
+++ b/apps/miniapp-client/src/app.css
@@ -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;
+}
diff --git a/apps/miniapp-client/src/screens/Chat.tsx b/apps/miniapp-client/src/screens/Chat.tsx
new file mode 100644
index 0000000..f8dddae
--- /dev/null
+++ b/apps/miniapp-client/src/screens/Chat.tsx
@@ -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(null);
+ const fileInput = useRef(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 (
+
+
+
+ );
+ const d = chat.data;
+ let prevDay = '';
+
+ return (
+
+
+
+ {d.canConcierge ? (
+
+ {(['CONCIERGE', 'SUPPORT'] as const).map((c) => (
+
+ ))}
+
+ ) : (
+
+ {t('client.chat.conciergeOnly')} →
+
+ )}
+
+
+
+
{t('client.chat.hint')}
+ {d.messages.length === 0 &&
{t('client.chat.empty')}
}
+ {d.messages.map((m) => {
+ const day = dt(m.createdAt, { day: 'numeric', month: 'long' });
+ const sep = day !== prevDay ?
{day}
: null;
+ prevDay = day;
+ return (
+
+ {sep}
+
+ {!m.mine &&
{t(m.channel === 'CONCIERGE' ? 'client.chat.concierge' : 'client.chat.support')}
}
+ {m.file &&
}
+ {m.text &&
{m.text}
}
+
{dt(m.createdAt, { hour: '2-digit', minute: '2-digit' })}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+/** Файл из переписки: картинку показываем, остальное — ссылкой. Доступ по initData, поэтому через fetch */
+function ChatFile({ id, file }: { id: string; file: { name: string | null; mime: string | null } }) {
+ const { t } = useI18n();
+ const [url, setUrl] = useState(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
;
+ return (
+
+ 📎 {file.name ?? t('client.chat.file')}
+
+ );
+}
diff --git a/apps/miniapp-client/src/screens/Docs.tsx b/apps/miniapp-client/src/screens/Docs.tsx
index 425f0f3..8d66bd5 100644
--- a/apps/miniapp-client/src/screens/Docs.tsx
+++ b/apps/miniapp-client/src/screens/Docs.tsx
@@ -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() {
{me.data && (
-