Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

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-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import { DomainError, type Ctx } from './context.ts';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId });
async function incoming(ctx: Ctx, peer: Peer, text: string) {
const body = text.trim().slice(0, 4000);
if (!body) return null;
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: peer.clientId ? 'CLIENT' : 'PROVIDER', text: body } });
await ctx.notify.emit({ type: 'concierge.message', messageId: msg.id });
return msg;
}
/** Сообщение клиента боту — в чат консьержа в админке и уведомление операторам */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string) => incoming(ctx, { clientId }, text);
/** Сообщение исполнителя рабочему боту */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string) => incoming(ctx, { providerId }, text);
/** Список диалогов для оператора: последнее сообщение и сколько непрочитанных, свежие сверху */
export async function listConversations(ctx: Ctx) {
const [last, unread] = await Promise.all([
ctx.db.conciergeMessage.findMany({
distinct: ['clientId', 'providerId'],
orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }],
include: {
client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } } } },
provider: { include: { user: { select: { username: true, locale: true } } } },
},
}),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { not: 'OPERATOR' }, readAt: null }, _count: true }),
]);
const key = (m: { clientId: string | null; providerId: string | null }) => (m.clientId ? `c:${m.clientId}` : `p:${m.providerId}`);
const unreadBy = new Map(unread.map((u) => [key(u), u._count]));
return last
.map((m) => ({
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
}))
.sort((a, b) => b.last.createdAt.getTime() - a.last.createdAt.getTime());
}
/** Переписка; открыл — входящие прочитаны. Пустая — можно написать первым */
export async function conversation(ctx: Ctx, peer: Peer) {
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
return ctx.db.conciergeMessage.findMany({ where: peerWhere(peer), orderBy: { createdAt: 'asc' }, take: 500 });
}
/** Кто это — для заголовка нового диалога, который оператор начинает первым */
export async function peerInfo(ctx: Ctx, peer: Peer) {
if (peer.clientId) {
const c = await ctx.db.client.findUniqueOrThrow({ where: { id: peer.clientId }, include: { user: true } });
return { name: `${c.user.firstName ?? ''} ${c.user.lastName ?? ''}`.trim(), username: c.user.username, locale: c.user.locale, reachable: true };
}
const p = await ctx.db.provider.findUniqueOrThrow({ where: { id: peer.providerId! }, include: { user: true } });
return { name: p.name, username: p.user?.username ?? null, locale: p.user?.locale ?? 'ru', reachable: !!p.user };
}
export function unreadConciergeCount(ctx: Ctx) {
return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, readAt: null } });
}
/** Ответ оператора: сохраняем и отправляем в чат с ботом (воркер) */
export async function replyTo(ctx: Ctx, input: Peer & { adminUserId: string; text: string }) {
const peer: Peer = input.clientId ? { clientId: input.clientId } : { providerId: input.providerId! };
if (peer.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: peer.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: 'OPERATOR', adminUserId: input.adminUserId, text: input.text.trim().slice(0, 4000) } });
// ответили — входящие этого диалога прочитаны
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
await ctx.notify.emit({ type: 'concierge.reply', messageId: msg.id });
return msg;
}
/** Совместимость: ответ клиенту */
export const replyToClient = (ctx: Ctx, input: { clientId: string; adminUserId: string; text: string }) => replyTo(ctx, input);