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:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import type { AdminUser, Db } from '@barev/db';
|
||||
import { env } from '../env.ts';
|
||||
|
||||
export const ADMIN_COOKIE = 'barev_admin';
|
||||
const SESSION_HOURS = 12;
|
||||
|
||||
/** В БД хранится только HMAC токена — утечка таблицы не даёт войти */
|
||||
const tokenHash = (token: string) => createHmac('sha256', env.ADMIN_SESSION_SECRET).update(token).digest('hex');
|
||||
|
||||
export async function createSession(db: Db, adminUserId: string) {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await db.adminSession.create({
|
||||
data: { adminUserId, tokenHash: tokenHash(token), expiresAt: new Date(Date.now() + SESSION_HOURS * 3600_000) },
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function findSession(db: Db, token: string | undefined) {
|
||||
if (!token) return null;
|
||||
const session = await db.adminSession.findUnique({ where: { tokenHash: tokenHash(token) }, include: { adminUser: true } });
|
||||
if (!session || session.expiresAt < new Date() || !session.adminUser.active) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function markVerified(db: Db, token: string) {
|
||||
await db.adminSession.update({ where: { tokenHash: tokenHash(token) }, data: { verified: true } });
|
||||
}
|
||||
|
||||
export async function destroySession(db: Db, token: string | undefined) {
|
||||
if (token) await db.adminSession.deleteMany({ where: { tokenHash: tokenHash(token) } });
|
||||
}
|
||||
|
||||
export const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict' as const,
|
||||
secure: env.isProd,
|
||||
path: '/',
|
||||
maxAge: SESSION_HOURS * 3600,
|
||||
};
|
||||
|
||||
/** Простой лимит попыток входа: 10 за 15 минут на email+ip */
|
||||
const attempts = new Map<string, { n: number; until: number }>();
|
||||
export function loginThrottled(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const a = attempts.get(key);
|
||||
if (!a || a.until < now) {
|
||||
attempts.set(key, { n: 1, until: now + 15 * 60_000 });
|
||||
return false;
|
||||
}
|
||||
a.n++;
|
||||
return a.n > 10;
|
||||
}
|
||||
|
||||
export function publicAdmin(u: AdminUser) {
|
||||
return { id: u.id, email: u.email, name: u.name, role: u.role, totpEnabled: u.totpEnabled };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Проверка подписи Telegram Mini App initData (раздел 6, критерий приёмки).
|
||||
* https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
*
|
||||
* secret = HMAC_SHA256(key = "WebAppData", data = bot_token)
|
||||
* hash = hex(HMAC_SHA256(key = secret, data = data_check_string))
|
||||
*/
|
||||
export interface TelegramInitUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
}
|
||||
|
||||
export type InitDataResult =
|
||||
| { ok: true; user: TelegramInitUser; authDate: Date; startParam?: string }
|
||||
| { ok: false; reason: 'MISSING' | 'BAD_HASH' | 'EXPIRED' | 'NO_USER' | 'NO_TOKEN' };
|
||||
|
||||
function dataCheckString(params: URLSearchParams): string {
|
||||
return [...params.entries()]
|
||||
.filter(([k]) => k !== 'hash')
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function sign(botToken: string, dcs: string): string {
|
||||
const secret = createHmac('sha256', 'WebAppData').update(botToken).digest();
|
||||
return createHmac('sha256', secret).update(dcs).digest('hex');
|
||||
}
|
||||
|
||||
export function validateInitData(raw: string | undefined, botToken: string, maxAgeSeconds: number, now = new Date()): InitDataResult {
|
||||
if (!botToken) return { ok: false, reason: 'NO_TOKEN' };
|
||||
if (!raw) return { ok: false, reason: 'MISSING' };
|
||||
const params = new URLSearchParams(raw);
|
||||
const hash = params.get('hash');
|
||||
if (!hash || !/^[0-9a-f]{64}$/.test(hash)) return { ok: false, reason: 'BAD_HASH' };
|
||||
const expected = sign(botToken, dataCheckString(params));
|
||||
if (!timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expected, 'hex'))) return { ok: false, reason: 'BAD_HASH' };
|
||||
const authDate = new Date(Number(params.get('auth_date')) * 1000);
|
||||
if (!Number.isFinite(authDate.getTime()) || now.getTime() - authDate.getTime() > maxAgeSeconds * 1000) {
|
||||
return { ok: false, reason: 'EXPIRED' };
|
||||
}
|
||||
try {
|
||||
const user = JSON.parse(params.get('user') ?? '') as TelegramInitUser;
|
||||
if (!user?.id) return { ok: false, reason: 'NO_USER' };
|
||||
return { ok: true, user, authDate, startParam: params.get('start_param') ?? undefined };
|
||||
} catch {
|
||||
return { ok: false, reason: 'NO_USER' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Подписать initData — для локальной разработки и тестов */
|
||||
export function signInitData(botToken: string, user: TelegramInitUser, opts: { authDate?: Date; startParam?: string } = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('auth_date', String(Math.floor((opts.authDate ?? new Date()).getTime() / 1000)));
|
||||
params.set('query_id', 'dev');
|
||||
params.set('user', JSON.stringify(user));
|
||||
if (opts.startParam) params.set('start_param', opts.startParam);
|
||||
params.set('hash', sign(botToken, dataCheckString(params)));
|
||||
return params.toString();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** TOTP (RFC 6238, SHA-1, 6 цифр, 30 с) — совместим с Google Authenticator, 1Password и т.п. */
|
||||
|
||||
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export function base32Encode(buf: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = '';
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += B32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(s: string): Buffer {
|
||||
const clean = s.replace(/=+$/, '').toUpperCase();
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
const idx = B32.indexOf(ch);
|
||||
if (idx < 0) throw new Error('bad base32');
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
export const generateTotpSecret = () => base32Encode(randomBytes(20));
|
||||
|
||||
export function totpCode(secret: string, time = Date.now(), step = 30): string {
|
||||
const counter = Buffer.alloc(8);
|
||||
counter.writeBigUInt64BE(BigInt(Math.floor(time / 1000 / step)));
|
||||
const h = createHmac('sha1', base32Decode(secret)).update(counter).digest();
|
||||
const offset = h[h.length - 1]! & 0xf;
|
||||
const bin = (h.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
||||
return String(bin).padStart(6, '0');
|
||||
}
|
||||
|
||||
/** Допускаем ±1 шаг на рассинхрон часов */
|
||||
export function verifyTotp(secret: string, code: string, time = Date.now()): boolean {
|
||||
if (!/^\d{6}$/.test(code)) return false;
|
||||
return [-1, 0, 1].some((w) => {
|
||||
const expected = totpCode(secret, time + w * 30_000);
|
||||
return timingSafeEqual(Buffer.from(expected), Buffer.from(code));
|
||||
});
|
||||
}
|
||||
|
||||
export function otpauthUri(secret: string, account: string, issuer = 'Barev Club Admin') {
|
||||
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import path from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
API_PORT: z.coerce.number().default(4000),
|
||||
DATABASE_URL: z.string(),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
CLIENT_BOT_TOKEN: z.string().default(''),
|
||||
PRO_BOT_TOKEN: z.string().default(''),
|
||||
DEV_CLIENT_BOT_TOKEN: z.string().default(''),
|
||||
DEV_PRO_BOT_TOKEN: z.string().default(''),
|
||||
CLIENT_BOT_USERNAME: z.string().default('barev_club_bot'),
|
||||
PRO_BOT_USERNAME: z.string().default('barev_pro_bot'),
|
||||
TG_INIT_DATA_MAX_AGE: z.coerce.number().default(86400),
|
||||
LANDING_URL: z.string().default('http://localhost:3000'),
|
||||
CLIENT_MINIAPP_URL: z.string().default('http://localhost:5174'),
|
||||
PRO_MINIAPP_URL: z.string().default('http://localhost:5175'),
|
||||
ADMIN_URL: z.string().default('http://localhost:5173'),
|
||||
ADMIN_SESSION_SECRET: z.string().min(16),
|
||||
S3_ENDPOINT: z.string().default('http://localhost:9000'),
|
||||
S3_REGION: z.string().default('us-east-1'),
|
||||
S3_BUCKET: z.string().default('barev-private'),
|
||||
S3_ACCESS_KEY: z.string().default(''),
|
||||
S3_SECRET_KEY: z.string().default(''),
|
||||
SENTRY_DSN: z.string().default(''),
|
||||
});
|
||||
|
||||
const parsed = schema.parse(process.env);
|
||||
|
||||
const isProd = parsed.NODE_ENV === 'production';
|
||||
if (isProd && (!parsed.CLIENT_BOT_TOKEN || !parsed.PRO_BOT_TOKEN)) {
|
||||
throw new Error('CLIENT_BOT_TOKEN и PRO_BOT_TOKEN обязательны в production');
|
||||
}
|
||||
|
||||
export const env = {
|
||||
...parsed,
|
||||
isProd,
|
||||
/**
|
||||
* Токены для проверки initData. Без настоящих токенов в разработке используются DEV_*,
|
||||
* чтобы Mini App можно было открыть в браузере (pnpm dev:initdata).
|
||||
*/
|
||||
clientBotToken: parsed.CLIENT_BOT_TOKEN || (isProd ? '' : parsed.DEV_CLIENT_BOT_TOKEN),
|
||||
proBotToken: parsed.PRO_BOT_TOKEN || (isProd ? '' : parsed.DEV_PRO_BOT_TOKEN),
|
||||
corsOrigins: [parsed.LANDING_URL, parsed.CLIENT_MINIAPP_URL, parsed.PRO_MINIAPP_URL, parsed.ADMIN_URL],
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { getDb } from '@barev/db';
|
||||
import type { Ctx } from '@barev/services';
|
||||
import { createQueueNotifier } from '@barev/services/queue';
|
||||
import { env } from './env.ts';
|
||||
|
||||
export const db = getDb();
|
||||
|
||||
// ───── события → воркер ─────
|
||||
|
||||
export const notifier = createQueueNotifier(env.REDIS_URL);
|
||||
|
||||
export const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
|
||||
|
||||
// ───── приватное объектное хранилище ─────
|
||||
|
||||
export const s3 = new S3Client({
|
||||
endpoint: env.S3_ENDPOINT,
|
||||
region: env.S3_REGION,
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY },
|
||||
});
|
||||
|
||||
export async function ensureBucket() {
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: env.S3_BUCKET }));
|
||||
} catch {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: env.S3_BUCKET }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Файлы шифруются на стороне хранилища (SSE); бакет приватный, доступ — только по временной ссылке */
|
||||
export async function putObject(prefix: string, body: Buffer, mime: string) {
|
||||
const ext = mime === 'image/png' ? 'png' : mime === 'image/webp' ? 'webp' : mime === 'application/pdf' ? 'pdf' : 'jpg';
|
||||
const key = `${prefix}/${randomUUID()}.${ext}`;
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: env.S3_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: mime,
|
||||
...(env.isProd ? { ServerSideEncryption: 'AES256' as const } : {}),
|
||||
}),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Временная ссылка на 5 минут */
|
||||
export function signedUrl(key: string) {
|
||||
return getSignedUrl(s3, new GetObjectCommand({ Bucket: env.S3_BUCKET, Key: key }), { expiresIn: 300 });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { adminRouter } from './routers/admin.ts';
|
||||
import { backofficeRouter } from './routers/backoffice/index.ts';
|
||||
import { clientRouter } from './routers/client.ts';
|
||||
import { proRouter } from './routers/pro.ts';
|
||||
import { publicRouter } from './routers/public.ts';
|
||||
import { router } from './trpc.ts';
|
||||
|
||||
export const appRouter = router({
|
||||
public: publicRouter,
|
||||
client: clientRouter,
|
||||
pro: proRouter,
|
||||
admin: adminRouter,
|
||||
bo: backofficeRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { ADMIN_COOKIE, cookieOptions, createSession, destroySession, markVerified } from '../../auth/admin.ts';
|
||||
import { env } from '../../env.ts';
|
||||
import { adminPreAuthProcedure, publicProcedure, router, staffProcedure, superProcedure } from '../../trpc.ts';
|
||||
|
||||
const LOGIN_TTL_MS = 5 * 60_000;
|
||||
const code = () => randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
|
||||
|
||||
export const STAFF_ROLES = ['SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES'] as const;
|
||||
|
||||
function staffView(a: { id: string; name: string; roles: string[]; telegramId: bigint | null; active: boolean; createdAt: Date; tgLinkToken?: string | null }) {
|
||||
return { id: a.id, name: a.name, roles: a.roles, telegramLinked: !!a.telegramId, active: a.active, createdAt: a.createdAt };
|
||||
}
|
||||
|
||||
async function openSession(ctx: { db: Parameters<typeof createSession>[0]; res: { setCookie: (n: string, v: string, o: typeof cookieOptions) => unknown } }, adminUserId: string) {
|
||||
const token = await createSession(ctx.db, adminUserId);
|
||||
await markVerified(ctx.db, token);
|
||||
ctx.res.setCookie(ADMIN_COOKIE, token, cookieOptions);
|
||||
}
|
||||
|
||||
export const boAuthRouter = router({
|
||||
/** Шаг 1: страница получает ссылку в рабочий бот */
|
||||
start: publicProcedure.mutation(async ({ ctx }) => {
|
||||
const req = await ctx.db.adminLoginRequest.create({
|
||||
data: { code: code(), pollKey: randomBytes(16).toString('hex'), expiresAt: new Date(Date.now() + LOGIN_TTL_MS) },
|
||||
});
|
||||
return { code: req.code, pollKey: req.pollKey, url: `https://t.me/${env.PRO_BOT_USERNAME}?start=lg-${req.code}`, expiresAt: req.expiresAt, devLogin: !env.isProd };
|
||||
}),
|
||||
|
||||
/** Шаг 2: страница опрашивает, подтвердил ли бот вход */
|
||||
poll: publicProcedure.input(z.object({ code: z.string().max(40), pollKey: z.string().max(64) })).mutation(async ({ ctx, input }) => {
|
||||
const req = await ctx.db.adminLoginRequest.findUnique({ where: { code: input.code } });
|
||||
if (!req || req.pollKey !== input.pollKey) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
if (req.consumedAt) return { status: 'expired' as const };
|
||||
if (!req.approvedAt || !req.adminUserId) return { status: req.expiresAt < new Date() ? ('expired' as const) : ('pending' as const) };
|
||||
await ctx.db.adminLoginRequest.update({ where: { id: req.id }, data: { consumedAt: new Date() } });
|
||||
await openSession(ctx, req.adminUserId);
|
||||
return { status: 'ok' as const };
|
||||
}),
|
||||
|
||||
/** Только локально: войти любым сотрудником без Telegram */
|
||||
devUsers: publicProcedure.query(async ({ ctx }) => {
|
||||
if (env.isProd) return [];
|
||||
const list = await ctx.db.adminUser.findMany({ where: { active: true, NOT: { roles: { isEmpty: true } } }, orderBy: { createdAt: 'asc' } });
|
||||
return list.map((a) => ({ id: a.id, name: a.name, roles: a.roles }));
|
||||
}),
|
||||
devLogin: publicProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
if (env.isProd) throw new TRPCError({ code: 'FORBIDDEN' });
|
||||
await openSession(ctx, input.id);
|
||||
}),
|
||||
|
||||
me: adminPreAuthProcedure.query(({ ctx }) => ({
|
||||
id: ctx.admin.id,
|
||||
name: ctx.admin.name,
|
||||
roles: ctx.admin.roles,
|
||||
verified: ctx.session.verified,
|
||||
telegramLinked: !!ctx.admin.telegramId,
|
||||
})),
|
||||
|
||||
logout: publicProcedure.mutation(async ({ ctx }) => {
|
||||
await destroySession(ctx.db, ctx.req.cookies[ADMIN_COOKIE]);
|
||||
ctx.res.clearCookie(ADMIN_COOKIE, { path: '/' });
|
||||
}),
|
||||
|
||||
/** Привязать свой Telegram (нужно для входа и уведомлений) */
|
||||
telegramLink: staffProcedure.mutation(async ({ ctx }) => {
|
||||
const token = code();
|
||||
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}` };
|
||||
}),
|
||||
});
|
||||
|
||||
/** Команда: суперадмин добавляет людей и раздаёт роли */
|
||||
export const boTeamRouter = router({
|
||||
list: superProcedure.query(async ({ ctx }) => (await ctx.db.adminUser.findMany({ orderBy: { createdAt: 'asc' } })).filter((a) => a.roles.length > 0 || a.active).map(staffView)),
|
||||
|
||||
/** Новый сотрудник: имя и роли; в ответе ссылка, по которой он привяжет свой Telegram */
|
||||
create: superProcedure.input(z.object({ name: z.string().trim().min(1).max(80), roles: z.array(z.enum(STAFF_ROLES)).min(1) })).mutation(async ({ ctx, input }) => {
|
||||
const token = code();
|
||||
const a = await ctx.db.adminUser.create({ data: { name: input.name, roles: input.roles, tgLinkToken: token } });
|
||||
return { ...staffView(a), linkUrl: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
|
||||
}),
|
||||
|
||||
update: superProcedure
|
||||
.input(z.object({ id: z.string(), name: z.string().trim().min(1).max(80).optional(), roles: z.array(z.enum(STAFF_ROLES)).optional(), active: z.boolean().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.id === ctx.admin.id && (input.active === false || (input.roles && !input.roles.includes('SUPERADMIN')))) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CANNOT_DEMOTE_SELF' });
|
||||
}
|
||||
const a = await ctx.db.adminUser.update({ where: { id: input.id }, data: { name: input.name, roles: input.roles, active: input.active } });
|
||||
if (input.active === false) await ctx.db.adminSession.deleteMany({ where: { adminUserId: a.id } });
|
||||
return staffView(a);
|
||||
}),
|
||||
|
||||
/** Новая ссылка для привязки Telegram (старая перестаёт работать) */
|
||||
linkUrl: superProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const token = code();
|
||||
await ctx.db.adminUser.update({ where: { id: input.id }, data: { tgLinkToken: token } });
|
||||
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=adm-${token}` };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { activeSubscription, DAY } from '@barev/core';
|
||||
import type { Db, Prisma } from '@barev/db';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { rolesProcedure, router, staffProcedure, superProcedure } from '../../trpc.ts';
|
||||
|
||||
const PLANS = ['BAREV_PLUS', 'ELITE'] as const;
|
||||
const PAY_METHODS = ['cash', 'transfer', 'card', 'link'] as const;
|
||||
|
||||
/** Фильтры списка клиентов — их же сохраняем как сегмент */
|
||||
export const clientFilters = z.object({
|
||||
search: z.string().trim().max(100).optional(),
|
||||
/** none — без активной подписки, any — с любой активной */
|
||||
plan: z.enum(['any', 'none', 'BAREV_PLUS', 'ELITE']).optional(),
|
||||
/** как получена текущая подписка */
|
||||
subSource: z.enum(['PAID', 'MANUAL', 'AGENCY_GIFT']).optional(),
|
||||
/** откуда пришёл: organic | deeplink | manual_code */
|
||||
source: z.string().max(20).optional(),
|
||||
agencyId: z.string().optional(),
|
||||
tag: z.string().max(40).optional(),
|
||||
/** подписка истекает в ближайшие N дней */
|
||||
expiringDays: z.number().int().min(1).max(90).optional(),
|
||||
segment: z.enum(['MOVING', 'LIVING']).optional(),
|
||||
/** не платили ни разу / платили */
|
||||
paying: z.enum(['yes', 'no']).optional(),
|
||||
});
|
||||
export type ClientFilters = z.infer<typeof clientFilters>;
|
||||
|
||||
const clientInclude = {
|
||||
user: true,
|
||||
agency: { select: { id: true, name: true, code: true } },
|
||||
subscriptions: { orderBy: { endsAt: 'desc' } },
|
||||
} satisfies Prisma.ClientInclude;
|
||||
type ClientRow = Prisma.ClientGetPayload<{ include: typeof clientInclude }>;
|
||||
|
||||
/** Деньги по клиентам: оплаченные заказы + подписки − возвраты */
|
||||
async function revenueByClient(db: Db, clientIds: string[]) {
|
||||
const [orders, subs] = await Promise.all([
|
||||
db.order.groupBy({ by: ['clientId'], where: { clientId: { in: clientIds }, paymentStatus: 'PAID' }, _sum: { totalPrice: true }, _count: true }),
|
||||
db.subscription.groupBy({ by: ['clientId'], where: { clientId: { in: clientIds } }, _sum: { amount: true, refundAmount: true } }),
|
||||
]);
|
||||
const out = new Map<string, { orders: number; subs: number; total: number; paidOrders: number }>();
|
||||
for (const id of clientIds) out.set(id, { orders: 0, subs: 0, total: 0, paidOrders: 0 });
|
||||
for (const o of orders) {
|
||||
const r = out.get(o.clientId)!;
|
||||
r.orders = o._sum.totalPrice ?? 0;
|
||||
r.paidOrders = o._count;
|
||||
}
|
||||
for (const s of subs) {
|
||||
const r = out.get(s.clientId)!;
|
||||
r.subs = (s._sum.amount ?? 0) - (s._sum.refundAmount ?? 0);
|
||||
}
|
||||
for (const r of out.values()) r.total = r.orders + r.subs;
|
||||
return out;
|
||||
}
|
||||
|
||||
function displayName(u: { firstName: string | null; lastName: string | null; username: string | null }) {
|
||||
return [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
|
||||
}
|
||||
|
||||
function utmSource(utm: unknown): string | null {
|
||||
if (utm && typeof utm === 'object' && 'utm_source' in utm) return String((utm as Record<string, unknown>).utm_source);
|
||||
if (utm && typeof utm === 'object' && 'source' in utm) return String((utm as Record<string, unknown>).source);
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentSub(c: ClientRow, now: Date) {
|
||||
return activeSubscription(c.subscriptions, now);
|
||||
}
|
||||
|
||||
function rowView(c: ClientRow, now: Date, money: { total: number; paidOrders: number } | undefined, showMoney: boolean) {
|
||||
const sub = currentSub(c, now);
|
||||
return {
|
||||
id: c.id,
|
||||
name: displayName(c.user),
|
||||
username: c.user.username,
|
||||
phone: c.user.phone,
|
||||
locale: c.user.locale,
|
||||
createdAt: c.createdAt,
|
||||
segment: c.segment,
|
||||
tags: c.tags,
|
||||
source: c.source,
|
||||
utmSource: utmSource(c.utm),
|
||||
agency: c.agency,
|
||||
sub: sub ? { id: sub.id, plan: sub.plan, source: sub.source, endsAt: sub.endsAt } : null,
|
||||
revenue: showMoney ? (money?.total ?? 0) : null,
|
||||
paying: (money?.paidOrders ?? 0) > 0 || c.subscriptions.some((s) => s.source === 'PAID'),
|
||||
};
|
||||
}
|
||||
|
||||
/** where для того, что фильтруется в базе; остальное — после загрузки */
|
||||
function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
|
||||
const and: Prisma.ClientWhereInput[] = [];
|
||||
if (f.search) {
|
||||
const s = f.search.replace(/^@/, '');
|
||||
and.push({
|
||||
OR: [
|
||||
{ user: { firstName: { contains: s, mode: 'insensitive' } } },
|
||||
{ user: { lastName: { contains: s, mode: 'insensitive' } } },
|
||||
{ user: { username: { contains: s, mode: 'insensitive' } } },
|
||||
{ user: { phone: { contains: s } } },
|
||||
{ cardCode: { equals: s.toUpperCase() } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (f.source) and.push({ source: f.source });
|
||||
if (f.agencyId) and.push({ agencyId: f.agencyId });
|
||||
if (f.tag) and.push({ tags: { has: f.tag } });
|
||||
if (f.segment) and.push({ segment: f.segment });
|
||||
return and.length ? { AND: and } : {};
|
||||
}
|
||||
|
||||
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
|
||||
const rows = await db.client.findMany({ where: baseWhere(f), include: clientInclude, orderBy: { createdAt: 'desc' }, take: 5000 });
|
||||
const money = await revenueByClient(
|
||||
db,
|
||||
rows.map((r) => r.id),
|
||||
);
|
||||
const list = rows.filter((c) => {
|
||||
const sub = currentSub(c, now);
|
||||
if (f.plan === 'any' && !sub) return false;
|
||||
if (f.plan === 'none' && sub) return false;
|
||||
if ((f.plan === 'BAREV_PLUS' || f.plan === 'ELITE') && sub?.plan !== f.plan) return false;
|
||||
if (f.subSource && sub?.source !== f.subSource) return false;
|
||||
if (f.expiringDays && !(sub && sub.endsAt.getTime() - now.getTime() <= f.expiringDays * DAY)) return false;
|
||||
const m = money.get(c.id)!;
|
||||
const paying = m.paidOrders > 0 || c.subscriptions.some((s) => s.source === 'PAID');
|
||||
if (f.paying === 'yes' && !paying) return false;
|
||||
if (f.paying === 'no' && paying) return false;
|
||||
return true;
|
||||
});
|
||||
return { list, money };
|
||||
}
|
||||
|
||||
const csvCell = (v: unknown) => {
|
||||
const s = v === null || v === undefined ? '' : v instanceof Date ? v.toISOString().slice(0, 10) : String(v);
|
||||
return /[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
|
||||
/** Новая подписка того же тарифа начинается после окончания текущей */
|
||||
async function nextStart(db: Db, clientId: string, plan: (typeof PLANS)[number], now: Date) {
|
||||
const current = await db.subscription.findFirst({
|
||||
where: { clientId, plan, status: 'ACTIVE', endsAt: { gt: now } },
|
||||
orderBy: { endsAt: 'desc' },
|
||||
});
|
||||
return current?.endsAt ?? now;
|
||||
}
|
||||
|
||||
async function subOf(db: Db, clientId: string, subscriptionId: string) {
|
||||
const s = await db.subscription.findUnique({ where: { id: subscriptionId } });
|
||||
if (!s || s.clientId !== clientId) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Подписки: подарить/продать/продлить/отменить/сменить тариф — суперадмин и поддержка; возврат — только суперадмин */
|
||||
const subsProcedure = rolesProcedure('SUPPORT');
|
||||
|
||||
export const boClientsRouter = router({
|
||||
list: staffProcedure
|
||||
.input(clientFilters.extend({ sort: z.enum(['new', 'revenue', 'ends']).default('new'), page: z.number().int().min(0).default(0) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const { list, money } = await filteredClients(ctx.db, input, now);
|
||||
const showMoney = ctx.isSuper;
|
||||
let views = list.map((c) => rowView(c, now, money.get(c.id), showMoney));
|
||||
if (input.sort === 'revenue' && showMoney) views = views.sort((a, b) => (b.revenue ?? 0) - (a.revenue ?? 0));
|
||||
if (input.sort === 'ends') views = views.sort((a, b) => (a.sub?.endsAt.getTime() ?? Infinity) - (b.sub?.endsAt.getTime() ?? Infinity));
|
||||
const PAGE = 50;
|
||||
return {
|
||||
total: views.length,
|
||||
totalRevenue: showMoney ? views.reduce((s, v) => s + (v.revenue ?? 0), 0) : null,
|
||||
items: views.slice(input.page * PAGE, (input.page + 1) * PAGE),
|
||||
pageSize: PAGE,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Справочники для фильтров */
|
||||
facets: staffProcedure.query(async ({ ctx }) => {
|
||||
const [agencies, tagsRaw, sources] = await Promise.all([
|
||||
ctx.db.agency.findMany({ select: { id: true, name: true }, orderBy: { name: 'asc' } }),
|
||||
ctx.db.$queryRaw<{ tag: string; n: bigint }[]>`SELECT unnest(tags) AS tag, count(*) AS n FROM "Client" GROUP BY 1 ORDER BY 2 DESC, 1`,
|
||||
ctx.db.client.groupBy({ by: ['source'], _count: true }),
|
||||
]);
|
||||
return { agencies, tags: tagsRaw.map((t) => ({ tag: t.tag, count: Number(t.n) })), sources: sources.map((s) => s.source) };
|
||||
}),
|
||||
|
||||
/** CSV для Excel: разделитель «;», BOM — чтобы кириллица открылась без танцев */
|
||||
exportCsv: staffProcedure.input(clientFilters).mutation(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const { list, money } = await filteredClients(ctx.db, input, now);
|
||||
const head = ['Имя', 'Username', 'Телефон', 'Язык', 'Создан', 'Источник', 'Агентство', 'UTM', 'Тариф', 'Как получена', 'До', 'Теги'];
|
||||
if (ctx.isSuper) head.push('Принёс, AMD');
|
||||
const lines = list.map((c) => {
|
||||
const v = rowView(c, now, money.get(c.id), ctx.isSuper);
|
||||
const cells: unknown[] = [v.name, v.username, v.phone, v.locale, v.createdAt, v.source, v.agency?.name, v.utmSource, v.sub?.plan, v.sub?.source, v.sub?.endsAt, v.tags.join(', ')];
|
||||
if (ctx.isSuper) cells.push(v.revenue);
|
||||
return cells.map(csvCell).join(';');
|
||||
});
|
||||
return { filename: `barev-clients-${now.toISOString().slice(0, 10)}.csv`, content: '' + [head.join(';'), ...lines].join('\r\n') };
|
||||
}),
|
||||
|
||||
get: staffProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const c = await ctx.db.client.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
...clientInclude,
|
||||
orders: { orderBy: { createdAt: 'desc' }, take: 100, include: { service: { select: { code: true } }, provider: { select: { name: true } }, rating: { select: { score: true } } } },
|
||||
_count: { select: { cardUsages: true, leads: true, requests: true, concierge: true } },
|
||||
},
|
||||
});
|
||||
if (!c) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
const money = (await revenueByClient(ctx.db, [c.id])).get(c.id)!;
|
||||
const adminIds = [...new Set(c.subscriptions.map((s) => s.grantedByAdminId).filter((x): x is string => !!x))];
|
||||
const agencyIds = [...new Set(c.subscriptions.map((s) => s.agencyId).filter((x): x is string => !!x))];
|
||||
const [admins, agencies, history] = await Promise.all([
|
||||
ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } }),
|
||||
ctx.db.agency.findMany({ where: { id: { in: agencyIds } }, select: { id: true, name: true } }),
|
||||
ctx.db.auditLog.findMany({ where: { entityId: c.id }, orderBy: { createdAt: 'desc' }, take: 50, include: { adminUser: { select: { name: true } } } }),
|
||||
]);
|
||||
const adminName = new Map(admins.map((a) => [a.id, a.name]));
|
||||
const agencyName = new Map(agencies.map((a) => [a.id, a.name]));
|
||||
const showMoney = ctx.isSuper;
|
||||
return {
|
||||
...rowView(c, now, money, showMoney),
|
||||
cardCode: c.cardCode,
|
||||
adminNote: c.adminNote,
|
||||
utm: c.utm,
|
||||
agencyAttachedAt: c.agencyAttachedAt,
|
||||
consentAt: c.consentAt,
|
||||
money: showMoney ? money : null,
|
||||
counts: c._count,
|
||||
subscriptions: c.subscriptions.map((s) => ({
|
||||
id: s.id,
|
||||
plan: s.plan,
|
||||
source: s.source,
|
||||
status: s.status,
|
||||
startsAt: s.startsAt,
|
||||
endsAt: s.endsAt,
|
||||
amount: showMoney ? s.amount : null,
|
||||
payMethod: s.payMethod,
|
||||
note: s.note,
|
||||
grantedBy: s.grantedByAdminId ? (adminName.get(s.grantedByAdminId) ?? '—') : null,
|
||||
agency: s.agencyId ? (agencyName.get(s.agencyId) ?? null) : s.source === 'AGENCY_GIFT' ? (c.agency?.name ?? null) : null,
|
||||
cancelledAt: s.cancelledAt,
|
||||
refundAmount: showMoney ? s.refundAmount : null,
|
||||
refundedAt: s.refundedAt,
|
||||
createdAt: s.createdAt,
|
||||
active: s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now,
|
||||
})),
|
||||
orders: c.orders.map((o) => ({
|
||||
id: o.id,
|
||||
number: o.number,
|
||||
service: o.service.code,
|
||||
scheduledAt: o.scheduledAt,
|
||||
status: o.status,
|
||||
paymentStatus: o.paymentStatus,
|
||||
total: showMoney ? o.totalPrice : null,
|
||||
provider: o.provider?.name ?? null,
|
||||
rating: o.rating?.score ?? null,
|
||||
})),
|
||||
history: history.map((h) => ({ id: h.id, action: h.action, by: h.adminUser?.name ?? null, at: h.createdAt, data: h.data })),
|
||||
};
|
||||
}),
|
||||
|
||||
setTags: staffProcedure.input(z.object({ id: z.string(), tags: z.array(z.string().trim().min(1).max(40)).max(20) })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.client.update({ where: { id: input.id }, data: { tags: [...new Set(input.tags)] } });
|
||||
}),
|
||||
|
||||
setNote: staffProcedure.input(z.object({ id: z.string(), note: z.string().max(4000) })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.client.update({ where: { id: input.id }, data: { adminNote: input.note || null } });
|
||||
}),
|
||||
|
||||
// ── подписки (id — клиент, чтобы журнал собирался в карточке) ──
|
||||
|
||||
gift: subsProcedure
|
||||
.input(z.object({ id: z.string(), plan: z.enum(PLANS), days: z.number().int().min(1).max(3660), note: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const now = ctx.svc.now();
|
||||
const startsAt = await nextStart(ctx.db, input.id, input.plan, now);
|
||||
const sub = await ctx.db.subscription.create({
|
||||
data: { clientId: input.id, plan: input.plan, source: 'MANUAL', startsAt, endsAt: new Date(startsAt.getTime() + input.days * DAY), grantedByAdminId: ctx.admin.id, note: input.note },
|
||||
});
|
||||
await ctx.svc.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
|
||||
return { subscriptionId: sub.id };
|
||||
}),
|
||||
|
||||
/** Продажа: ручная отметка оплаты. Ссылка на оплату — когда подключим платёжку. */
|
||||
sell: subsProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
plan: z.enum(PLANS),
|
||||
days: z.number().int().min(1).max(3660),
|
||||
amount: z.number().int().min(0).max(100_000_000),
|
||||
payMethod: z.enum(PAY_METHODS),
|
||||
note: z.string().max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.payMethod === 'link') throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'PAYMENTS_NOT_CONFIGURED' });
|
||||
const now = ctx.svc.now();
|
||||
const startsAt = await nextStart(ctx.db, input.id, input.plan, now);
|
||||
const sub = await ctx.db.subscription.create({
|
||||
data: {
|
||||
clientId: input.id,
|
||||
plan: input.plan,
|
||||
source: 'PAID',
|
||||
startsAt,
|
||||
endsAt: new Date(startsAt.getTime() + input.days * DAY),
|
||||
amount: input.amount,
|
||||
payMethod: input.payMethod,
|
||||
grantedByAdminId: ctx.admin.id,
|
||||
note: input.note,
|
||||
},
|
||||
});
|
||||
await ctx.svc.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
|
||||
return { subscriptionId: sub.id };
|
||||
}),
|
||||
|
||||
extend: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), days: z.number().int().min(-3660).max(3660) })).mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
const endsAt = new Date(s.endsAt.getTime() + input.days * DAY);
|
||||
if (endsAt <= s.startsAt) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ENDS_BEFORE_START' });
|
||||
await ctx.db.subscription.update({ where: { id: s.id }, data: { endsAt, status: endsAt > ctx.svc.now() && s.status === 'EXPIRED' ? 'ACTIVE' : s.status } });
|
||||
}),
|
||||
|
||||
cancel: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), reason: z.string().max(500).optional() })).mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
await ctx.db.subscription.update({
|
||||
where: { id: s.id },
|
||||
data: { status: 'CANCELLED', cancelledAt: ctx.svc.now(), note: input.reason ? [s.note, `Отмена: ${input.reason}`].filter(Boolean).join('\n') : s.note },
|
||||
});
|
||||
}),
|
||||
|
||||
changePlan: subsProcedure.input(z.object({ id: z.string(), subscriptionId: z.string(), plan: z.enum(PLANS) })).mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
await ctx.db.subscription.update({ where: { id: s.id }, data: { plan: input.plan } });
|
||||
}),
|
||||
|
||||
refund: superProcedure
|
||||
.input(z.object({ id: z.string(), subscriptionId: z.string(), amount: z.number().int().min(1), cancel: z.boolean().default(true) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const s = await subOf(ctx.db, input.id, input.subscriptionId);
|
||||
const refunded = (s.refundAmount ?? 0) + input.amount;
|
||||
if (s.amount === null || refunded > s.amount) throw new TRPCError({ code: 'BAD_REQUEST', message: 'REFUND_TOO_BIG' });
|
||||
const now = ctx.svc.now();
|
||||
await ctx.db.subscription.update({
|
||||
where: { id: s.id },
|
||||
data: { refundAmount: refunded, refundedAt: now, ...(input.cancel ? { status: 'CANCELLED', cancelledAt: now } : {}) },
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
/** Сохранённые выборки — у каждого сотрудника свои */
|
||||
export const boSegmentsRouter = router({
|
||||
list: staffProcedure.query(({ ctx }) => ctx.db.savedSegment.findMany({ where: { adminUserId: ctx.admin.id }, orderBy: { createdAt: 'asc' } })),
|
||||
save: staffProcedure.input(z.object({ name: z.string().trim().min(1).max(60), filters: clientFilters })).mutation(({ ctx, input }) =>
|
||||
ctx.db.savedSegment.create({ data: { name: input.name, filters: input.filters, adminUserId: ctx.admin.id } }),
|
||||
),
|
||||
remove: staffProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.savedSegment.deleteMany({ where: { id: input.id, adminUserId: ctx.admin.id } });
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { DAY, startOfYerevanDay } from '@barev/core';
|
||||
import { unreadConciergeCount } from '@barev/services';
|
||||
import { router, staffProcedure } from '../../trpc.ts';
|
||||
|
||||
/** День по Еревану (UTC+4, без перехода на летнее время) */
|
||||
const dayKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10);
|
||||
|
||||
function name(u: { firstName: string | null; lastName: string | null; username: string | null } | null | undefined) {
|
||||
if (!u) return '—';
|
||||
return [u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
|
||||
}
|
||||
|
||||
export const boDashboardRouter = router({
|
||||
/** Что требует внимания — видят все роли */
|
||||
attention: staffProcedure.query(async ({ ctx }) => {
|
||||
const now = ctx.svc.now();
|
||||
const [unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring] = await Promise.all([
|
||||
ctx.db.order.count({ where: { providerId: null, companyId: null, status: { in: ['NEW', 'ENROLLED'] }, offers: { none: { status: 'OFFERED' } } } }),
|
||||
unreadConciergeCount(ctx.svc),
|
||||
ctx.db.webRequest.count({ where: { status: { in: ['NEW', 'LINKED'] } } }),
|
||||
ctx.db.lead.count({ where: { status: 'NEW' } }),
|
||||
ctx.db.clientRequest.count({ where: { status: 'NEW' } }),
|
||||
ctx.db.rating.count({ where: { score: { lte: 3 }, createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
|
||||
ctx.db.subscription.count({ where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } } }),
|
||||
]);
|
||||
return { unassigned, unreadChats, webRequests, leads, appRequests, lowRatings, expiring };
|
||||
}),
|
||||
|
||||
/** Полная сводка — деньги и команда видит только суперадмин */
|
||||
overview: staffProcedure.query(async ({ ctx }) => {
|
||||
const now = ctx.svc.now();
|
||||
const today = startOfYerevanDay(now);
|
||||
const from60 = new Date(today.getTime() - 59 * DAY);
|
||||
const from30 = new Date(today.getTime() - 29 * DAY);
|
||||
|
||||
const [paidOrders, soldSubs, refunds, activeSubs, expiringSubs, newClients, leads30, giftClients, paidSubClients, cardUsages, providers, recentSubs, recentOrders, recentAudit] =
|
||||
await Promise.all([
|
||||
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { gte: from60 } }, select: { paidAt: true, totalPrice: true } }),
|
||||
ctx.db.subscription.findMany({ where: { amount: { not: null }, createdAt: { gte: from60 } }, select: { createdAt: true, amount: true } }),
|
||||
ctx.db.subscription.findMany({ where: { refundedAt: { gte: from60 } }, select: { refundedAt: true, refundAmount: true } }),
|
||||
ctx.db.subscription.findMany({ where: { status: 'ACTIVE', startsAt: { lte: now }, endsAt: { gt: now } }, select: { clientId: true, plan: true, source: true } }),
|
||||
ctx.db.subscription.findMany({
|
||||
where: { status: 'ACTIVE', endsAt: { gt: now, lte: new Date(now.getTime() + 7 * DAY) } },
|
||||
orderBy: { endsAt: 'asc' },
|
||||
take: 6,
|
||||
include: { client: { include: { user: true } } },
|
||||
}),
|
||||
ctx.db.client.findMany({ where: { createdAt: { gte: from60 } }, select: { createdAt: true, source: true, utm: true, agency: { select: { name: true } } } }),
|
||||
ctx.db.lead.count({ where: { createdAt: { gte: from30 } } }),
|
||||
ctx.db.subscription.findMany({ where: { source: { in: ['MANUAL', 'AGENCY_GIFT'] } }, select: { clientId: true }, distinct: ['clientId'] }),
|
||||
ctx.db.subscription.findMany({ where: { source: 'PAID' }, select: { clientId: true }, distinct: ['clientId'] }),
|
||||
ctx.db.cardUsage.groupBy({ by: ['partnerId'], where: { createdAt: { gte: from30 }, partnerId: { not: null } }, _count: true, orderBy: { _count: { partnerId: 'desc' } }, take: 5 }),
|
||||
ctx.db.provider.findMany({ where: { active: true, ratingCount: { gt: 0 } }, select: { id: true, name: true, ratingSum: true, ratingCount: true, role: true } }),
|
||||
ctx.db.subscription.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { client: { include: { user: true } } } }),
|
||||
ctx.db.order.findMany({ where: { paymentStatus: 'PAID', paidAt: { not: null } }, orderBy: { paidAt: 'desc' }, take: 8, include: { client: { include: { user: true } }, service: { select: { code: true } } } }),
|
||||
ctx.db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 8, include: { adminUser: { select: { name: true } } } }),
|
||||
]);
|
||||
|
||||
// ── деньги по дням ──
|
||||
const days: string[] = [];
|
||||
for (let i = 59; i >= 0; i--) days.push(dayKey(new Date(today.getTime() - i * DAY + 12 * 3600_000)));
|
||||
const byDay = new Map(days.map((d) => [d, { orders: 0, subs: 0 }]));
|
||||
for (const o of paidOrders) byDay.get(dayKey(o.paidAt!))!.orders += o.totalPrice;
|
||||
for (const s of soldSubs) {
|
||||
const b = byDay.get(dayKey(s.createdAt));
|
||||
if (b) b.subs += s.amount ?? 0;
|
||||
}
|
||||
for (const r of refunds) {
|
||||
const b = byDay.get(dayKey(r.refundedAt!));
|
||||
if (b) b.subs -= r.refundAmount ?? 0;
|
||||
}
|
||||
const series = days.map((d) => ({ day: d, ...byDay.get(d)! }));
|
||||
const sum = (from: number, to: number) => series.slice(from, to).reduce((a, x) => ({ orders: a.orders + x.orders, subs: a.subs + x.subs }), { orders: 0, subs: 0 });
|
||||
const money = {
|
||||
today: sum(59, 60),
|
||||
yesterday: sum(58, 59),
|
||||
week: sum(53, 60),
|
||||
prevWeek: sum(46, 53),
|
||||
month: sum(30, 60),
|
||||
prevMonth: sum(0, 30),
|
||||
series: series.slice(30),
|
||||
};
|
||||
|
||||
// ── подписки ──
|
||||
const plus = new Set(activeSubs.filter((s) => s.plan === 'BAREV_PLUS').map((s) => s.clientId));
|
||||
const elite = new Set(activeSubs.filter((s) => s.plan === 'ELITE').map((s) => s.clientId));
|
||||
for (const id of elite) plus.delete(id);
|
||||
const paidSet = new Set(paidSubClients.map((s) => s.clientId));
|
||||
const giftConverted = giftClients.filter((g) => paidSet.has(g.clientId)).length;
|
||||
|
||||
// ── рост ──
|
||||
const clients30 = newClients.filter((c) => c.createdAt >= from30);
|
||||
const sources = new Map<string, number>();
|
||||
for (const c of clients30) {
|
||||
const utm = c.utm && typeof c.utm === 'object' ? ((c.utm as Record<string, unknown>).utm_source ?? (c.utm as Record<string, unknown>).source) : null;
|
||||
const key = c.agency ? `agency:${c.agency.name}` : utm ? `utm:${String(utm)}` : c.source;
|
||||
sources.set(key, (sources.get(key) ?? 0) + 1);
|
||||
}
|
||||
const payingClients30 = await ctx.db.client.count({
|
||||
where: {
|
||||
createdAt: { gte: from30 },
|
||||
OR: [{ subscriptions: { some: { source: 'PAID' } } }, { orders: { some: { paymentStatus: 'PAID' } } }],
|
||||
},
|
||||
});
|
||||
|
||||
// ── команда ──
|
||||
const partnerNames = new Map(
|
||||
(await ctx.db.partner.findMany({ where: { id: { in: cardUsages.map((c) => c.partnerId!) } }, select: { id: true, name: true } })).map((p) => [p.id, p.name]),
|
||||
);
|
||||
const rated = providers.map((p) => ({ id: p.id, name: p.name, role: p.role, avg: p.ratingSum / p.ratingCount, count: p.ratingCount })).sort((a, b) => b.avg - a.avg);
|
||||
|
||||
// ── лента ──
|
||||
const feed = [
|
||||
...recentSubs.map((s) => ({
|
||||
kind: 'subscription' as const,
|
||||
at: s.createdAt,
|
||||
clientId: s.clientId,
|
||||
who: name(s.client.user),
|
||||
plan: s.plan,
|
||||
source: s.source,
|
||||
amount: ctx.isSuper ? s.amount : null,
|
||||
})),
|
||||
...recentOrders.map((o) => ({ kind: 'payment' as const, at: o.paidAt!, clientId: o.clientId, who: name(o.client.user), service: o.service.code, number: o.number, amount: ctx.isSuper ? o.totalPrice : null })),
|
||||
...recentAudit.map((a) => ({ kind: 'action' as const, at: a.createdAt, by: a.adminUser?.name ?? null, action: a.action, entityId: a.entityId })),
|
||||
]
|
||||
.sort((a, b) => b.at.getTime() - a.at.getTime())
|
||||
.slice(0, 12);
|
||||
|
||||
return {
|
||||
money: ctx.isSuper ? money : null,
|
||||
subs: {
|
||||
plus: plus.size,
|
||||
elite: elite.size,
|
||||
gifts: activeSubs.filter((s) => s.source !== 'PAID').length,
|
||||
expiring: expiringSubs.map((s) => ({ clientId: s.clientId, name: name(s.client.user), plan: s.plan, endsAt: s.endsAt, source: s.source })),
|
||||
giftClients: giftClients.length,
|
||||
giftConverted,
|
||||
},
|
||||
growth: {
|
||||
today: newClients.filter((c) => c.createdAt >= today).length,
|
||||
week: newClients.filter((c) => c.createdAt >= new Date(today.getTime() - 6 * DAY)).length,
|
||||
month: clients30.length,
|
||||
prevMonth: newClients.length - clients30.length,
|
||||
sources: [...sources.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count),
|
||||
funnel: { leads: leads30, clients: clients30.length, paying: payingClients30 },
|
||||
},
|
||||
team: ctx.isSuper
|
||||
? {
|
||||
topProviders: rated.slice(0, 3),
|
||||
bottomProviders: rated.length > 3 ? rated.slice(-2).reverse() : [],
|
||||
topPartners: cardUsages.map((c) => ({ id: c.partnerId!, name: partnerNames.get(c.partnerId!) ?? '—', scans: c._count })),
|
||||
}
|
||||
: null,
|
||||
feed,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { router } from '../../trpc.ts';
|
||||
import { boAuthRouter, boTeamRouter } from './auth.ts';
|
||||
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
|
||||
import { boDashboardRouter } from './dashboard.ts';
|
||||
|
||||
/** API новой админки (apps/backoffice) */
|
||||
export const backofficeRouter = router({
|
||||
auth: boAuthRouter,
|
||||
team: boTeamRouter,
|
||||
dashboard: boDashboardRouter,
|
||||
clients: boClientsRouter,
|
||||
segments: boSegmentsRouter,
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { defaultParams, discountFor, LOCALES } from '@barev/core';
|
||||
import {
|
||||
applyAgencyCode,
|
||||
availableSlots,
|
||||
cancelByClient,
|
||||
cancelPreview,
|
||||
clientOrder,
|
||||
createOrder,
|
||||
getMembership,
|
||||
getServiceByCode,
|
||||
getSettings,
|
||||
giveConsent,
|
||||
listCatalog,
|
||||
listClientOrders,
|
||||
quoteForClient,
|
||||
repeatDraft,
|
||||
rescheduleByClient,
|
||||
setLocale,
|
||||
clientRelocation,
|
||||
createAppLead,
|
||||
createClientRequest,
|
||||
LEAD_MOVE_WHEN,
|
||||
LEAD_PACKAGES,
|
||||
openClientRequests,
|
||||
relocationNames,
|
||||
setSegment,
|
||||
toggleRelocationDoc,
|
||||
cardQrToken,
|
||||
clientPartners,
|
||||
} from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../env.ts';
|
||||
import { signedUrl } from '../infra.ts';
|
||||
import { clientProcedure, consentedClientProcedure, router } from '../trpc.ts';
|
||||
|
||||
const paramsInput = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))]));
|
||||
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
export const clientRouter = router({
|
||||
me: clientProcedure.query(async ({ ctx }) => {
|
||||
const [membership, agency, settings] = await Promise.all([
|
||||
getMembership(ctx.db, ctx.client.id, ctx.svc.now()),
|
||||
ctx.client.agencyId ? ctx.db.agency.findUnique({ where: { id: ctx.client.agencyId } }) : null,
|
||||
getSettings(ctx.db),
|
||||
]);
|
||||
return {
|
||||
firstName: ctx.client.user.firstName,
|
||||
locale: ctx.client.user.locale,
|
||||
consented: !!ctx.client.consentAt,
|
||||
membership,
|
||||
agencyName: agency?.name ?? null,
|
||||
giftUsed: !!ctx.client.giftUsedAt,
|
||||
cardCode: ctx.client.cardCode,
|
||||
barevPlusPrice: settings['barevPlus.monthlyPrice'],
|
||||
freeCancelHours: settings['cancel.freeHours'],
|
||||
/** сценарий приложения: MOVING — переезжает, LIVING — уже живёт; null — спросить */
|
||||
segment: ctx.client.segment,
|
||||
plans: {
|
||||
plusPrice: settings['barevPlus.monthlyPrice'],
|
||||
plusMaxPct: settings['barevPlus.maxDiscountPct'],
|
||||
elitePrice: settings['elite.monthlyPrice'],
|
||||
eliteMaxPct: settings['elite.maxDiscountPct'],
|
||||
consultation: settings['relocation.consultation'],
|
||||
},
|
||||
/** чат с консьержем — переписка в боте */
|
||||
conciergeUrl: `https://t.me/${env.CLIENT_BOT_USERNAME}`,
|
||||
};
|
||||
}),
|
||||
|
||||
setLocale: clientProcedure.input(z.object({ locale: z.enum(LOCALES) })).mutation(async ({ ctx, input }) => {
|
||||
await setLocale(ctx.db, ctx.telegramId, input.locale);
|
||||
}),
|
||||
|
||||
/** ONB-3 в Mini App — если клиент открыл приложение, минуя онбординг в боте */
|
||||
consent: clientProcedure.mutation(async ({ ctx }) => giveConsent(ctx.svc, ctx.client.id)),
|
||||
|
||||
applyAgencyCode: consentedClientProcedure
|
||||
.input(z.object({ code: z.string().min(2).max(32) }))
|
||||
.mutation(({ ctx, input }) => applyAgencyCode(ctx.svc, ctx.client.id, input.code)),
|
||||
|
||||
catalog: clientProcedure.query(async ({ ctx }) => {
|
||||
const [categories, membership] = await Promise.all([listCatalog(ctx.db), getMembership(ctx.db, ctx.client.id, ctx.svc.now())]);
|
||||
return { categories, level: membership.level };
|
||||
}),
|
||||
|
||||
setSegment: clientProcedure.input(z.object({ segment: z.enum(['MOVING', 'LIVING']) })).mutation(async ({ ctx, input }) => {
|
||||
await setSegment(ctx.svc, ctx.client.id, input.segment);
|
||||
}),
|
||||
|
||||
/** План переезда: шаги со статусами от оператора и чек-лист документов */
|
||||
relocation: router({
|
||||
/** names — названия шагов и документов из общего списка по ключу «STEP:код» / «DOC:код» */
|
||||
get: clientProcedure.query(async ({ ctx }) => {
|
||||
const [lead, names] = await Promise.all([clientRelocation(ctx.svc, ctx.client.id), relocationNames(ctx.db)]);
|
||||
return { lead, names };
|
||||
}),
|
||||
create: consentedClientProcedure
|
||||
.input(
|
||||
z.object({
|
||||
moveWhen: z.enum(LEAD_MOVE_WHEN),
|
||||
moveDate: z.iso.date().transform((v) => new Date(`${v}T00:00:00Z`)).optional(),
|
||||
package: z.enum(LEAD_PACKAGES),
|
||||
options: z
|
||||
.object({
|
||||
airport: z.enum(['none', 'standard', 'premium']).optional(),
|
||||
stay: z.enum(['none', 'flat', 'hotel']).optional(),
|
||||
stayBudgetFrom: z.number().int().min(0).max(10_000_000).optional(),
|
||||
stayBudgetTo: z.number().int().min(0).max(10_000_000).optional(),
|
||||
flat: z.boolean().optional(),
|
||||
socialCard: z.boolean().optional(),
|
||||
bankCard: z.boolean().optional(),
|
||||
registration: z.boolean().optional(),
|
||||
residence: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
comment: z.string().max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => createAppLead(ctx.svc, ctx.client.id, input)),
|
||||
toggleDoc: clientProcedure
|
||||
.input(z.object({ docId: z.string().max(40), checked: z.boolean() }))
|
||||
.mutation(({ ctx, input }) => toggleRelocationDoc(ctx.svc, ctx.client.id, input.docId, input.checked)),
|
||||
}),
|
||||
|
||||
/** Заявки оператору: подписка Barev+/Elite или «перезвоните» по услуге без онлайн-записи */
|
||||
requests: router({
|
||||
open: clientProcedure.query(({ ctx }) => openClientRequests(ctx.svc, ctx.client.id)),
|
||||
create: consentedClientProcedure
|
||||
.input(
|
||||
z.object({
|
||||
kind: z.enum(['SUBSCRIPTION', 'SERVICE']),
|
||||
plan: z.enum(['BAREV_PLUS', 'ELITE']).optional(),
|
||||
serviceCode: z.string().max(40).optional(),
|
||||
comment: z.string().max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ ctx, input }) => createClientRequest(ctx.svc, ctx.client.id, input)),
|
||||
}),
|
||||
|
||||
/** Партнёры клуба: скидки и подарки по клубной карте */
|
||||
partners: clientProcedure.query(({ ctx }) => clientPartners(ctx.svc, ctx.client.id)),
|
||||
/** Живой QR клубной карты: ссылка на рабочий бот, меняется каждую минуту */
|
||||
cardQr: consentedClientProcedure.query(({ ctx }) => {
|
||||
const { token, expiresAt } = cardQrToken(ctx.client.cardCode, ctx.svc.now());
|
||||
return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=${token}`, expiresAt, cardCode: ctx.client.cardCode };
|
||||
}),
|
||||
|
||||
/** ORD-2: параметры услуги для формы */
|
||||
service: clientProcedure.input(z.object({ code: z.string() })).query(async ({ ctx, input }) => {
|
||||
const s = await getServiceByCode(ctx.db, input.code);
|
||||
const membership = await getMembership(ctx.db, ctx.client.id, ctx.svc.now());
|
||||
return {
|
||||
code: s.code,
|
||||
category: s.category,
|
||||
flow: s.flow,
|
||||
active: s.active,
|
||||
isOnline: s.isOnline,
|
||||
paramsSchema: s.paramsSchema,
|
||||
defaults: defaultParams(s.paramsSchema),
|
||||
fromPrice: s.fromPrice,
|
||||
discountPct: discountFor(membership.level, s.categoryDiscountPct, s.categoryEliteDiscountPct),
|
||||
categoryDiscountPct: s.categoryDiscountPct,
|
||||
categoryEliteDiscountPct: s.categoryEliteDiscountPct,
|
||||
horizonDays: s.slotConfig.horizonDays,
|
||||
lateCancelFeePct: s.lateCancelFeePct,
|
||||
/** цены вариантов — чтобы показать «от N ֏» на кнопках выбора */
|
||||
variantPrices: s.pricing.kind === 'rooms' ? Object.fromEntries(Object.entries(s.pricing.tiers).map(([v, tier]) => [v, tier.base])) : {},
|
||||
/** цены доп. услуг — показываются рядом с выбором */
|
||||
extrasPrices: s.pricing.kind === 'rooms' ? s.pricing.extras : {},
|
||||
};
|
||||
}),
|
||||
|
||||
districts: clientProcedure.query(({ ctx }) => ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' }, select: { id: true, code: true } })),
|
||||
|
||||
paymentMethods: clientProcedure.query(({ ctx }) =>
|
||||
ctx.db.paymentMethod.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { id: true, code: true, name: true } }),
|
||||
),
|
||||
|
||||
/** ORD-4 */
|
||||
quote: clientProcedure
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput }))
|
||||
.query(async ({ ctx, input }) => (await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params)).quote),
|
||||
|
||||
/** ORD-3 */
|
||||
slots: clientProcedure
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput, dateKey, excludeOrderId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const slots = await availableSlots(ctx.svc, input);
|
||||
return slots.map((s) => ({ start: s.start, end: s.end }));
|
||||
}),
|
||||
|
||||
/** ORD-5 */
|
||||
createOrder: consentedClientProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serviceCode: z.string(),
|
||||
params: paramsInput,
|
||||
scheduledAt: z.date(),
|
||||
addressText: z.string().max(300).optional(),
|
||||
districtId: z.string().optional(),
|
||||
lat: z.number().min(-90).max(90).optional(),
|
||||
lng: z.number().min(-180).max(180).optional(),
|
||||
note: z.string().max(1000).optional(),
|
||||
paymentMethodId: z.string().optional(),
|
||||
repeatOfId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const order = await createOrder(ctx.svc, { ...input, clientId: ctx.client.id });
|
||||
return { id: order.id, number: order.number };
|
||||
}),
|
||||
|
||||
orders: router({
|
||||
list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)),
|
||||
get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)),
|
||||
cancelPreview: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => cancelPreview(ctx.svc, input.id, ctx.client.id)),
|
||||
cancel: clientProcedure
|
||||
.input(z.object({ id: z.string(), reason: z.string().max(500).optional() }))
|
||||
.mutation(({ ctx, input }) => cancelByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, reason: input.reason }).then((r) => ({ fee: r.fee }))),
|
||||
reschedule: clientProcedure
|
||||
.input(z.object({ id: z.string(), scheduledAt: z.date() }))
|
||||
.mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })),
|
||||
/** ORD-7 */
|
||||
repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)),
|
||||
}),
|
||||
|
||||
/** Фотоотчёт по своему заказу — временная ссылка, доступ логируется */
|
||||
fileUrl: clientProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId }, include: { order: true } });
|
||||
if (!file || file.deletedAt || file.order.clientId !== ctx.client.id || file.kind !== 'PHOTO_REPORT') throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'CLIENT', actorId: ctx.client.id } });
|
||||
return { url: await signedUrl(file.storageKey) };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { averageRating, fromYerevan, parseDateKey } from '@barev/core';
|
||||
import {
|
||||
companyBoard,
|
||||
companyStaffForOrder,
|
||||
dispatcherAssign,
|
||||
markPaid,
|
||||
proHistory,
|
||||
proOrder,
|
||||
proToday,
|
||||
providerAction,
|
||||
respondToOffer,
|
||||
setChecklistItem,
|
||||
setShift,
|
||||
checkCard,
|
||||
createStaffInvite,
|
||||
partnerCabinet,
|
||||
redeemOffer,
|
||||
removePartnerMember,
|
||||
} from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { signedUrl } from '../infra.ts';
|
||||
import { proUserProcedure, providerProcedure, router } from '../trpc.ts';
|
||||
|
||||
const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
/** Кабинет партнёра клуба: собственник и сотрудники */
|
||||
const partnerRouter = router({
|
||||
cabinet: proUserProcedure.input(z.object({ partnerId: z.string().optional() })).query(({ ctx, input }) => partnerCabinet(ctx.svc, ctx.identity.user.telegramId, input.partnerId)),
|
||||
/** скан QR клиента (ссылка или код) */
|
||||
check: proUserProcedure
|
||||
.input(z.object({ token: z.string().max(300), partnerId: z.string().optional() }))
|
||||
.mutation(({ ctx, input }) => checkCard(ctx.svc, { telegramId: ctx.identity.user.telegramId, ...input })),
|
||||
redeem: proUserProcedure
|
||||
.input(z.object({ checkId: z.string(), offerId: z.string() }))
|
||||
.mutation(({ ctx, input }) => redeemOffer(ctx.svc, { telegramId: ctx.identity.user.telegramId, ...input })),
|
||||
invite: proUserProcedure.input(z.object({ partnerId: z.string().optional() })).mutation(({ ctx, input }) => createStaffInvite(ctx.svc, ctx.identity.user.telegramId, input.partnerId)),
|
||||
removeMember: proUserProcedure.input(z.object({ memberId: z.string() })).mutation(({ ctx, input }) => removePartnerMember(ctx.svc, ctx.identity.user.telegramId, input.memberId)),
|
||||
});
|
||||
|
||||
export const proRouter = router({
|
||||
partner: partnerRouter,
|
||||
/** Кто я: исполнитель и/или диспетчер компаний */
|
||||
me: proUserProcedure.query(({ ctx }) => {
|
||||
const p = ctx.identity.provider;
|
||||
return {
|
||||
locale: ctx.identity.user.locale,
|
||||
provider: p
|
||||
? { id: p.id, name: p.name, role: p.role, onShift: p.onShift, rating: averageRating(p.ratingSum, p.ratingCount), companyName: p.company?.name ?? null }
|
||||
: null,
|
||||
companies: ctx.identity.companies.map((c) => ({ id: c.id, name: c.name })),
|
||||
partners: ctx.identity.partners,
|
||||
};
|
||||
}),
|
||||
|
||||
today: providerProcedure.query(({ ctx }) => proToday(ctx.svc, ctx.provider.id)),
|
||||
order: providerProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => proOrder(ctx.svc, ctx.provider.id, input.id)),
|
||||
|
||||
setShift: providerProcedure.input(z.object({ onShift: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
await setShift(ctx.db, ctx.provider.id, input.onShift);
|
||||
}),
|
||||
|
||||
/** PRO-5 */
|
||||
respondOffer: providerProcedure
|
||||
.input(z.object({ offerId: z.string(), accept: z.boolean() }))
|
||||
.mutation(({ ctx, input }) => respondToOffer(ctx.svc, { ...input, providerId: ctx.provider.id })),
|
||||
|
||||
/** PRO-3 */
|
||||
action: providerProcedure
|
||||
.input(z.object({ orderId: z.string(), action: z.string().max(40), comment: z.string().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await providerAction(ctx.svc, { ...input, providerId: ctx.provider.id });
|
||||
}),
|
||||
|
||||
/** PRO-4 */
|
||||
checklist: providerProcedure
|
||||
.input(z.object({ orderId: z.string(), code: z.string(), checked: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await setChecklistItem(ctx.svc, { ...input, providerId: ctx.provider.id });
|
||||
}),
|
||||
|
||||
/** PAY-1: исполнитель отмечает «оплачено» (например, наличными) */
|
||||
markPaid: providerProcedure.input(z.object({ orderId: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await markPaid(ctx.svc, { orderId: input.orderId, by: { type: 'PROVIDER', id: ctx.provider.id } });
|
||||
}),
|
||||
|
||||
/** PRO-6 */
|
||||
history: providerProcedure.input(z.object({ from: dateKey, to: dateKey })).query(({ ctx, input }) => {
|
||||
const f = parseDateKey(input.from);
|
||||
const t = parseDateKey(input.to);
|
||||
return proHistory(ctx.svc, ctx.provider.id, fromYerevan(f.year, f.month, f.day), fromYerevan(t.year, t.month, t.day + 1));
|
||||
}),
|
||||
|
||||
fileUrl: providerProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId }, include: { order: true } });
|
||||
if (!file || file.deletedAt || file.order.providerId !== ctx.provider.id) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'PROVIDER', actorId: ctx.provider.id } });
|
||||
return { url: await signedUrl(file.storageKey) };
|
||||
}),
|
||||
|
||||
/** PRO-7: диспетчер компании */
|
||||
dispatcher: router({
|
||||
board: proUserProcedure.input(z.object({ companyId: z.string() })).query(({ ctx, input }) => companyBoard(ctx.svc, input.companyId, ctx.identity.user.id)),
|
||||
staffForOrder: proUserProcedure.input(z.object({ companyId: z.string(), orderId: z.string() })).query(async ({ ctx, input }) => {
|
||||
if (!ctx.identity.companies.some((c) => c.id === input.companyId)) throw new TRPCError({ code: 'FORBIDDEN' });
|
||||
return companyStaffForOrder(ctx.svc, input.companyId, input.orderId);
|
||||
}),
|
||||
assign: proUserProcedure
|
||||
.input(z.object({ companyId: z.string(), orderId: z.string(), providerId: z.string() }))
|
||||
.mutation(({ ctx, input }) => dispatcherAssign(ctx.svc, { ...input, dispatcherUserId: ctx.identity.user.id })),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { botLink, encodeStartPayload, LOCALES, normalizeAgencyCode, UTM_KEYS } from '@barev/core';
|
||||
import { createLead, createWebRequest, getServiceByCode, getSettings, LEAD_MOVE_WHEN, LEAD_PACKAGES, listCatalog, updateLead } from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { loginThrottled } from '../auth/admin.ts';
|
||||
import { env } from '../env.ts';
|
||||
import { publicProcedure, router } from '../trpc.ts';
|
||||
|
||||
export const publicRouter = router({
|
||||
/** LND-5: цены «от» и скидки — для лендинга */
|
||||
catalog: publicProcedure.query(async ({ ctx }) => {
|
||||
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
|
||||
return {
|
||||
categories,
|
||||
barevPlus: { monthlyPrice: settings['barevPlus.monthlyPrice'], giftDays: settings['barevPlus.giftDays'] },
|
||||
/** цены и скидки главной: отель, консультация, Elite, «скидки до N%» */
|
||||
offer: {
|
||||
plusMaxPct: settings['barevPlus.maxDiscountPct'],
|
||||
elitePrice: settings['elite.monthlyPrice'],
|
||||
eliteMaxPct: settings['elite.maxDiscountPct'],
|
||||
hotelWithPlus: settings['relocation.hotelWithPlus'],
|
||||
hotelBase: settings['relocation.hotelBase'],
|
||||
consultation: settings['relocation.consultation'],
|
||||
docsFrom: settings['docs.fromPrice'],
|
||||
socialCard: settings['docs.socialCardPrice'],
|
||||
residenceEscortFrom: settings['residence.escortFrom'],
|
||||
residenceStateFee: settings['residence.stateFee'],
|
||||
transferStandard: settings['transfer.standardPrice'],
|
||||
transferPremium: settings['transfer.premiumPrice'],
|
||||
},
|
||||
/** условия для агентств-партнёров — страница /land6 */
|
||||
agency: { commissionPct: settings['agency.defaultCommissionPct'], attributionMonths: settings['agency.attributionMonths'] },
|
||||
botUsername: env.CLIENT_BOT_USERNAME,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* LND-3: ссылка в бота. Если есть UTM — сохраняем их и отдаём короткий токен,
|
||||
* потому что payload Telegram ограничен 64 символами.
|
||||
*/
|
||||
startLink: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
locale: z.enum(LOCALES).optional(),
|
||||
agency: z.string().max(32).optional(),
|
||||
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
|
||||
const hasUtm = input.utm && Object.keys(input.utm).length > 0;
|
||||
let payload: string;
|
||||
if (hasUtm) {
|
||||
const id = randomBytes(6).toString('base64url').replace(/[-_]/g, 'x');
|
||||
await ctx.db.startToken.create({ data: { id, locale: input.locale, agency, utm: input.utm } });
|
||||
payload = encodeStartPayload({ token: id });
|
||||
} else {
|
||||
payload = encodeStartPayload({ locale: input.locale, agency });
|
||||
}
|
||||
return { payload, url: botLink(env.CLIENT_BOT_USERNAME, payload) };
|
||||
}),
|
||||
|
||||
/** Страница /clean: правила цены и окно записи — цена считается в браузере той же функцией quote, что и на сервере */
|
||||
cleaning: publicProcedure.query(async ({ ctx }) => {
|
||||
const [s, districts] = await Promise.all([getServiceByCode(ctx.db, 'cleaning'), ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' }, select: { code: true } })]);
|
||||
return {
|
||||
active: s.active,
|
||||
pricing: s.pricing,
|
||||
paramsSchema: s.paramsSchema,
|
||||
slotConfig: s.slotConfig,
|
||||
categoryDiscountPct: s.categoryDiscountPct,
|
||||
districts: districts.map((d) => d.code),
|
||||
botUsername: env.CLIENT_BOT_USERNAME,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Заявка с сайта → сразу в админке; в ответе ссылка в бота, которая привязывает заявку к Telegram */
|
||||
cleaningRequest: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])),
|
||||
preferredAt: z.iso.datetime().transform((v) => new Date(v)),
|
||||
districtCode: z.string().max(40),
|
||||
addressText: z.string().max(300),
|
||||
note: z.string().max(1000).optional(),
|
||||
phone: z.string().max(30),
|
||||
name: z.string().max(80).optional(),
|
||||
locale: z.enum(LOCALES),
|
||||
agency: z.string().max(32).optional(),
|
||||
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
|
||||
consent: z.literal(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// защита от спама формой: тот же счётчик, что у входа в админку (10 попыток за 15 минут с IP)
|
||||
if (loginThrottled(`web-request|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'REQUEST_THROTTLED' });
|
||||
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
|
||||
const request = await createWebRequest(ctx.svc, { ...input, serviceCode: 'cleaning', agency });
|
||||
const payload = encodeStartPayload({ token: request.token });
|
||||
return { number: request.number, url: botLink(env.CLIENT_BOT_USERNAME, payload) };
|
||||
}),
|
||||
|
||||
/** Квиз на главной, шаг 1: имя и контакт — сразу в базу и в админку. В ответе ключ для следующих шагов */
|
||||
leadStart: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().max(80),
|
||||
contact: z.string().max(60),
|
||||
locale: z.enum(LOCALES),
|
||||
agency: z.string().max(32).optional(),
|
||||
utm: z.partialRecord(z.enum(UTM_KEYS), z.string().max(120)).optional(),
|
||||
consent: z.literal(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (loginThrottled(`lead|${ctx.req.ip}`)) throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'REQUEST_THROTTLED' });
|
||||
const agency = input.agency ? (normalizeAgencyCode(input.agency) ?? undefined) : undefined;
|
||||
const lead = await createLead(ctx.svc, { ...input, agency });
|
||||
return { id: lead.id, key: lead.key, number: lead.number };
|
||||
}),
|
||||
|
||||
/** Квиз, следующие шаги: ответы дописываются в ту же заявку */
|
||||
leadUpdate: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().max(40),
|
||||
key: z.string().max(40),
|
||||
moved: z.boolean().optional(),
|
||||
moveWhen: z.enum(LEAD_MOVE_WHEN).optional(),
|
||||
moveDate: z.iso.date().transform((v) => new Date(`${v}T00:00:00Z`)).optional(),
|
||||
package: z.enum(LEAD_PACKAGES).optional(),
|
||||
options: z
|
||||
.object({
|
||||
airport: z.enum(['none', 'standard', 'premium']).optional(),
|
||||
stay: z.enum(['none', 'flat', 'hotel']).optional(),
|
||||
stayBudgetFrom: z.number().int().min(0).max(10_000_000).optional(),
|
||||
stayBudgetTo: z.number().int().min(0).max(10_000_000).optional(),
|
||||
flat: z.boolean().optional(),
|
||||
socialCard: z.boolean().optional(),
|
||||
bankCard: z.boolean().optional(),
|
||||
registration: z.boolean().optional(),
|
||||
residence: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
submit: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const lead = await updateLead(ctx.svc, input);
|
||||
return { number: lead.number, submitted: !!lead.submittedAt };
|
||||
}),
|
||||
|
||||
health: publicProcedure.query(() => ({ ok: true })),
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import multipart from '@fastify/multipart';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { getFlow } from '@barev/core';
|
||||
import { DomainError, proIdentity, setChecklistItem } from '@barev/services';
|
||||
import Fastify from 'fastify';
|
||||
import { validateInitData } from './auth/telegram.ts';
|
||||
import { env } from './env.ts';
|
||||
import { ctx as svc, db, ensureBucket, putObject } from './infra.ts';
|
||||
import { appRouter, type AppRouter } from './router.ts';
|
||||
import { createContext } from './trpc.ts';
|
||||
|
||||
if (env.SENTRY_DSN) Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: env.isProd ? 'info' : 'warn', redact: ['req.headers.authorization', 'req.headers.cookie'] },
|
||||
bodyLimit: 1024 * 1024,
|
||||
trustProxy: true,
|
||||
// tRPC склеивает несколько запросов в один путь (/trpc/a,b,c) — по умолчанию Fastify режет параметр после 100 символов (414)
|
||||
routerOptions: { maxParamLength: 5000 },
|
||||
});
|
||||
|
||||
await app.register(cors, { origin: env.corsOrigins, credentials: true });
|
||||
await app.register(cookie);
|
||||
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ error, path }) {
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
app.log.error({ path, err: error.cause ?? error }, 'trpc error');
|
||||
if (env.SENTRY_DSN) Sentry.captureException(error.cause ?? error);
|
||||
}
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
|
||||
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic']);
|
||||
|
||||
/**
|
||||
* PRO-4: фото к пункту чек-листа (фотоотчёт клинера).
|
||||
* multipart: orderId, code, file. Авторизация — initData бота исполнителей.
|
||||
*/
|
||||
app.post('/files/pro/checklist-photo', async (req, reply) => {
|
||||
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
|
||||
const init = validateInitData(auth, env.proBotToken, env.TG_INIT_DATA_MAX_AGE);
|
||||
if (!init.ok) return reply.code(401).send({ error: `INIT_DATA_${init.reason}` });
|
||||
const identity = await proIdentity(db, BigInt(init.user.id));
|
||||
if (!identity?.provider) return reply.code(403).send({ error: 'NOT_PROVIDER' });
|
||||
|
||||
const part = await req.file();
|
||||
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
||||
const field = (name: string) => {
|
||||
const f = part.fields[name];
|
||||
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
|
||||
};
|
||||
const orderId = field('orderId');
|
||||
const code = field('code');
|
||||
if (!orderId || !code) return reply.code(400).send({ error: 'BAD_INPUT' });
|
||||
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
||||
|
||||
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
|
||||
if (!order || order.providerId !== identity.provider.id) return reply.code(404).send({ error: 'NOT_FOUND' });
|
||||
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) return reply.code(409).send({ error: 'TRANSITION_NOT_ALLOWED' });
|
||||
|
||||
const body = await part.toBuffer();
|
||||
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
|
||||
const file = await db.orderFile.create({
|
||||
data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'PROVIDER', uploadedById: identity.provider.id },
|
||||
});
|
||||
try {
|
||||
await setChecklistItem(svc, { orderId: order.id, providerId: identity.provider.id, code, checked: true, fileId: file.id });
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
|
||||
throw e;
|
||||
}
|
||||
return { fileId: file.id };
|
||||
});
|
||||
|
||||
/** Ошибки из Mini App и админки (sendBeacon) — только в лог, без хранения */
|
||||
app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
|
||||
const b = (typeof req.body === 'object' && req.body) || {};
|
||||
app.log.warn({ clientError: b, ua: req.headers['user-agent']?.slice(0, 160), host: req.headers.host }, 'client error');
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
|
||||
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
|
||||
console.log(`API: http://localhost:${env.API_PORT}`);
|
||||
@@ -0,0 +1,169 @@
|
||||
// подключает типы req.cookies / reply.setCookie
|
||||
import type {} from '@fastify/cookie';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import { DomainError, getClientByTelegramId, proIdentity, startClient } from '@barev/services';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
|
||||
import { validateInitData } from './auth/telegram.ts';
|
||||
import { env } from './env.ts';
|
||||
import { ctx as servicesCtx, db } from './infra.ts';
|
||||
|
||||
export async function createContext({ req, res }: { req: FastifyRequest; res: FastifyReply }) {
|
||||
return { req, res, db, svc: servicesCtx };
|
||||
}
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
const DOMAIN_TO_TRPC: Partial<Record<DomainError['code'], TRPCError['code']>> = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
CONSENT_REQUIRED: 'PRECONDITION_FAILED',
|
||||
};
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
const cause = error.cause;
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
// код бизнес-ошибки — фронт показывает errors.<domainCode>
|
||||
domainCode: cause instanceof DomainError ? cause.code : null,
|
||||
domainDetails: cause instanceof DomainError ? (cause.details ?? null) : null,
|
||||
zod: cause instanceof ZodError ? cause.issues.map((i) => ({ path: i.path.join('.'), code: i.code })) : null,
|
||||
stack: env.isProd ? undefined : shape.data.stack,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** DomainError → TRPCError с сохранением кода */
|
||||
const domainErrors = t.middleware(async ({ next }) => {
|
||||
const result = await next();
|
||||
if (!result.ok && result.error.cause instanceof DomainError) {
|
||||
const cause = result.error.cause;
|
||||
throw new TRPCError({ code: DOMAIN_TO_TRPC[cause.code] ?? 'BAD_REQUEST', message: cause.code, cause });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure.use(domainErrors);
|
||||
|
||||
function initDataFrom(req: FastifyRequest): string | undefined {
|
||||
const h = req.headers.authorization;
|
||||
return h?.startsWith('tma ') ? h.slice(4) : undefined;
|
||||
}
|
||||
|
||||
function checkInitData(req: FastifyRequest, token: string) {
|
||||
const res = validateInitData(initDataFrom(req), token, env.TG_INIT_DATA_MAX_AGE);
|
||||
if (!res.ok) throw new TRPCError({ code: 'UNAUTHORIZED', message: `INIT_DATA_${res.reason}` });
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиентский Mini App: каждый запрос подписан initData клиентского бота.
|
||||
* Если клиент открыл Mini App раньше, чем написал /start, — заводим его без источника.
|
||||
*/
|
||||
export const clientProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
const init = checkInitData(ctx.req, env.clientBotToken);
|
||||
const telegramId = BigInt(init.user.id);
|
||||
let client = await getClientByTelegramId(ctx.db, telegramId);
|
||||
if (!client) {
|
||||
await startClient(ctx.svc, init.user, init.startParam);
|
||||
client = await getClientByTelegramId(ctx.db, telegramId);
|
||||
}
|
||||
return next({ ctx: { tgUser: init.user, telegramId, client: client! } });
|
||||
});
|
||||
|
||||
/** То же, но заказ требует согласия на обработку ПДн (ONB-3) */
|
||||
export const consentedClientProcedure = clientProcedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.client.consentAt) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'CONSENT_REQUIRED', cause: new DomainError('CONSENT_REQUIRED') });
|
||||
return next();
|
||||
});
|
||||
|
||||
/** Mini App исполнителя: initData бота исполнителей; пользователь должен быть привязан инвайтом */
|
||||
export const proUserProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
const init = checkInitData(ctx.req, env.proBotToken);
|
||||
const identity = await proIdentity(ctx.db, BigInt(init.user.id));
|
||||
if (!identity || (!identity.provider && identity.companies.length === 0 && identity.partners.length === 0)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_LINKED' });
|
||||
}
|
||||
return next({ ctx: { tgUser: init.user, identity } });
|
||||
});
|
||||
|
||||
export const providerProcedure = proUserProcedure.use(async ({ ctx, next }) => {
|
||||
const provider = ctx.identity.provider;
|
||||
if (!provider) throw new TRPCError({ code: 'FORBIDDEN', message: 'NOT_PROVIDER' });
|
||||
return next({ ctx: { provider } });
|
||||
});
|
||||
|
||||
// ───── админка ─────
|
||||
|
||||
async function loadAdmin(ctx: Context) {
|
||||
const token = ctx.req.cookies[ADMIN_COOKIE];
|
||||
const session = await findSession(ctx.db, token);
|
||||
return session ? { session, admin: session.adminUser, token: token! } : null;
|
||||
}
|
||||
|
||||
/** Вошёл паролем, но 2FA ещё не пройдена */
|
||||
export const adminPreAuthProcedure = publicProcedure.use(async ({ ctx, next }) => {
|
||||
const s = await loadAdmin(ctx);
|
||||
if (!s) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'ADMIN_LOGIN_REQUIRED' });
|
||||
return next({ ctx: s });
|
||||
});
|
||||
|
||||
/** Аудит-лог действий админов: каждая мутация */
|
||||
const audit = t.middleware(async ({ ctx, next, type, path, getRawInput }) => {
|
||||
const result = await next();
|
||||
const admin = (ctx as Context & { admin?: { id: string } }).admin;
|
||||
if (type === 'mutation' && result.ok && admin) {
|
||||
const input = (await getRawInput()) as Record<string, unknown> | undefined;
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
adminUserId: admin.id,
|
||||
action: path,
|
||||
entity: path.split('.')[1] ?? 'admin',
|
||||
entityId: typeof input?.id === 'string' ? input.id : typeof input?.orderId === 'string' ? input.orderId : null,
|
||||
data: input ? JSON.parse(JSON.stringify(input, (k, v) => (/password|code|secret/i.test(k) ? '***' : v))) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
export const adminProcedure = adminPreAuthProcedure
|
||||
.use(async ({ ctx, next }) => {
|
||||
if (!ctx.session.verified) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'ADMIN_2FA_REQUIRED' });
|
||||
return next();
|
||||
})
|
||||
.use(audit);
|
||||
|
||||
/** ADM-9: финансы и настройки — только владелец */
|
||||
export const ownerProcedure = adminProcedure.use(async ({ ctx, next }) => {
|
||||
if (ctx.admin.role !== 'OWNER') throw new TRPCError({ code: 'FORBIDDEN', message: 'OWNER_ONLY' });
|
||||
return next();
|
||||
});
|
||||
|
||||
// ───── новая админка (apps/backoffice) ─────
|
||||
|
||||
export type StaffRole = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
|
||||
|
||||
/** Сотрудник новой админки: вошёл и имеет хотя бы одну роль */
|
||||
export const staffProcedure = adminProcedure.use(async ({ ctx, next }) => {
|
||||
if (ctx.admin.roles.length === 0) throw new TRPCError({ code: 'FORBIDDEN', message: 'NO_ROLE' });
|
||||
const roles = ctx.admin.roles as StaffRole[];
|
||||
const isSuper = roles.includes('SUPERADMIN');
|
||||
return next({ ctx: { roles, isSuper } });
|
||||
});
|
||||
|
||||
/** Доступ для перечисленных ролей; суперадмину можно всё */
|
||||
export const rolesProcedure = (...allowed: StaffRole[]) =>
|
||||
staffProcedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.isSuper && !ctx.roles.some((r) => allowed.includes(r))) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
|
||||
return next();
|
||||
});
|
||||
|
||||
export const superProcedure = rolesProcedure();
|
||||
Reference in new issue
Block a user