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().optional(), providerId: z.string().optional(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => { if (!input.clientId && !input.providerId) throw new TRPCError({ code: 'BAD_REQUEST' }); // исполнитель пишет только в поддержку const channel = input.providerId ? 'SUPPORT' : (input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined)); const t = await openThreadFor(ctx.svc, input.providerId ? { providerId: input.providerId } : { 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)), }), });