Backoffice stage 2: chats (support + concierge, files)

- ChatThread per client/provider and channel; messages get files, internal notes,
  source (bot/mini app) and delivery errors; reply templates. Migration moves old
  concierge messages into threads.
- Routing: subscribers write to the concierge, others to support; /support and
  /concierge in the client bot; staff can take, transfer, close and move threads.
- Telegram: photos/documents from clients and providers, replies with the channel
  header, files sent from storage, notifications only to the assignee or the
  channel's staff, "Reply" into a specific thread.
- New admin: /chats with list, conversation, notes, files, templates, client panel;
  "Message" button on the client card; dashboard counts threads awaiting reply.
- Client mini app: in-app chat screen sharing the same conversation.
- Old admin keeps working on the same data (notes hidden there).
- Tests for routing, notes, assign/close/move, lists, recipients; separate test DB
  via TEST_DB_NAME; dev:chats-demo script.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:50:37 +04:00
1 parent d452824c62
commit 23ffb2bf1d
46 files changed
+4176 -144

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import {
assignThread,
closeThread,
deleteTemplate,
getThreadForStaff,
listTemplates,
listThreads,
moveThread,
openThreadFor,
reopenThread,
saveTemplate,
staffSend,
threadCounts,
type ChatChannel,
} from '@barev/services';
import type { Db } from '@barev/db';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, type StaffRole } from '../../trpc.ts';
const CHANNELS = ['SUPPORT', 'CONCIERGE'] as const;
/** Какие каналы видит сотрудник: поддержка — SUPPORT, консьерж — CONCIERGE, суперадмин — оба */
export function channelsFor(roles: StaffRole[], isSuper: boolean): ChatChannel[] {
if (isSuper) return ['SUPPORT', 'CONCIERGE'];
return CHANNELS.filter((c) => roles.includes(c));
}
const chatProcedure = rolesProcedure('SUPPORT', 'CONCIERGE').use(async ({ ctx, next }) => next({ ctx: { channels: channelsFor(ctx.roles, ctx.isSuper) } }));
/** Диалог существует и его канал доступен этому сотруднику */
async function guard(ctx: { db: Db; channels: ChatChannel[] }, id: string) {
const t = await ctx.db.chatThread.findUnique({ where: { id } });
if (!t) throw new TRPCError({ code: 'NOT_FOUND' });
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return t;
}
const filter = z.enum(['mine', 'unassigned', 'waiting', 'open', 'closed', 'all']);
export const boChatsRouter = router({
counts: chatProcedure.query(({ ctx }) => threadCounts(ctx.svc, { channels: ctx.channels, adminUserId: ctx.admin.id })),
list: chatProcedure
.input(z.object({ channel: z.enum(CHANNELS).optional(), filter, search: z.string().trim().max(100).optional() }))
.query(({ ctx, input }) => listThreads(ctx.svc, { ...input, channels: ctx.channels, adminUserId: ctx.admin.id })),
get: chatProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
await guard(ctx, input.id);
return getThreadForStaff(ctx.svc, input.id);
}),
send: chatProcedure
.input(z.object({ id: z.string(), text: z.string().max(4000), internal: z.boolean().default(false) }))
.mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
const m = await staffSend(ctx.svc, { threadId: input.id, adminUserId: ctx.admin.id, text: input.text, internal: input.internal });
return { id: m.id };
}),
/** Взять себе (assigneeId = я), передать коллеге или снять ответственного (null) */
assign: chatProcedure.input(z.object({ id: z.string(), assigneeId: z.string().nullable() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await assignThread(ctx.svc, { threadId: input.id, assigneeId: input.assigneeId, byAdminId: ctx.admin.id });
}),
close: chatProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await closeThread(ctx.svc, { threadId: input.id, byAdminId: ctx.admin.id });
}),
reopen: chatProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
await reopenThread(ctx.svc, { threadId: input.id, byAdminId: ctx.admin.id });
}),
/** Перенести в другой канал; диалог остаётся видимым, только если новый канал доступен сотруднику */
move: chatProcedure.input(z.object({ id: z.string(), channel: z.enum(CHANNELS) })).mutation(async ({ ctx, input }) => {
await guard(ctx, input.id);
const t = await moveThread(ctx.svc, { threadId: input.id, channel: input.channel, byAdminId: ctx.admin.id });
return { id: t.id, visible: ctx.channels.includes(t.channel) };
}),
/** Написать клиенту из карточки: открывает диалог в канале, куда он пишет (или в указанном) */
openFor: chatProcedure.input(z.object({ clientId: z.string(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => {
const channel = input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined);
const t = await openThreadFor(ctx.svc, { clientId: input.clientId }, channel);
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return { id: t.id };
}),
/** Кому можно передать диалог этого канала */
colleagues: chatProcedure.input(z.object({ channel: z.enum(CHANNELS) })).query(async ({ ctx, input }) => {
const staff = await ctx.db.adminUser.findMany({ where: { active: true, roles: { hasSome: ['SUPERADMIN', input.channel] } }, orderBy: { name: 'asc' } });
return staff.map((s) => ({ id: s.id, name: s.name, roles: s.roles, telegramLinked: !!s.telegramId }));
}),
templates: router({
list: chatProcedure.query(({ ctx }) => listTemplates(ctx.svc, ctx.channels)),
save: chatProcedure
.input(z.object({ id: z.string().optional(), channel: z.enum(CHANNELS).nullable(), title: z.string().trim().min(1).max(80), text: z.string().trim().min(1).max(4000) }))
.mutation(async ({ ctx, input }) => {
if (input.channel && !ctx.channels.includes(input.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return saveTemplate(ctx.svc, { ...input, adminUserId: ctx.admin.id });
}),
remove: chatProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => deleteTemplate(ctx.svc, input.id)),
}),
});
+4 -2
View File
@@ -1,5 +1,6 @@
import { DAY, startOfYerevanDay } from '@barev/core';
import { unreadConciergeCount } from '@barev/services';
import { threadCounts } from '@barev/services';
import { channelsFor } from './chats.ts';
import { router, staffProcedure } from '../../trpc.ts';
/** День по Еревану (UTC+4, без перехода на летнее время) */
@@ -16,7 +17,8 @@ export const boDashboardRouter = router({
const now = ctx.svc.now();
const [unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring] = await Promise.all([
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
unreadConciergeCount(ctx.svc),
// диалоги, которые ждут ответа, — только в каналах, доступных сотруднику
threadCounts(ctx.svc, { channels: channelsFor(ctx.roles, ctx.isSuper), adminUserId: ctx.admin.id }).then((c) => c.total),
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
ctx.db.lead.count({ where: { status: 'NEW' } }),
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
+2
View File
@@ -2,6 +2,7 @@ import { router } from '../../trpc.ts';
import { boAuthRouter, boTeamRouter } from './auth.ts';
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
import { boDashboardRouter } from './dashboard.ts';
import { boChatsRouter } from './chats.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
@@ -10,4 +11,5 @@ export const backofficeRouter = router({
dashboard: boDashboardRouter,
clients: boClientsRouter,
segments: boSegmentsRouter,
chats: boChatsRouter,
});
+13
View File
@@ -27,6 +27,9 @@ import {
toggleRelocationDoc,
cardQrToken,
clientPartners,
clientConversation,
receiveMessage,
setClientChannel,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
@@ -232,4 +235,14 @@ export const clientRouter = router({
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'CLIENT', actorId: ctx.client.id } });
return { url: await signedUrl(file.storageKey) };
}),
/** Чат с командой: та же переписка, что в боте; ответы приходят и сюда, и в чат с ботом */
chat: router({
get: clientProcedure.query(({ ctx }) => clientConversation(ctx.svc, ctx.client.id)),
send: consentedClientProcedure.input(z.object({ text: z.string().trim().min(1).max(4000) })).mutation(async ({ ctx, input }) => {
const res = await receiveMessage(ctx.svc, { clientId: ctx.client.id }, { text: input.text, source: 'MINIAPP' });
return { id: res?.message.id ?? null, channel: res?.channel ?? null };
}),
setChannel: consentedClientProcedure.input(z.object({ channel: z.enum(['SUPPORT', 'CONCIERGE']) })).mutation(({ ctx, input }) => setClientChannel(ctx.svc, ctx.client.id, input.channel)),
}),
});
+114 -4
View File
@@ -1,16 +1,24 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import multipart, { type MultipartFile } from '@fastify/multipart';
import * as Sentry from '@sentry/node';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { getFlow } from '@barev/core';
import { DomainError, proIdentity, setChecklistItem } from '@barev/services';
import Fastify from 'fastify';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend } from '@barev/services';
import { getFile, putFile } from '@barev/services/storage';
import { TelegramSender } from '@barev/services/telegram';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
import { validateInitData } from './auth/telegram.ts';
import { env } from './env.ts';
import { ctx as svc, db, ensureBucket, putObject } from './infra.ts';
import { appRouter, type AppRouter } from './router.ts';
import { createContext } from './trpc.ts';
import { channelsFor } from './routers/backoffice/chats.ts';
import { createContext, type StaffRole } from './trpc.ts';
/** Скачивание файлов, присланных ботам, — по их токенам */
const clientBotFiles = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
const proBotFiles = new TelegramSender(env.PRO_BOT_TOKEN, 'pro-bot');
if (env.SENTRY_DSN) Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
@@ -82,6 +90,108 @@ app.post('/files/pro/checklist-photo', async (req, reply) => {
return { fileId: file.id };
});
// ───── файлы в чатах ─────
/** Что можно прислать в чат: картинки, PDF, офисные документы, текст, архив */
const CHAT_TYPES = /^(image\/(jpeg|png|webp|gif|heic)|application\/pdf|application\/msword|application\/vnd\.openxmlformats-officedocument\.[\w.]+|application\/vnd\.ms-excel|text\/plain|application\/zip)$/;
/** Сотрудник новой админки по cookie-сессии и каналы, которые ему доступны */
async function staffFromCookie(req: FastifyRequest) {
const session = await findSession(db, req.cookies[ADMIN_COOKIE]);
if (!session?.verified || session.adminUser.roles.length === 0) return null;
const roles = session.adminUser.roles as StaffRole[];
return { admin: session.adminUser, channels: channelsFor(roles, roles.includes('SUPERADMIN')) };
}
function multipartField(part: MultipartFile, name: string) {
const f = part.fields[name];
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
}
/** Имя файла как пришло, но без путей и управляющих символов */
const safeName = (name: string | undefined) => (name ?? 'file').replace(/[\\/\u0000-\u001f]/g, '_').slice(-120) || 'file';
/** Команда отправляет файл в диалог (или во внутреннюю заметку) */
app.post<{ Params: { threadId: string } }>('/files/bo/chat/:threadId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
const thread = await db.chatThread.findUnique({ where: { id: req.params.threadId } });
if (!thread) return reply.code(404).send({ error: 'NOT_FOUND' });
if (!staff.channels.includes(thread.channel)) return reply.code(403).send({ error: 'ROLE_FORBIDDEN' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`chats/${thread.id}`, body, part.mimetype, fileName);
try {
const m = await staffSend(svc, {
threadId: thread.id,
adminUserId: staff.admin.id,
text: multipartField(part, 'text') ?? '',
internal: multipartField(part, 'internal') === 'true',
file: { fileKey, fileName, mime: part.mimetype, size: body.length },
});
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.chats.sendFile', entity: 'chats', entityId: thread.id, data: { fileName, size: body.length } } });
return { id: m.id };
} catch (e) {
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
throw e;
}
});
/** Отдать файл сообщения: из нашего хранилища или из Telegram (бот скачивает по file_id) */
async function sendChatFile(reply: FastifyReply, m: { fileKey: string | null; tgFileId: string | null; tgBot: string | null; mime: string | null; fileName: string | null }) {
reply.header('cache-control', 'private, max-age=3600');
reply.header('content-type', m.mime ?? 'application/octet-stream');
reply.header('content-disposition', `inline; filename*=UTF-8''${encodeURIComponent(m.fileName ?? 'file')}`);
reply.header('x-content-type-options', 'nosniff');
if (m.fileKey) return reply.send(await getFile(m.fileKey));
if (m.tgFileId) {
const res = await (m.tgBot === 'pro' ? proBotFiles : clientBotFiles).download(m.tgFileId);
if (!res?.body) return reply.code(404).send({ error: 'FILE_UNAVAILABLE' });
return reply.send(Buffer.from(await res.arrayBuffer()));
}
return reply.code(404).send({ error: 'NOT_FOUND' });
}
app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId }, include: { thread: { select: { channel: true } } } });
if (!m?.thread || !staff.channels.includes(m.thread.channel)) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, m);
});
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
function clientFromInit(req: FastifyRequest) {
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
const init = validateInitData(auth, env.clientBotToken, env.TG_INIT_DATA_MAX_AGE);
return init.ok ? getClientByTelegramId(db, BigInt(init.user.id)) : Promise.resolve(null);
}
app.get<{ Params: { messageId: string } }>('/files/client/chat-file/:messageId', async (req, reply) => {
const client = await clientFromInit(req);
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId } });
if (!m || m.clientId !== client.id || m.internal) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, m);
});
app.post('/files/client/chat', async (req, reply) => {
const client = await clientFromInit(req);
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
if (!client.consentAt) return reply.code(412).send({ error: 'CONSENT_REQUIRED' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`chats/client-${client.id}`, body, part.mimetype, fileName);
const res = await receiveMessage(svc, { clientId: client.id }, { text: multipartField(part, 'text') ?? '', source: 'MINIAPP', file: { fileKey, fileName, mime: part.mimetype, size: body.length } });
return { id: res?.message.id ?? null };
});
/** Ошибки из Mini App и админки (sendBeacon) — только в лог, без хранения */
app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
const b = (typeof req.body === 'object' && req.body) || {};