Bariki cashback, order edit, and current work in progress

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+105 -3
View File
@@ -9,6 +9,12 @@ import {
acceptPartnerInvite,
addProviderMessage,
checkCard,
accruePoints,
confirmRedemption,
getSettings,
previewAccrual,
rejectRedemption,
createProWebLogin,
DomainError,
linkByInvite,
peerName,
@@ -42,12 +48,25 @@ async function identity(c: Context) {
}
const localeOf = (id: Awaited<ReturnType<typeof identity>>): Locale => (id?.user.locale as Locale) ?? 'ru';
function cabinetKeyboard(locale: Locale) {
function cabinetKeyboard(locale: Locale, manager = false) {
const kb = new InlineKeyboard();
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL);
if (APP_URL.startsWith('https://')) kb.webApp(createT(locale)('bot.pro.openCabinet'), APP_URL).row();
// менеджер партнёра может открыть кабинет и на компьютере
if (manager) kb.text(createT(locale)('bot.pro.openWeb'), 'web');
return kb;
}
/** Одноразовая ссылка на кабинет в браузере (15 минут) */
async function sendWebLink(c: Context) {
const id = await identity(c);
const t = createT(localeOf(id));
if (!id || id.companies.length === 0) return void (await c.reply(t('bot.pro.notLinked')));
const code = await createProWebLogin(ctx, id.user.id);
const url = `${APP_URL || 'http://localhost:5175'}/?login=${code}#/company`;
const kb = url.startsWith('https://') ? new InlineKeyboard().url(t('bot.pro.openWeb'), url) : undefined;
await c.reply(t('bot.pro.webLink', { url }), { reply_markup: kb, link_preview_options: { is_disabled: true } });
}
async function greet(c: Context) {
const id = await identity(c);
const locale = localeOf(id);
@@ -61,7 +80,7 @@ async function greet(c: Context) {
...id.companies.map((co) => t('bot.pro.youAreDispatcher', { company: co.name })),
...id.partners.map((p) => t(p.role === 'OWNER' ? 'bot.partner.youAreOwner' : 'bot.partner.youAreStaff', { partner: p.name })),
].filter(Boolean);
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale) });
await c.reply(`${roles.join('\n')}\n\n${t('bot.pro.welcome')}`, { reply_markup: cabinetKeyboard(locale, id.companies.length > 0) });
}
/** Оператор админки, привязавший этот Telegram */
@@ -173,6 +192,11 @@ bot.command('start', async (c) => {
});
bot.command('app', greet);
bot.command('web', sendWebLink);
bot.callbackQuery('web', async (c) => {
await c.answerCallbackQuery();
await sendWebLink(c);
});
/** PRO-5: принять / отклонить из уведомления */
bot.callbackQuery(/^offer:(accept|decline):(\w+)$/, async (c) => {
@@ -214,6 +238,84 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
});
/** «Ответить» под уведомлением о сообщении */
// ───── барики: быстрые действия команды ─────
/** Кто может управлять бариками из бота: суперадмин или поддержка, если разрешено в настройках */
async function pointsActor(c: Context) {
const admin = await adminOf(c);
if (!admin) return null;
const owner = admin.roles.includes('SUPERADMIN');
if (!owner && !(admin.roles.includes('SUPPORT') && (await getSettings(db))['points.supportCanManage'])) return null;
return { adminId: admin.id, owner };
}
const fmt = (n: number) => new Intl.NumberFormat('ru-RU').format(n);
async function pointsError(c: Context, e: unknown) {
if (!(e instanceof DomainError)) throw e;
await c.reply(adminT(`errors.${e.code}`));
}
bot.callbackQuery(/^pr:(ok|no):(\w+)$/, async (c) => {
await c.answerCallbackQuery();
const actor = await pointsActor(c);
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
const r = await db.pointsRedemption.findUnique({ where: { id: c.match[2]! }, include: { order: true } });
if (!r) return;
try {
if (c.match[1] === 'ok') {
if (!r.order) return;
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: r.order.totalPrice }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(adminT('bot.admin.points.confirmed', { amount: fmt(res.amount) }));
} else {
await rejectRedemption(ctx, { redemptionId: r.id, reason: 'declined' }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(adminT('bot.admin.points.rejected'));
}
} catch (e) {
await pointsError(c, e);
}
});
/** /barik <телефон или @username> <сумма заказа> [категория] — подсказка и кнопка «Начислить» */
bot.command('barik', async (c) => {
const actor = await pointsActor(c);
if (!actor) return void (await c.reply(adminT('bot.admin.notAdmin')));
const parts = (c.match ?? '').trim().split(/\s+/).filter(Boolean);
if (parts.length < 2) return void (await c.reply(adminT('bot.admin.points.help')));
const [who, sumRaw, catRaw] = parts;
const sum = Number(sumRaw!.replace(/[^\d]/g, ''));
const digits = who!.replace(/[^\d]/g, '');
const user = who!.startsWith('@')
? await db.user.findFirst({ where: { username: { equals: who!.slice(1), mode: 'insensitive' } }, include: { client: true } })
: digits.length >= 8
? await db.user.findFirst({ where: { phone: { endsWith: digits.slice(-8) } }, include: { client: true } })
: null;
if (!user?.client) return void (await c.reply(adminT('bot.admin.points.noClient')));
const cat = await db.pointsCategory.findUnique({ where: { code: (catRaw ?? 'cleaning').toLowerCase() } });
if (!cat || !sum) return void (await c.reply(adminT('bot.admin.points.help')));
const p = await previewAccrual(db, { clientId: user.client.id, categoryId: cat.id, orderAmount: sum, serviceDate: new Date() }, await getSettings(db), new Date());
const name = [user.firstName, user.lastName].filter(Boolean).join(' ') || `@${user.username}`;
const catName = (cat.name as Record<string, string>).ru ?? cat.code;
const tier = p.tier === 'ELITE' ? 'Elite' : p.tier === 'BAREV_PLUS' ? 'Barev+' : 'Barev';
const kb = new InlineKeyboard().text(adminT('bot.admin.points.accrue'), `pa:${user.client.id}:${cat.code}:${sum}`);
await c.reply(adminT('bot.admin.points.preview', { amount: fmt(p.suggested), name, what: catName, sum: fmt(sum), tier, rate: p.ratePct, date: formatDateTime('ru', p.availableAt, { day: 'numeric', month: 'long' }) }), { reply_markup: kb });
});
bot.callbackQuery(/^pa:(\w+):([a-z_]+):(\d+)$/, async (c) => {
await c.answerCallbackQuery();
const actor = await pointsActor(c);
if (!actor) return;
const cat = await db.pointsCategory.findUnique({ where: { code: c.match[2]! } });
if (!cat) return;
try {
const r = await accruePoints(ctx, { clientId: c.match[1]!, kind: 'ORDER', categoryId: cat.id, orderAmount: Number(c.match[3]), serviceDate: new Date(), channel: 'BOT', idempotencyKey: `bot:${c.callbackQuery.message?.message_id}:${c.match[1]}` }, actor);
await c.editMessageReplyMarkup().catch(() => undefined);
await c.reply(r.status === 'created' ? adminT('bot.admin.points.done', { amount: fmt(r.amount) }) : r.status === 'needs_approval' ? adminT('bot.admin.points.needsApproval') : adminT('bot.admin.points.zero'));
} catch (e) {
await pointsError(c, e);
}
});
bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
const admin = await adminOf(c);
if (!admin) {