fix(bot): issue #127 — active-state sync, disabled-entity handling, double-tap lock, state reset
- BUG-01: getActiveLocationById + product availability checks (loc_active/cat_active) — disabled locations/categories filtered from bot menus - BUG-02: graceful redirect to main menu (location_disabled notice) when entity disabled mid-flow, no crash in handleDistrictSelection/handleProductSelection/handleBuyProduct/handlePay - BUG-03: per-user callback lock (LOCK_MS 1500ms debounce) in utils/callbackLock.js — duplicate taps dropped - BUG-04: resetUserContext clears tracked photo/product messages + userStates on main-menu navigation and /start - fix: handlePay uses validated numeric quantity (was raw string) for price/stock/purchase writes - tests: 7 new (botBugFixes.test.js), updated userProductHandler tests — 30 total pass - bump v1.2.2
This commit is contained in:
@@ -9,10 +9,17 @@
|
|||||||
|
|
||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
**v1.2.1** — 2026-07-18
|
**v1.2.2** — 2026-08-04
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v1.2.2 — 2026-08-04
|
||||||
|
- **fix**: BUG-01 — disabled locations/categories/subcategories filtered from bot menus (getActiveLocationById, is_active checks)
|
||||||
|
- **fix**: BUG-02 — graceful handling of disabled entity during purchase flow (location_disabled notice + main-menu redirect, no crash)
|
||||||
|
- **fix**: BUG-03 — per-user callback lock (1500ms debounce) prevents duplicate messages on double-tap
|
||||||
|
- **fix**: BUG-04 — resetUserContext clears stale inline keyboards/state on main-menu navigation and /start
|
||||||
|
- **fix**: handlePay uses validated numeric quantity for price/stock/purchase writes (was raw string)
|
||||||
|
|
||||||
### v1.2.1 — 2026-07-18
|
### v1.2.1 — 2026-07-18
|
||||||
- **refactor**: Removed deposit amount-selection step (redundant); deposit_wallet_ now goes directly to Mercuryo instructions
|
- **refactor**: Removed deposit amount-selection step (redundant); deposit_wallet_ now goes directly to Mercuryo instructions
|
||||||
- **feat**: Updated Mercuryo button text to include VISA/Mastercard branding in all locales
|
- **feat**: Updated Mercuryo button text to include VISA/Mastercard branding in all locales
|
||||||
|
|||||||
239
src/__tests__/botBugFixes.test.js
Normal file
239
src/__tests__/botBugFixes.test.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../config/config.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
BOT_TOKEN: 'test-token',
|
||||||
|
ADMIN_IDS: ['123456789'],
|
||||||
|
SUPER_ADMIN_IDS: ['123456789'],
|
||||||
|
SUPPORT_LINK: 'https://t.me/support',
|
||||||
|
DEFAULT_LANGUAGE: 'en',
|
||||||
|
ENCRYPTION_KEY: 'x'.repeat(64)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/logger.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
fatal: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recorded = {
|
||||||
|
calls: []
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../config/database.js', () => {
|
||||||
|
const db = {
|
||||||
|
allAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
recorded.calls.push({ method: 'allAsync', sql, params });
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
getAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
recorded.calls.push({ method: 'getAsync', sql, params });
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
runAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
recorded.calls.push({ method: 'runAsync', sql, params });
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return { __esModule: true, default: db };
|
||||||
|
});
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
products: {
|
||||||
|
location_disabled: 'This location is no longer available.',
|
||||||
|
back_to_main: '🏠 Main Menu',
|
||||||
|
error_loading_categories: 'Error loading categories. Please try again.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getNestedValue(obj, keyPath) {
|
||||||
|
return keyPath.split('.').reduce((o, k) => o?.[k], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../i18n/index.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
tForUser: (lang) => (key) => getNestedValue(en, key) || key
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../context/bot.js', () => {
|
||||||
|
const bot = {
|
||||||
|
deleteMessage: vi.fn().mockResolvedValue({}),
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }),
|
||||||
|
sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }),
|
||||||
|
editMessageText: vi.fn().mockResolvedValue({ message_id: 222 }),
|
||||||
|
answerCallbackQuery: vi.fn().mockResolvedValue({})
|
||||||
|
};
|
||||||
|
return { __esModule: true, default: bot, botAvailable: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../context/userStates.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
get: vi.fn().mockResolvedValue({}),
|
||||||
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
|
delete: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/userService.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getUserByTelegramId: vi.fn().mockResolvedValue({ language: 'en' })
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/categoryService.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getCategoriesByLocationId: vi.fn().mockResolvedValue([])
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import LocationService from '../services/locationService.js';
|
||||||
|
import UserProductHandler from '../handlers/userHandlers/userProductHandler.js';
|
||||||
|
import bot from '../context/bot.js';
|
||||||
|
import userStates from '../context/userStates.js';
|
||||||
|
import { resetUserContext } from '../utils/messageUtils.js';
|
||||||
|
import * as callbackLock from '../utils/callbackLock.js';
|
||||||
|
import CategoryService from '../services/categoryService.js';
|
||||||
|
|
||||||
|
describe('BUG-01: getActiveLocationById SQL filter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
recorded.calls = [];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getActiveLocationById includes is_active = 1 and id = 5', async () => {
|
||||||
|
await LocationService.getActiveLocationById(5);
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||||
|
expect(sql).toContain('is_active = 1');
|
||||||
|
expect(sql).toContain('id = ?');
|
||||||
|
const params = recorded.calls.find(c => c.method === 'getAsync')?.params;
|
||||||
|
expect(params).toEqual([5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLocationById does NOT include is_active filter', async () => {
|
||||||
|
await LocationService.getLocationById(5);
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||||
|
expect(sql).not.toContain('is_active');
|
||||||
|
expect(sql).toContain('id = ?');
|
||||||
|
const params = recorded.calls.find(c => c.method === 'getAsync')?.params;
|
||||||
|
expect(params).toEqual([5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BUG-02: handleDistrictSelection disabled location path', () => {
|
||||||
|
let getActiveLocationByIdSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getActiveLocationByIdSpy = vi.spyOn(LocationService, 'getActiveLocationById').mockResolvedValue(null);
|
||||||
|
CategoryService.getCategoriesByLocationId.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
getActiveLocationByIdSpy?.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows location_disabled notice and resets state when district is disabled', async () => {
|
||||||
|
const callbackQuery = {
|
||||||
|
id: 'cq-disabled',
|
||||||
|
from: { id: 12345, first_name: 'Test' },
|
||||||
|
message: {
|
||||||
|
chat: { id: 67890 },
|
||||||
|
message_id: 111
|
||||||
|
},
|
||||||
|
data: 'shop_loc_42'
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(UserProductHandler.handleDistrictSelection(callbackQuery)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(bot.answerCallbackQuery).toHaveBeenCalledWith('cq-disabled');
|
||||||
|
expect(userStates.delete).toHaveBeenCalledWith(67890);
|
||||||
|
expect(bot.editMessageText).toHaveBeenCalledTimes(1);
|
||||||
|
const [text, options] = bot.editMessageText.mock.calls[0];
|
||||||
|
expect(text).toContain(en.products.location_disabled);
|
||||||
|
expect(options.chat_id).toBe(67890);
|
||||||
|
expect(options.message_id).toBe(111);
|
||||||
|
const keyboard = options.reply_markup.inline_keyboard;
|
||||||
|
expect(keyboard).toHaveLength(1);
|
||||||
|
expect(keyboard[0]).toHaveLength(1);
|
||||||
|
expect(keyboard[0][0].text).toBe(en.products.back_to_main);
|
||||||
|
expect(keyboard[0][0].callback_data).toBe('shop_start');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BUG-03: double-tap lock', () => {
|
||||||
|
const { tryAcquireLock, LOCK_MS } = callbackLock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
callbackLock.pruneLocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
callbackLock.pruneLocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first acquisition succeeds, immediate second fails, after LOCK_MS succeeds', () => {
|
||||||
|
const userId = 12345;
|
||||||
|
|
||||||
|
expect(tryAcquireLock(userId)).toBe(true);
|
||||||
|
expect(tryAcquireLock(userId)).toBe(false);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(LOCK_MS);
|
||||||
|
expect(tryAcquireLock(userId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BUG-04: resetUserContext', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes tracked photo/product/hidden messages and clears state', async () => {
|
||||||
|
userStates.get.mockResolvedValue({
|
||||||
|
photoMessageId: 1,
|
||||||
|
productMessageId: 2,
|
||||||
|
hiddenPhotoMessageId: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetUserContext(123);
|
||||||
|
|
||||||
|
expect(bot.deleteMessage).toHaveBeenCalledWith(123, 1);
|
||||||
|
expect(bot.deleteMessage).toHaveBeenCalledWith(123, 2);
|
||||||
|
expect(bot.deleteMessage).toHaveBeenCalledWith(123, 3);
|
||||||
|
expect(userStates.delete).toHaveBeenCalledWith(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when deleteMessage rejects', async () => {
|
||||||
|
bot.deleteMessage.mockRejectedValueOnce(new Error('cannot delete'));
|
||||||
|
bot.deleteMessage.mockResolvedValueOnce({});
|
||||||
|
bot.deleteMessage.mockRejectedValueOnce(new Error('cannot delete'));
|
||||||
|
userStates.get.mockResolvedValue({
|
||||||
|
photoMessageId: 10,
|
||||||
|
productMessageId: 11,
|
||||||
|
hiddenPhotoMessageId: 12
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resetUserContext(456)).resolves.toBeUndefined();
|
||||||
|
expect(bot.deleteMessage).toHaveBeenCalledTimes(3);
|
||||||
|
expect(userStates.delete).toHaveBeenCalledWith(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty state gracefully', async () => {
|
||||||
|
userStates.get.mockResolvedValue({});
|
||||||
|
|
||||||
|
await expect(resetUserContext(789)).resolves.toBeUndefined();
|
||||||
|
expect(bot.deleteMessage).not.toHaveBeenCalled();
|
||||||
|
expect(userStates.delete).toHaveBeenCalledWith(789);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,9 @@ const en = {
|
|||||||
infinite_stock: '∞ Always available',
|
infinite_stock: '∞ Always available',
|
||||||
no_description: 'No description provided',
|
no_description: 'No description provided',
|
||||||
no_photo: 'No photo available',
|
no_photo: 'No photo available',
|
||||||
error_loading_product: 'Error loading product details. Please try again.'
|
error_loading_product: 'Error loading product details. Please try again.',
|
||||||
|
location_disabled: 'This location is no longer available.',
|
||||||
|
back_to_main: '🏠 Main Menu'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,7 +22,8 @@ vi.mock('../context/bot.js', () => {
|
|||||||
const bot = {
|
const bot = {
|
||||||
deleteMessage: vi.fn(),
|
deleteMessage: vi.fn(),
|
||||||
sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }),
|
sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }),
|
||||||
sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 })
|
sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }),
|
||||||
|
answerCallbackQuery: vi.fn().mockResolvedValue({})
|
||||||
};
|
};
|
||||||
return { __esModule: true, default: bot, botAvailable: true };
|
return { __esModule: true, default: bot, botAvailable: true };
|
||||||
});
|
});
|
||||||
@@ -30,7 +33,7 @@ vi.mock('../context/userStates.js', () => ({
|
|||||||
default: {
|
default: {
|
||||||
get: vi.fn().mockResolvedValue({}),
|
get: vi.fn().mockResolvedValue({}),
|
||||||
set: vi.fn().mockResolvedValue(undefined),
|
set: vi.fn().mockResolvedValue(undefined),
|
||||||
delete: vi.fn()
|
delete: vi.fn().mockResolvedValue(undefined)
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,7 +53,8 @@ vi.mock('../services/productService.js', () => ({
|
|||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: {
|
default: {
|
||||||
getDetailedProductById: vi.fn(),
|
getDetailedProductById: vi.fn(),
|
||||||
getProductById: vi.fn()
|
getProductById: vi.fn(),
|
||||||
|
getProductAvailability: vi.fn()
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -114,9 +118,19 @@ function baseProduct(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function baseAvailability(overrides = {}) {
|
||||||
|
return {
|
||||||
|
...baseProduct(),
|
||||||
|
loc_active: 1,
|
||||||
|
cat_active: 1,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('UserProductHandler.handleProductSelection edge cases', () => {
|
describe('UserProductHandler.handleProductSelection edge cases', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
ProductService.getProductAvailability.mockResolvedValue(baseAvailability());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders fallback text when description and photo are missing, sends message, does not send photo', async () => {
|
it('renders fallback text when description and photo are missing, sends message, does not send photo', async () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<svg class="sa-icon sa-thin">
|
<svg class="sa-icon sa-thin">
|
||||||
<use href="/icons/sprite.svg#tag"></use>
|
<use href="/icons/sprite.svg#tag"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="fs-xs opacity-70">v1.2.1</span>
|
<span class="fs-xs opacity-70">v1.2.2</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -43,10 +43,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="alert alert-info mb-3">
|
<div class="alert alert-info mb-3">
|
||||||
<strong>Current:</strong> v1.2.1 · 2026-07-18
|
<strong>Current:</strong> v1.2.2 · 2026-08-04
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="fw-bold mb-2">v1.2.1 <span class="text-muted fs-sm">— 2026-07-18</span></h6>
|
<h6 class="fw-bold mb-2">v1.2.2 <span class="text-muted fs-sm">— 2026-08-04</span></h6>
|
||||||
<ul class="small mb-3">
|
<ul class="small mb-3">
|
||||||
<li><span class="badge bg-info">refactor</span> Removed deposit amount-selection step; deposit_wallet_ now goes directly to Mercuryo instructions</li>
|
<li><span class="badge bg-info">refactor</span> Removed deposit amount-selection step; deposit_wallet_ now goes directly to Mercuryo instructions</li>
|
||||||
<li><span class="badge bg-primary">feat</span> Updated Mercuryo button text to include VISA/Mastercard branding in all locales</li>
|
<li><span class="badge bg-primary">feat</span> Updated Mercuryo button text to include VISA/Mastercard branding in all locales</li>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import bot from "../../context/bot.js";
|
|||||||
import UserService from "../../services/userService.js";
|
import UserService from "../../services/userService.js";
|
||||||
import WalletService from "../../services/walletService.js";
|
import WalletService from "../../services/walletService.js";
|
||||||
import logger from "../../utils/logger.js";
|
import logger from "../../utils/logger.js";
|
||||||
|
import { resetUserContext } from "../../utils/messageUtils.js";
|
||||||
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
|
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
|
||||||
|
|
||||||
export default class UserHandler {
|
export default class UserHandler {
|
||||||
@@ -100,6 +101,7 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
|
|||||||
const username = msg.chat.username;
|
const username = msg.chat.username;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await resetUserContext(chatId);
|
||||||
await UserService.createUser({
|
await UserService.createUser({
|
||||||
telegram_id: telegramId,
|
telegram_id: telegramId,
|
||||||
username: username
|
username: username
|
||||||
|
|||||||
@@ -187,17 +187,19 @@ export default class UserProductHandler {
|
|||||||
const lang = user?.language || 'en';
|
const lang = user?.language || 'en';
|
||||||
const t = tForUser(lang);
|
const t = tForUser(lang);
|
||||||
|
|
||||||
const location = await LocationService.getLocationById(locationId);
|
const location = await LocationService.getActiveLocationById(locationId);
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
|
await bot.answerCallbackQuery(callbackQuery.id);
|
||||||
|
await userStates.delete(chatId);
|
||||||
await bot.editMessageText(
|
await bot.editMessageText(
|
||||||
t('products.not_found'),
|
t('products.location_disabled'),
|
||||||
{
|
{
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [[
|
inline_keyboard: [[
|
||||||
{ text: t('products.back'), callback_data: `shop_city_${encodeURIComponent(location?.country || '')}|${encodeURIComponent(location?.city || '')}` }
|
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||||
]]
|
]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,6 +414,24 @@ export default class UserProductHandler {
|
|||||||
throw new Error('Product not found');
|
throw new Error('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availability = await ProductService.getProductAvailability(productId);
|
||||||
|
if (!availability || !availability.loc_active || !availability.cat_active) {
|
||||||
|
await bot.answerCallbackQuery(callbackQuery.id);
|
||||||
|
const state = await userStates.get(chatId);
|
||||||
|
if (state?.photoMessageId) {
|
||||||
|
try { await bot.deleteMessage(chatId, state.photoMessageId); } catch (_) {}
|
||||||
|
}
|
||||||
|
await userStates.delete(chatId);
|
||||||
|
await bot.sendMessage(chatId, t('products.location_disabled'), {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Удаляем предыдущее сообщение
|
// Удаляем предыдущее сообщение
|
||||||
await bot.deleteMessage(chatId, messageId);
|
await bot.deleteMessage(chatId, messageId);
|
||||||
|
|
||||||
@@ -634,6 +654,20 @@ export default class UserProductHandler {
|
|||||||
throw new Error('Product not found');
|
throw new Error('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availability = await ProductService.getProductAvailability(productId);
|
||||||
|
if (!availability || !availability.loc_active || !availability.cat_active) {
|
||||||
|
await bot.answerCallbackQuery(callbackQuery.id);
|
||||||
|
await userStates.delete(chatId);
|
||||||
|
await editOrSendCallback(callbackQuery, t('products.location_disabled'), {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const quantity = product.is_mono ? 1 : (state?.quantity || 1);
|
const quantity = product.is_mono ? 1 : (state?.quantity || 1);
|
||||||
const totalPrice = product.price * quantity;
|
const totalPrice = product.price * quantity;
|
||||||
|
|
||||||
@@ -762,7 +796,21 @@ export default class UserProductHandler {
|
|||||||
throw new Error('Product not found');
|
throw new Error('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPrice = product.price * quantity;
|
const availability = await ProductService.getProductAvailability(productId);
|
||||||
|
if (!availability || !availability.loc_active || !availability.cat_active) {
|
||||||
|
await bot.answerCallbackQuery(callbackQuery.id);
|
||||||
|
await userStates.delete(chatId);
|
||||||
|
await editOrSendCallback(callbackQuery, t('products.location_disabled'), {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPrice = product.price * qty;
|
||||||
const balance = user.total_balance + user.bonus_balance;
|
const balance = user.total_balance + user.bonus_balance;
|
||||||
|
|
||||||
if (totalPrice > balance) {
|
if (totalPrice > balance) {
|
||||||
@@ -775,17 +823,17 @@ export default class UserProductHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Проверка наличия товара (skip for mono products)
|
// Проверка наличия товара (skip for mono products)
|
||||||
if (!product.is_mono && product.quantity_in_stock < quantity) {
|
if (!product.is_mono && product.quantity_in_stock < qty) {
|
||||||
await editOrSendCallback(callbackQuery, t('purchase.not_enough_stock', { count: product.quantity_in_stock }));
|
await editOrSendCallback(callbackQuery, t('purchase.not_enough_stock', { count: product.quantity_in_stock }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем покупку и получаем её ID
|
// Создаем покупку и получаем её ID
|
||||||
const purchaseId = await PurchaseService.createPurchase(user.id, productId, walletType, quantity, totalPrice);
|
const purchaseId = await PurchaseService.createPurchase(user.id, productId, walletType, qty, totalPrice);
|
||||||
|
|
||||||
// Уменьшаем количество товара в базе данных (skip for mono products)
|
// Уменьшаем количество товара в базе данных (skip for mono products)
|
||||||
if (!product.is_mono) {
|
if (!product.is_mono) {
|
||||||
await ProductService.decreaseProductQuantity(productId, quantity);
|
await ProductService.decreaseProductQuantity(productId, qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекаем данные о локации
|
// Извлекаем данные о локации
|
||||||
@@ -810,7 +858,7 @@ export default class UserProductHandler {
|
|||||||
const message = `
|
const message = `
|
||||||
${t('purchase.details')}
|
${t('purchase.details')}
|
||||||
${t('purchase.product')}: ${product.name}
|
${t('purchase.product')}: ${product.name}
|
||||||
${t('purchase.quantity')}: ${quantity}
|
${t('purchase.quantity')}: ${qty}
|
||||||
${t('purchase.total')}: $${totalPrice}
|
${t('purchase.total')}: $${totalPrice}
|
||||||
${t('purchase.location')}: ${location?.country || 'N/A'}, ${location?.city || 'N/A'}, ${location?.district || 'N/A'}
|
${t('purchase.location')}: ${location?.country || 'N/A'}, ${location?.city || 'N/A'}, ${location?.district || 'N/A'}
|
||||||
${t('purchase.category')}: ${category?.name || 'N/A'}
|
${t('purchase.category')}: ${category?.name || 'N/A'}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
"error_loading_categories": "Fehler beim Laden der Kategorien. Bitte versuche es erneut.",
|
"error_loading_categories": "Fehler beim Laden der Kategorien. Bitte versuche es erneut.",
|
||||||
"error_loading_product": "Fehler beim Laden der Produktdetails. Bitte versuche es erneut.",
|
"error_loading_product": "Fehler beim Laden der Produktdetails. Bitte versuche es erneut.",
|
||||||
"not_found": "Standort nicht gefunden. Zurück zum vorherigen Menü.",
|
"not_found": "Standort nicht gefunden. Zurück zum vorherigen Menü.",
|
||||||
|
"location_disabled": "Dieser Standort ist nicht mehr verfügbar.",
|
||||||
|
"back_to_main": "🏠 Hauptmenü",
|
||||||
"mono_product": "📦 Digitales Produkt",
|
"mono_product": "📦 Digitales Produkt",
|
||||||
"infinite_stock": "∞ Immer verfügbar",
|
"infinite_stock": "∞ Immer verfügbar",
|
||||||
"no_description": "Keine Beschreibung",
|
"no_description": "Keine Beschreibung",
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
"error_loading_categories": "Error loading categories. Please try again.",
|
"error_loading_categories": "Error loading categories. Please try again.",
|
||||||
"error_loading_product": "Error loading product details. Please try again.",
|
"error_loading_product": "Error loading product details. Please try again.",
|
||||||
"not_found": "Location not found. Returning to previous menu.",
|
"not_found": "Location not found. Returning to previous menu.",
|
||||||
|
"location_disabled": "This location is no longer available.",
|
||||||
|
"back_to_main": "🏠 Main Menu",
|
||||||
"mono_product": "📦 Digital Product",
|
"mono_product": "📦 Digital Product",
|
||||||
"infinite_stock": "∞ Always available",
|
"infinite_stock": "∞ Always available",
|
||||||
"no_description": "No description provided",
|
"no_description": "No description provided",
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
"error_loading_categories": "Error al cargar categorías. Inténtalo de nuevo.",
|
"error_loading_categories": "Error al cargar categorías. Inténtalo de nuevo.",
|
||||||
"error_loading_product": "Error al cargar detalles del producto. Inténtalo de nuevo.",
|
"error_loading_product": "Error al cargar detalles del producto. Inténtalo de nuevo.",
|
||||||
"not_found": "Ubicación no encontrada. Volviendo al menú anterior.",
|
"not_found": "Ubicación no encontrada. Volviendo al menú anterior.",
|
||||||
|
"location_disabled": "Esta ubicación ya no está disponible.",
|
||||||
|
"back_to_main": "🏠 Menú Principal",
|
||||||
"mono_product": "📦 Producto Digital",
|
"mono_product": "📦 Producto Digital",
|
||||||
"infinite_stock": "∞ Siempre disponible",
|
"infinite_stock": "∞ Siempre disponible",
|
||||||
"no_description": "Sin descripción",
|
"no_description": "Sin descripción",
|
||||||
|
|||||||
17
src/index.js
17
src/index.js
@@ -10,6 +10,12 @@ import callbackRouter from './router/callbackRouter.js';
|
|||||||
import messageRouter from './router/messageRouter.js';
|
import messageRouter from './router/messageRouter.js';
|
||||||
|
|
||||||
import { initStates } from './services/stateService.js';
|
import { initStates } from './services/stateService.js';
|
||||||
|
import { resetUserContext } from './utils/messageUtils.js';
|
||||||
|
import { tryAcquireLock, pruneLocks } from './utils/callbackLock.js';
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
pruneLocks();
|
||||||
|
}, 3600_000);
|
||||||
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
await cleanUpInvalidForeignKeys();
|
await cleanUpInvalidForeignKeys();
|
||||||
@@ -21,6 +27,7 @@ if (bot && botAvailable) {
|
|||||||
const canUse = await userHandler.canUseBot(msg);
|
const canUse = await userHandler.canUseBot(msg);
|
||||||
if (!canUse) return;
|
if (!canUse) return;
|
||||||
try {
|
try {
|
||||||
|
await resetUserContext(msg.chat.id);
|
||||||
await userHandler.handleStart(msg);
|
await userHandler.handleStart(msg);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ErrorHandler.handleError(bot, msg.chat.id, error, 'start command');
|
await ErrorHandler.handleError(bot, msg.chat.id, error, 'start command');
|
||||||
@@ -60,10 +67,20 @@ if (bot && botAvailable) {
|
|||||||
await bot.answerCallbackQuery(callbackQuery.id);
|
await bot.answerCallbackQuery(callbackQuery.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const userId = callbackQuery.from?.id;
|
||||||
|
if (userId && !tryAcquireLock(userId)) {
|
||||||
|
await bot.answerCallbackQuery(callbackQuery.id, { text: '⏳' }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let answered = false;
|
||||||
try {
|
try {
|
||||||
await callbackRouter.dispatch(callbackQuery);
|
await callbackRouter.dispatch(callbackQuery);
|
||||||
await bot.answerCallbackQuery(callbackQuery.id);
|
await bot.answerCallbackQuery(callbackQuery.id);
|
||||||
|
answered = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!answered) {
|
||||||
|
await bot.answerCallbackQuery(callbackQuery.id).catch(() => {});
|
||||||
|
}
|
||||||
await ErrorHandler.handleError(bot, callbackQuery.message.chat.id, error, 'callback query');
|
await ErrorHandler.handleError(bot, callbackQuery.message.chat.id, error, 'callback query');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import callbackRouter from './callbackRouter.js';
|
|||||||
import messageRouter from './messageRouter.js';
|
import messageRouter from './messageRouter.js';
|
||||||
import { isAdmin } from '../middleware/auth.js';
|
import { isAdmin } from '../middleware/auth.js';
|
||||||
import logger from '../utils/logger.js';
|
import logger from '../utils/logger.js';
|
||||||
|
import { resetUserContext } from '../utils/messageUtils.js';
|
||||||
|
|
||||||
import userHandler from '../handlers/userHandlers/userHandler.js';
|
import userHandler from '../handlers/userHandlers/userHandler.js';
|
||||||
import userPurchaseHandler from '../handlers/userHandlers/userPurchaseHandler.js';
|
import userPurchaseHandler from '../handlers/userHandlers/userPurchaseHandler.js';
|
||||||
@@ -40,18 +41,22 @@ export function registerRoutes() {
|
|||||||
|
|
||||||
// === Text Commands ===
|
// === Text Commands ===
|
||||||
messageRouter.registerText('keyboard.products', async (msg) => {
|
messageRouter.registerText('keyboard.products', async (msg) => {
|
||||||
|
await resetUserContext(msg.chat.id);
|
||||||
logDebug(msg.text, 'showProducts');
|
logDebug(msg.text, 'showProducts');
|
||||||
await userProductHandler.showProducts(msg);
|
await userProductHandler.showProducts(msg);
|
||||||
});
|
});
|
||||||
messageRouter.registerText('keyboard.profile', async (msg) => {
|
messageRouter.registerText('keyboard.profile', async (msg) => {
|
||||||
|
await resetUserContext(msg.chat.id);
|
||||||
logDebug(msg.text, 'showProfile');
|
logDebug(msg.text, 'showProfile');
|
||||||
await userHandler.showProfile(msg);
|
await userHandler.showProfile(msg);
|
||||||
});
|
});
|
||||||
messageRouter.registerText('keyboard.wallets', async (msg) => {
|
messageRouter.registerText('keyboard.wallets', async (msg) => {
|
||||||
|
await resetUserContext(msg.chat.id);
|
||||||
logDebug(msg.text, 'showBalance');
|
logDebug(msg.text, 'showBalance');
|
||||||
await userWalletsHandler.showBalance(msg);
|
await userWalletsHandler.showBalance(msg);
|
||||||
});
|
});
|
||||||
messageRouter.registerText('keyboard.purchases', async (msg) => {
|
messageRouter.registerText('keyboard.purchases', async (msg) => {
|
||||||
|
await resetUserContext(msg.chat.id);
|
||||||
logDebug(msg.text, 'showPurchases');
|
logDebug(msg.text, 'showPurchases');
|
||||||
await userPurchaseHandler.showPurchases(msg);
|
await userPurchaseHandler.showPurchases(msg);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ class LocationService {
|
|||||||
throw new Error('Failed to fetch location');
|
throw new Error('Failed to fetch location');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getActiveLocationById(locationId) {
|
||||||
|
try {
|
||||||
|
const location = await db.getAsync(
|
||||||
|
'SELECT * FROM locations WHERE id = ? AND is_active = 1',
|
||||||
|
[locationId]
|
||||||
|
);
|
||||||
|
return location;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, 'Error fetching active location by ID');
|
||||||
|
throw new Error('Failed to fetch location');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LocationService;
|
export default LocationService;
|
||||||
@@ -58,6 +58,20 @@ class ProductService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getProductAvailability(productId) {
|
||||||
|
if (!Validators.isValidNumericId(Number(productId))) {
|
||||||
|
throw new Error('Invalid product ID');
|
||||||
|
}
|
||||||
|
return await db.getAsync(
|
||||||
|
`SELECT p.*, l.is_active as loc_active, c.is_active as cat_active
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN locations l ON p.location_id = l.id
|
||||||
|
LEFT JOIN categories c ON p.category_id = c.id
|
||||||
|
WHERE p.id = ?`,
|
||||||
|
[productId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static async decreaseProductQuantity(productId, quantity) {
|
static async decreaseProductQuantity(productId, quantity) {
|
||||||
if (!Validators.isValidNumericId(Number(productId))) {
|
if (!Validators.isValidNumericId(Number(productId))) {
|
||||||
throw new Error('Invalid product ID');
|
throw new Error('Invalid product ID');
|
||||||
|
|||||||
17
src/utils/callbackLock.js
Normal file
17
src/utils/callbackLock.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const callbackLocks = new Map();
|
||||||
|
export const LOCK_MS = 1500;
|
||||||
|
|
||||||
|
export function tryAcquireLock(userId) {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = callbackLocks.get(userId) || 0;
|
||||||
|
if (now - last < LOCK_MS) return false;
|
||||||
|
callbackLocks.set(userId, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneLocks() {
|
||||||
|
const cutoff = Date.now() - LOCK_MS;
|
||||||
|
for (const [k, v] of callbackLocks) {
|
||||||
|
if (v < cutoff) callbackLocks.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
import bot from '../context/bot.js';
|
import bot from '../context/bot.js';
|
||||||
|
import userStates from '../context/userStates.js';
|
||||||
|
|
||||||
|
export async function resetUserContext(chatId) {
|
||||||
|
try {
|
||||||
|
const state = await userStates.get(chatId);
|
||||||
|
if (state) {
|
||||||
|
const msgIds = [state.photoMessageId, state.productMessageId, state.hiddenPhotoMessageId].filter(Boolean);
|
||||||
|
for (const msgId of msgIds) {
|
||||||
|
try { await bot.deleteMessage(chatId, msgId); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await userStates.delete(chatId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
export async function editOrSend(chatId, messageId, text, options = {}) {
|
export async function editOrSend(chatId, messageId, text, options = {}) {
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user