Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
573 lines
28 KiB
TypeScript
573 lines
28 KiB
TypeScript
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,
|
||
});
|