import { randomBytes } from 'node:crypto'; import { hash, verify } from '@node-rs/argon2'; import { getFlow, LOCALES, normalizeAgencyCode, ORDER_STATUSES, paramsSchemaSchema, payoutRulesSchema, pricingSchema, PROVIDER_ROLES, SETTING_KEYS, settingsSchema, slotConfigSchema, startOfYerevanDay, type ServiceFlow, } from '@barev/core'; import { adminOrder, assignmentCandidates, getSettings, kanban, listLeads, conversation, grantSubscription, listClientRequests, listConversations, replyTo, addLeadDoc, addLeadStep, listRelocationItems, moveRelocationItem, peerInfo, relocationNames, removeLeadDoc, removeLeadStep, saveRelocationItem, updateLeadDoc, createOwnerInvite, savePartnerOffer, unreadConciergeCount, updateClientRequest, updateLeadStep, listWebRequests, markPaid, offerOrder, summary, transitionOrder, unassignOrder, updateLeadByAdmin, updateWebRequest, } from '@barev/services'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { ADMIN_COOKIE, cookieOptions, createSession, destroySession, loginThrottled, markVerified, publicAdmin } from '../auth/admin.ts'; import { generateTotpSecret, otpauthUri, verifyTotp } from '../auth/totp.ts'; import { env } from '../env.ts'; import { signedUrl } from '../infra.ts'; import { adminPreAuthProcedure, adminProcedure, ownerProcedure, publicProcedure, router } from '../trpc.ts'; const invite = (prefix: string) => `${prefix}-${randomBytes(5).toString('hex').toUpperCase()}`; const weekSchedule = z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) })); const localized = z.object({ ru: z.string().min(1), en: z.string(), zh: z.string(), hy: z.string(), es: z.string() }); const authRouter = router({ /** Шаг 1: email + пароль. Сессия создаётся «непроверенной» до прохождения 2FA. */ login: publicProcedure.input(z.object({ email: z.string().trim().min(3).max(120), password: z.string().min(1) })).mutation(async ({ ctx, input }) => { if (loginThrottled(`${input.email}|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'LOGIN_THROTTLED' }); const admin = await ctx.db.adminUser.findUnique({ where: { email: input.email.toLowerCase() } }); if (!admin || !admin.active || !admin.passwordHash || !(await verify(admin.passwordHash, input.password))) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'BAD_CREDENTIALS' }); } const token = await createSession(ctx.db, admin.id); ctx.res.setCookie(ADMIN_COOKIE, token, cookieOptions); return { next: admin.totpEnabled ? ('totp' as const) : ('totp_setup' as const) }; }), /** 2FA обязательна: при первом входе — выдаём секрет для приложения-аутентификатора */ totpSetup: adminPreAuthProcedure.mutation(async ({ ctx }) => { if (ctx.admin.totpEnabled) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TOTP_ALREADY_ENABLED' }); const secret = generateTotpSecret(); await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { totpSecret: secret } }); return { secret, uri: otpauthUri(secret, ctx.admin.email ?? ctx.admin.name) }; }), /** Шаг 2: код из приложения. При первой настройке — включает 2FA. */ totpVerify: adminPreAuthProcedure.input(z.object({ code: z.string().regex(/^\d{6}$/) })).mutation(async ({ ctx, input }) => { const admin = await ctx.db.adminUser.findUniqueOrThrow({ where: { id: ctx.admin.id } }); if (!admin.totpSecret || !verifyTotp(admin.totpSecret, input.code)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'BAD_TOTP' }); if (!admin.totpEnabled) await ctx.db.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: true } }); await markVerified(ctx.db, ctx.token); return publicAdmin(admin); }), me: adminPreAuthProcedure.query(({ ctx }) => ({ ...publicAdmin(ctx.admin), verified: ctx.session.verified })), /** Уведомления в Telegram через рабочий бот: статус привязки */ telegram: adminProcedure.query(async ({ ctx }) => { const a = await ctx.db.adminUser.findUniqueOrThrow({ where: { id: ctx.admin.id } }); return { linked: !!a.telegramId, notify: a.tgNotify, bot: env.PRO_BOT_USERNAME }; }), /** Одноразовая ссылка t.me/<рабочий бот>?start=adm-<код>: открыл — Telegram привязан к этому администратору */ telegramLink: adminProcedure.mutation(async ({ ctx }) => { const token = randomBytes(9).toString('base64url').replace(/[-_]/g, 'x'); await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { tgLinkToken: token } }); return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` }; }), telegramSettings: adminProcedure.input(z.object({ notify: z.boolean().optional(), unlink: z.boolean().optional() })).mutation(async ({ ctx, input }) => { await ctx.db.adminUser.update({ where: { id: ctx.admin.id }, data: { tgNotify: input.notify, ...(input.unlink ? { telegramId: null, tgLinkToken: null } : {}) }, }); }), logout: publicProcedure.mutation(async ({ ctx }) => { await destroySession(ctx.db, ctx.req.cookies[ADMIN_COOKIE]); ctx.res.clearCookie(ADMIN_COOKIE, { path: '/' }); }), }); const ordersRouter = router({ /** ADM-1 */ kanban: adminProcedure .input( z.object({ category: z.string().optional(), serviceCode: z.string().optional(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), search: z.string().max(100).optional(), includeCancelled: z.boolean().optional(), }), ) .query(({ ctx, input }) => { let dateFrom: Date | undefined; let dateTo: Date | undefined; if (input.date) { dateFrom = startOfYerevanDay(new Date(`${input.date}T12:00:00+04:00`)); dateTo = new Date(dateFrom.getTime() + 24 * 3600_000); } return kanban(ctx.svc, { ...input, dateFrom, dateTo }); }), /** ADM-2 */ summary: adminProcedure.query(({ ctx }) => summary(ctx.svc)), /** ADM-4 */ get: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { const o = await adminOrder(ctx.db, input.id); const flow = getFlow(o.service.flow as ServiceFlow); const allowed = [...new Set(flow.transitions.filter((t) => t.from === o.status && t.by.includes('ADMIN')).map((t) => t.to))]; return { ...o, client: { ...o.client, user: { ...o.client.user, telegramId: o.client.user.telegramId.toString() } }, adminTransitions: allowed, flowSteps: flow.steps, canCancel: o.status !== 'CANCELLED' && !flow.done.includes(o.status), }; }), /** ADM-3 */ candidates: adminProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => assignmentCandidates(ctx.svc, input.id)), assign: adminProcedure .input(z.object({ orderId: z.string(), target: z.object({ kind: z.enum(['provider', 'company']), id: z.string() }) })) .mutation(({ ctx, input }) => offerOrder(ctx.svc, { orderId: input.orderId, adminId: ctx.admin.id, target: input.target })), unassign: adminProcedure.input(z.object({ orderId: z.string() })).mutation(({ ctx, input }) => unassignOrder(ctx.svc, { orderId: input.orderId, adminId: ctx.admin.id })), /** Ручная смена статуса — с теми же проверками state machine */ transition: adminProcedure .input(z.object({ orderId: z.string(), to: z.enum(ORDER_STATUSES), comment: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { await transitionOrder(ctx.svc, { orderId: input.orderId, to: input.to, actor: 'ADMIN', actorId: ctx.admin.id, comment: input.comment }); }), /** Отмена оператором; удержание задаётся вручную (по умолчанию 0) */ cancel: adminProcedure .input(z.object({ orderId: z.string(), reason: z.string().max(500).optional(), fee: z.number().int().min(0).default(0) })) .mutation(async ({ ctx, input }) => { await transitionOrder(ctx.svc, { orderId: input.orderId, to: 'CANCELLED', actor: 'ADMIN', actorId: ctx.admin.id, comment: input.reason, data: { cancelFee: input.fee, cancelReason: input.reason }, }); await ctx.db.orderOffer.updateMany({ where: { orderId: input.orderId, status: 'OFFERED' }, data: { status: 'REVOKED' } }); }), /** PAY-1 */ markPaid: adminProcedure .input(z.object({ orderId: z.string(), paymentMethodId: z.string().optional() })) .mutation(async ({ ctx, input }) => { await markPaid(ctx.svc, { orderId: input.orderId, by: { type: 'ADMIN', id: ctx.admin.id }, paymentMethodId: input.paymentMethodId }); }), fileUrl: adminProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => { const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId } }); if (!file || file.deletedAt) throw new TRPCError({ code: 'NOT_FOUND' }); await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'ADMIN', actorId: ctx.admin.id } }); return { url: await signedUrl(file.storageKey) }; }), }); const servicesRouter = router({ list: adminProcedure.query(({ ctx }) => ctx.db.category.findMany({ orderBy: { sortOrder: 'asc' }, include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } }, }), ), update: adminProcedure .input( z.object({ id: z.string(), active: z.boolean().optional(), lateCancelFeePct: z.number().int().min(0).max(100).optional(), pricing: pricingSchema.optional(), paramsSchema: paramsSchemaSchema.optional(), slotConfig: slotConfigSchema.optional(), }), ) .mutation(({ ctx, input }) => { const { id, ...data } = input; return ctx.db.service.update({ where: { id }, data }); }), updateCategory: ownerProcedure .input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100).optional(), eliteDiscountPct: z.number().int().min(0).max(100).optional() })) .mutation(({ ctx, input }) => { const { id, ...data } = input; return ctx.db.category.update({ where: { id }, data }); }), }); const providerInput = z.object({ name: z.string().min(1).max(100), phone: z.string().max(30).optional().nullable(), role: z.enum(PROVIDER_ROLES), companyId: z.string().optional().nullable(), languages: z.array(z.enum(LOCALES)).min(1), districtIds: z.array(z.string()), schedule: weekSchedule, rateType: z.enum(['FIXED', 'PERCENT']), rateValue: z.number().int().min(0), /** пресет ставок; null — индивидуальная ставка */ payoutPresetId: z.string().optional().nullable(), active: z.boolean().default(true), }); /** Пресеты ставок (джун / мидл / прайват): фикс или % по каждой строке цены */ const presetsRouter = router({ list: adminProcedure.query(async ({ ctx }) => { const presets = await ctx.db.payoutPreset.findMany({ orderBy: [{ role: 'asc' }, { sortOrder: 'asc' }], include: { _count: { select: { providers: true } } } }); return presets.map((p) => ({ ...p, rules: payoutRulesSchema.parse(p.rules), providers: p._count.providers })); }), save: ownerProcedure .input(z.object({ id: z.string().optional(), name: z.string().min(1).max(60), role: z.enum(PROVIDER_ROLES), rules: payoutRulesSchema, active: z.boolean(), sortOrder: z.number().int().default(0) })) .mutation(({ ctx, input }) => { const { id, ...data } = input; return id ? ctx.db.payoutPreset.update({ where: { id }, data }) : ctx.db.payoutPreset.create({ data }); }), }); const providersRouter = router({ list: adminProcedure.query(({ ctx }) => ctx.db.provider.findMany({ orderBy: [{ active: 'desc' }, { name: 'asc' }], include: { company: { select: { id: true, name: true } }, payoutPreset: { select: { id: true, name: true } }, districts: { include: { district: true } }, schedule: true }, }), ), create: adminProcedure.input(providerInput).mutation(({ ctx, input }) => { const { districtIds, schedule, ...data } = input; return ctx.db.provider.create({ data: { ...data, inviteCode: invite('PRO'), districts: { create: districtIds.map((districtId) => ({ districtId })) }, schedule: { create: schedule }, }, }); }), update: adminProcedure.input(providerInput.extend({ id: z.string() })).mutation(({ ctx, input }) => { const { id, districtIds, schedule, ...data } = input; return ctx.db.provider.update({ where: { id }, data: { ...data, districts: { deleteMany: {}, create: districtIds.map((districtId) => ({ districtId })) }, schedule: { deleteMany: {}, create: schedule }, }, }); }), /** Новый инвайт — старая привязка к Telegram снимается */ resetInvite: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => ctx.db.provider.update({ where: { id: input.id }, data: { inviteCode: invite('PRO'), userId: null } }), ), }); const companyInput = z.object({ name: z.string().min(1).max(100), roles: z.array(z.enum(PROVIDER_ROLES)).min(1), rateType: z.enum(['FIXED', 'PERCENT']), rateValue: z.number().int().min(0), contactPhone: z.string().max(30).optional().nullable(), active: z.boolean().default(true), }); const companiesRouter = router({ list: adminProcedure.query(({ ctx }) => ctx.db.providerCompany.findMany({ orderBy: { name: 'asc' }, include: { employees: { select: { id: true, name: true, active: true } }, dispatchers: { include: { user: { select: { firstName: true, username: true } } } } }, }), ), create: adminProcedure.input(companyInput).mutation(({ ctx, input }) => ctx.db.providerCompany.create({ data: { ...input, inviteCode: invite('CO') } })), update: adminProcedure.input(companyInput.extend({ id: z.string() })).mutation(({ ctx, input }) => { const { id, ...data } = input; return ctx.db.providerCompany.update({ where: { id }, data }); }), resetInvite: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => ctx.db.providerCompany.update({ where: { id: input.id }, data: { inviteCode: invite('CO') } }), ), removeDispatcher: adminProcedure.input(z.object({ id: z.string() })).mutation(({ ctx, input }) => ctx.db.companyDispatcher.delete({ where: { id: input.id } })), }); const agencyInput = z.object({ code: z.string().min(2).max(32), name: z.string().min(1).max(100), commissionPct: z.number().int().min(0).max(100), contact: z.string().max(200).optional().nullable(), active: z.boolean().default(true), }); const agenciesRouter = router({ list: adminProcedure.query(async ({ ctx }) => { const agencies = await ctx.db.agency.findMany({ orderBy: { name: 'asc' }, include: { _count: { select: { clients: true } } } }); return agencies.map((a) => ({ ...a, leads: a._count.clients })); }), create: ownerProcedure.input(agencyInput).mutation(({ ctx, input }) => { const code = normalizeAgencyCode(input.code); if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' }); return ctx.db.agency.create({ data: { ...input, code } }); }), update: ownerProcedure.input(agencyInput.extend({ id: z.string() })).mutation(({ ctx, input }) => { const { id, ...data } = input; const code = normalizeAgencyCode(data.code); if (!code) throw new TRPCError({ code: 'BAD_REQUEST', message: 'AGENCY_CODE_FORMAT' }); return ctx.db.agency.update({ where: { id }, data: { ...data, code } }); }), }); const clientsRouter = router({ /** ADM-6 (базовый): список, источник, статус Barev+ */ list: adminProcedure.input(z.object({ search: z.string().max(100).optional() })).query(async ({ ctx, input }) => { const q = input.search?.trim(); const now = ctx.svc.now(); const clients = await ctx.db.client.findMany({ where: q ? { user: { OR: [{ firstName: { contains: q, mode: 'insensitive' } }, { username: { contains: q, mode: 'insensitive' } }, { phone: { contains: q } }] } } : {}, include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } }, agency: { select: { name: true, code: true } }, subscriptions: { where: { status: 'ACTIVE', startsAt: { lte: now }, endsAt: { gt: now } }, orderBy: { plan: 'desc' }, take: 1 }, _count: { select: { orders: true } }, }, orderBy: { createdAt: 'desc' }, take: 200, }); return clients.map((c) => ({ id: c.id, user: c.user, agency: c.agency, source: c.source, utm: c.utm, consented: !!c.consentAt, barevPlusUntil: c.subscriptions[0]?.endsAt ?? null, plan: c.subscriptions[0]?.plan ?? null, segment: c.segment, orders: c._count.orders, createdAt: c.createdAt, })); }), /** Выдать Barev+ или Elite вручную (онлайн-оплаты пока нет) */ grantBarevPlus: ownerProcedure .input(z.object({ clientId: z.string(), days: z.number().int().min(1).max(366), plan: z.enum(['BAREV_PLUS', 'ELITE']).default('BAREV_PLUS') })) .mutation(({ ctx, input }) => grantSubscription(ctx.svc, input)), }); const dictionariesRouter = router({ /** ссылки для инвайтов и агентств */ config: adminProcedure.query(() => ({ clientBot: env.CLIENT_BOT_USERNAME, proBot: env.PRO_BOT_USERNAME, landingUrl: env.LANDING_URL })), districts: adminProcedure.query(({ ctx }) => ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })), paymentMethods: adminProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })), savePaymentMethod: ownerProcedure .input(z.object({ id: z.string().optional(), code: z.string().regex(/^[a-z0-9_]+$/), name: localized, active: z.boolean(), sortOrder: z.number().int() })) .mutation(({ ctx, input }) => { const { id, ...data } = input; return id ? ctx.db.paymentMethod.update({ where: { id }, data }) : ctx.db.paymentMethod.create({ data }); }), }); const settingsRouter = router({ get: adminProcedure.query(({ ctx }) => getSettings(ctx.db)), update: ownerProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => { for (const key of SETTING_KEYS) { if (input[key] === undefined) continue; await ctx.db.setting.upsert({ where: { key }, create: { key, value: input[key] }, update: { value: input[key] } }); } return getSettings(ctx.db); }), }); const adminsRouter = router({ list: ownerProcedure.query(async ({ ctx }) => (await ctx.db.adminUser.findMany({ orderBy: { createdAt: 'asc' } })).map((a) => ({ ...publicAdmin(a), active: a.active }))), create: ownerProcedure .input(z.object({ email: z.string().trim().min(3).max(120), name: z.string().min(1), role: z.enum(['OWNER', 'OPERATOR']), password: z.string().min(10) })) .mutation(async ({ ctx, input }) => publicAdmin( await ctx.db.adminUser.create({ data: { email: input.email.toLowerCase(), name: input.name, role: input.role, roles: input.role === 'OWNER' ? ['SUPERADMIN'] : ['SUPPORT', 'CONCIERGE'], passwordHash: await hash(input.password) } }), ), ), setActive: ownerProcedure.input(z.object({ id: z.string(), active: z.boolean() })).mutation(async ({ ctx, input }) => { if (input.id === ctx.admin.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'CANNOT_DISABLE_SELF' }); await ctx.db.adminUser.update({ where: { id: input.id }, data: { active: input.active } }); if (!input.active) await ctx.db.adminSession.deleteMany({ where: { adminUserId: input.id } }); }), }); /** Заявки с сайта (barevclub.com/clean) */ const webRequestsRouter = router({ list: adminProcedure .input(z.object({ status: z.enum(['NEW', 'LINKED', 'ORDERED', 'CANCELLED']).optional() })) .query(({ ctx, input }) => listWebRequests(ctx.svc, input)), /** счётчик для бейджа в меню: заявки, по которым ещё нет заказа */ openCount: adminProcedure.query(({ ctx }) => ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } })), update: adminProcedure .input(z.object({ id: z.string(), status: z.enum(['CANCELLED', 'REOPEN']).optional(), adminNote: z.string().max(1000).optional() })) .mutation(({ ctx, input }) => updateWebRequest(ctx.svc, input)), }); /** Заявки из квиза на главной («Переезжаю в Армению») */ const LEAD_STATUSES = ['NEW', 'IN_WORK', 'DONE', 'CANCELLED'] as const; const leadsRouter = router({ list: adminProcedure.input(z.object({ status: z.enum(LEAD_STATUSES).optional() })).query(({ ctx, input }) => listLeads(ctx.svc, input)), openCount: adminProcedure.query(({ ctx }) => ctx.db.lead.count({ where: { status: 'NEW' } })), update: adminProcedure .input(z.object({ id: z.string(), status: z.enum(LEAD_STATUSES).optional(), adminNote: z.string().max(1000).optional() })) .mutation(({ ctx, input }) => updateLeadByAdmin(ctx.svc, input)), /** шаг плана переезда: статус, комментарий для клиента, своё название */ updateStep: adminProcedure .input(z.object({ stepId: z.string(), status: z.enum(['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED']).optional(), note: z.string().max(500).optional(), title: z.string().max(120).optional() })) .mutation(({ ctx, input }) => updateLeadStep(ctx.svc, input)), addStep: adminProcedure .input(z.object({ leadId: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() })) .mutation(({ ctx, input }) => addLeadStep(ctx.svc, input)), removeStep: adminProcedure.input(z.object({ stepId: z.string() })).mutation(({ ctx, input }) => removeLeadStep(ctx.svc, input.stepId)), /** документы клиента */ addDoc: adminProcedure .input(z.object({ leadId: z.string(), code: z.string().max(40).optional(), title: z.string().max(120).optional() })) .mutation(({ ctx, input }) => addLeadDoc(ctx.svc, input)), updateDoc: adminProcedure .input(z.object({ docId: z.string(), title: z.string().max(120).optional(), checked: z.boolean().optional() })) .mutation(({ ctx, input }) => updateLeadDoc(ctx.svc, input)), removeDoc: adminProcedure.input(z.object({ docId: z.string() })).mutation(({ ctx, input }) => removeLeadDoc(ctx.svc, input.docId)), /** общий список шагов и документов */ names: adminProcedure.query(({ ctx }) => relocationNames(ctx.db)), templates: adminProcedure.query(({ ctx }) => listRelocationItems(ctx.svc)), saveTemplate: adminProcedure .input( z.object({ id: z.string().optional(), kind: z.enum(['STEP', 'DOC']), name: localized, hint: z.object({ ru: z.string(), en: z.string(), zh: z.string(), hy: z.string(), es: z.string() }).optional().nullable(), active: z.boolean(), inTurnkey: z.boolean().optional(), }), ) .mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input)), moveTemplate: adminProcedure.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)), }); /** Заявки из приложения: подписка или «перезвоните» по услуге */ const requestsRouter = router({ list: adminProcedure.input(z.object({ status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional() })).query(({ ctx, input }) => listClientRequests(ctx.svc, input)), openCount: adminProcedure.query(({ ctx }) => ctx.db.clientRequest.count({ where: { status: 'NEW' } })), update: adminProcedure .input(z.object({ id: z.string(), status: z.enum(['NEW', 'DONE', 'CANCELLED']).optional(), adminNote: z.string().max(1000).optional() })) .mutation(({ ctx, input }) => updateClientRequest(ctx.svc, input)), /** включить подписку по заявке: создаёт подписку и закрывает заявку */ activate: adminProcedure .input(z.object({ id: z.string(), days: z.number().int().min(1).max(366) })) .mutation(async ({ ctx, input }) => { const r = await ctx.db.clientRequest.findUniqueOrThrow({ where: { id: input.id } }); if (r.kind !== 'SUBSCRIPTION' || !r.plan) throw new TRPCError({ code: 'BAD_REQUEST' }); return grantSubscription(ctx.svc, { clientId: r.clientId, plan: r.plan, days: input.days, requestId: r.id }); }), }); /** Переписка: клиенты пишут клиентскому боту, исполнители — рабочему; оператор отвечает отсюда */ const peerInput = z.union([z.object({ clientId: z.string() }), z.object({ providerId: z.string() })]); const conciergeRouter = router({ list: adminProcedure.query(({ ctx }) => listConversations(ctx.svc)), unreadCount: adminProcedure.query(({ ctx }) => unreadConciergeCount(ctx.svc)), get: adminProcedure.input(peerInput).query(async ({ ctx, input }) => ({ peer: await peerInfo(ctx.svc, input), messages: await conversation(ctx.svc, input) })), reply: adminProcedure .input(z.intersection(peerInput, z.object({ text: z.string().min(1).max(4000) }))) .mutation(({ ctx, input }) => replyTo(ctx.svc, { ...input, adminUserId: ctx.admin.id })), }); /** Партнёры клуба: скидки и подарки по клубной карте */ const partnersRouter = router({ list: adminProcedure.query(({ ctx }) => ctx.db.partner.findMany({ orderBy: [{ active: 'desc' }, { name: 'asc' }], include: { offers: { orderBy: { sortOrder: 'asc' } }, members: { include: { user: { select: { firstName: true, lastName: true, username: true } } } }, _count: { select: { usages: { where: { offerId: null } } } }, }, }), ), /** одноразовая ссылка для собственника: открыл рабочий бот — стал собственником партнёра */ ownerInvite: adminProcedure.input(z.object({ partnerId: z.string() })).mutation(({ ctx, input }) => createOwnerInvite(ctx.svc, input.partnerId)), saveOffer: adminProcedure .input( z.object({ id: z.string().optional(), partnerId: z.string(), title: localized, minLevel: z.enum(['BAREV_PLUS', 'ELITE']).nullable(), limit: z.enum(['NONE', 'ONCE', 'WEEK', 'MONTH']), limitCount: z.number().int().min(1).max(100), active: z.boolean(), }), ) .mutation(({ ctx, input }) => savePartnerOffer(ctx.svc, input)), removeMember: adminProcedure.input(z.object({ memberId: z.string() })).mutation(({ ctx, input }) => ctx.db.partnerMember.delete({ where: { id: input.memberId } })), save: adminProcedure .input( z.object({ id: z.string().optional(), category: z.enum(['cafe', 'restaurant', 'shop', 'beauty', 'other']), name: z.string().min(1).max(100), offer: localized, address: z.string().max(200).optional().nullable(), active: z.boolean(), }), ) .mutation(({ ctx, input }) => { const { id, ...data } = input; return id ? ctx.db.partner.update({ where: { id }, data }) : ctx.db.partner.create({ data }); }), }); export const adminRouter = router({ webRequests: webRequestsRouter, leads: leadsRouter, requests: requestsRouter, concierge: conciergeRouter, partners: partnersRouter, auth: authRouter, orders: ordersRouter, services: servicesRouter, providers: providersRouter, presets: presetsRouter, companies: companiesRouter, agencies: agenciesRouter, clients: clientsRouter, dictionaries: dictionariesRouter, settings: settingsRouter, admins: adminsRouter, });