- 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>
111 lines
5.8 KiB
TypeScript
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)),
|
|
}),
|
|
});
|