Files
BarevClub/apps/api/src/routers/admin.ts
T

573 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
});