Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+572
View File
@@ -0,0 +1,572 @@
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,
});