Files
BarevClub/apps/api/src/routers/backoffice/chats.ts
T
zabudko-ruandClaude Opus 5.5 e7de4f5893 Backoffice stage 3: orders and providers, auto-assignment
- Auto-assignment: a new order is offered to the best free provider (same ranking as
  the dispatcher hint); a decline or timeout passes it to the next one; nobody is
  offered the same order twice; after N tries it waits for a dispatcher and the team
  is notified once. The worker retries waiting orders every minute. Settings
  assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
  done, cancelled), periods and search; order card with progress, provider or ranked
  candidates ("offer" / "pick automatically"), details, checklist and photos,
  history in plain words, client, money (superadmin), payment, status change,
  reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
  address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
  quality, ratings, upcoming orders, schedule and districts, admission documents,
  block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
  and documents (ProviderDoc).
- Tests for auto-assignment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 02:25:41 +04:00

111 lines
5.8 KiB
TypeScript

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)),
}),
});