From 23ffb2bf1d67c0b0d8b518318a933f39775f06cf Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Wed, 21 Oct 2026 01:50:37 +0400 Subject: [PATCH] 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) --- .env.example | 2 + apps/api/package.json | 3 +- apps/api/scripts/dev-backoffice-demo.ts | 5 +- apps/api/scripts/dev-chats-demo.ts | 195 ++++ apps/api/src/routers/backoffice/chats.ts | 108 ++ apps/api/src/routers/backoffice/dashboard.ts | 6 +- apps/api/src/routers/backoffice/index.ts | 2 + apps/api/src/routers/client.ts | 13 + apps/api/src/server.ts | 118 ++- apps/backoffice/src/App.tsx | 8 +- apps/backoffice/src/chats.css | 789 +++++++++++++++ apps/backoffice/src/components/kit.tsx | 10 + apps/backoffice/src/i18n.tsx | 309 +++++- apps/backoffice/src/main.tsx | 1 + apps/backoffice/src/pages/Chats.tsx | 945 ++++++++++++++++++ apps/backoffice/src/pages/ClientCard.tsx | 33 +- apps/backoffice/vite.config.ts | 4 +- apps/bot-client/src/index.ts | 66 +- apps/bot-pro/src/index.ts | 65 +- apps/miniapp-client/src/App.tsx | 16 +- apps/miniapp-client/src/app.css | 154 +++ apps/miniapp-client/src/screens/Chat.tsx | 162 +++ apps/miniapp-client/src/screens/Docs.tsx | 4 +- apps/miniapp-client/src/screens/Move.tsx | 4 +- apps/miniapp-client/vite.config.ts | 4 +- apps/worker/src/index.ts | 61 +- infra/prod/env.example | 2 + .../migration.sql | 130 +++ packages/db/prisma/schema.prisma | 87 +- packages/i18n/locales/en.json | 34 +- packages/i18n/locales/es.json | 34 +- packages/i18n/locales/hy.json | 34 +- packages/i18n/locales/ru.json | 34 +- packages/i18n/locales/zh.json | 34 +- packages/services/package.json | 12 +- packages/services/src/chats.ts | 447 +++++++++ packages/services/src/concierge.ts | 85 +- packages/services/src/context.ts | 6 +- packages/services/src/index.ts | 1 + packages/services/src/storage.ts | 43 + packages/services/src/telegram.ts | 40 +- packages/services/test/chats.test.ts | 189 ++++ packages/services/test/flows.test.ts | 8 +- packages/services/test/global-setup.ts | 8 +- packages/services/test/helpers.ts | 2 +- pnpm-lock.yaml | 3 + 46 files changed, 4176 insertions(+), 144 deletions(-) create mode 100644 apps/api/scripts/dev-chats-demo.ts create mode 100644 apps/api/src/routers/backoffice/chats.ts create mode 100644 apps/backoffice/src/chats.css create mode 100644 apps/backoffice/src/pages/Chats.tsx create mode 100644 apps/miniapp-client/src/screens/Chat.tsx create mode 100644 packages/db/prisma/migrations/20261021120000_backoffice_chats/migration.sql create mode 100644 packages/services/src/chats.ts create mode 100644 packages/services/src/storage.ts create mode 100644 packages/services/test/chats.test.ts 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 ( -
+