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:
NW
2026-08-04 15:21:55 +01:00
parent 29ab8f9d34
commit bd3391c111
15 changed files with 416 additions and 18 deletions

View File

@@ -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

View 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);
});
});

View File

@@ -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 () => {

View File

@@ -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 &middot; 2026-07-18 <strong>Current:</strong> v1.2.2 &middot; 2026-08-04
</div> </div>
<h6 class="fw-bold mb-2">v1.2.1 <span class="text-muted fs-sm">&mdash; 2026-07-18</span></h6> <h6 class="fw-bold mb-2">v1.2.2 <span class="text-muted fs-sm">&mdash; 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>

View File

@@ -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

View File

@@ -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'}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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');
} }
}); });

View File

@@ -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);
}); });

View File

@@ -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;

View File

@@ -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
View 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);
}
}

View File

@@ -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) {