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:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4176
-144
No files matched your search
@@ -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)),
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user