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:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -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);
|
||||
Reference in new issue
Block a user