diff --git a/VERSION.md b/VERSION.md
index 14a2f99..f2fba22 100644
--- a/VERSION.md
+++ b/VERSION.md
@@ -9,10 +9,17 @@
## Current Version
-**v1.2.1** — 2026-07-18
+**v1.2.2** — 2026-08-04
## 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
- **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
diff --git a/src/__tests__/botBugFixes.test.js b/src/__tests__/botBugFixes.test.js
new file mode 100644
index 0000000..a072436
--- /dev/null
+++ b/src/__tests__/botBugFixes.test.js
@@ -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);
+ });
+});
diff --git a/src/__tests__/userProductHandler.test.js b/src/__tests__/userProductHandler.test.js
index 742ecaf..9908368 100644
--- a/src/__tests__/userProductHandler.test.js
+++ b/src/__tests__/userProductHandler.test.js
@@ -12,7 +12,9 @@ const en = {
infinite_stock: '∞ Always available',
no_description: 'No description provided',
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 = {
deleteMessage: vi.fn(),
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 };
});
@@ -30,7 +33,7 @@ vi.mock('../context/userStates.js', () => ({
default: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
- delete: vi.fn()
+ delete: vi.fn().mockResolvedValue(undefined)
}
}));
@@ -50,7 +53,8 @@ vi.mock('../services/productService.js', () => ({
__esModule: true,
default: {
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', () => {
beforeEach(() => {
vi.clearAllMocks();
+ ProductService.getProductAvailability.mockResolvedValue(baseAvailability());
});
it('renders fallback text when description and photo are missing, sends message, does not send photo', async () => {
diff --git a/src/admin/views/partials/app-sidebar.ejs b/src/admin/views/partials/app-sidebar.ejs
index 1626f1c..d87e6c3 100644
--- a/src/admin/views/partials/app-sidebar.ejs
+++ b/src/admin/views/partials/app-sidebar.ejs
@@ -28,7 +28,7 @@
- v1.2.1
+ v1.2.2
@@ -43,10 +43,10 @@
- Current: v1.2.1 · 2026-07-18
+ Current: v1.2.2 · 2026-08-04
-
v1.2.1 — 2026-07-18
+
v1.2.2 — 2026-08-04
- refactor Removed deposit amount-selection step; deposit_wallet_ now goes directly to Mercuryo instructions
- feat Updated Mercuryo button text to include VISA/Mastercard branding in all locales
diff --git a/src/handlers/userHandlers/userHandler.js b/src/handlers/userHandlers/userHandler.js
index 003d1bd..877782a 100644
--- a/src/handlers/userHandlers/userHandler.js
+++ b/src/handlers/userHandlers/userHandler.js
@@ -5,6 +5,7 @@ import bot from "../../context/bot.js";
import UserService from "../../services/userService.js";
import WalletService from "../../services/walletService.js";
import logger from "../../utils/logger.js";
+import { resetUserContext } from "../../utils/messageUtils.js";
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
export default class UserHandler {
@@ -100,6 +101,7 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
const username = msg.chat.username;
try {
+ await resetUserContext(chatId);
await UserService.createUser({
telegram_id: telegramId,
username: username
diff --git a/src/handlers/userHandlers/userProductHandler.js b/src/handlers/userHandlers/userProductHandler.js
index 7eb6b22..a6e4226 100644
--- a/src/handlers/userHandlers/userProductHandler.js
+++ b/src/handlers/userHandlers/userProductHandler.js
@@ -187,17 +187,19 @@ export default class UserProductHandler {
const lang = user?.language || 'en';
const t = tForUser(lang);
- const location = await LocationService.getLocationById(locationId);
+ const location = await LocationService.getActiveLocationById(locationId);
if (!location) {
+ await bot.answerCallbackQuery(callbackQuery.id);
+ await userStates.delete(chatId);
await bot.editMessageText(
- t('products.not_found'),
+ t('products.location_disabled'),
{
chat_id: chatId,
message_id: messageId,
reply_markup: {
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' }
]]
}
}
@@ -411,6 +413,24 @@ export default class UserProductHandler {
if (!product) {
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);
@@ -633,7 +653,21 @@ export default class UserProductHandler {
if (!product) {
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 totalPrice = product.price * quantity;
@@ -761,8 +795,22 @@ export default class UserProductHandler {
if (!product) {
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;
if (totalPrice > balance) {
@@ -775,17 +823,17 @@ export default class UserProductHandler {
}
// Проверка наличия товара (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 }));
return;
}
// Создаем покупку и получаем её 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)
if (!product.is_mono) {
- await ProductService.decreaseProductQuantity(productId, quantity);
+ await ProductService.decreaseProductQuantity(productId, qty);
}
// Извлекаем данные о локации
@@ -810,7 +858,7 @@ export default class UserProductHandler {
const message = `
${t('purchase.details')}
${t('purchase.product')}: ${product.name}
- ${t('purchase.quantity')}: ${quantity}
+ ${t('purchase.quantity')}: ${qty}
${t('purchase.total')}: $${totalPrice}
${t('purchase.location')}: ${location?.country || 'N/A'}, ${location?.city || 'N/A'}, ${location?.district || 'N/A'}
${t('purchase.category')}: ${category?.name || 'N/A'}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 63c4093..ad129f1 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -54,6 +54,8 @@
"error_loading_categories": "Fehler beim Laden der Kategorien. 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ü.",
+ "location_disabled": "Dieser Standort ist nicht mehr verfügbar.",
+ "back_to_main": "🏠 Hauptmenü",
"mono_product": "📦 Digitales Produkt",
"infinite_stock": "∞ Immer verfügbar",
"no_description": "Keine Beschreibung",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 79ddd99..0ad9385 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -54,6 +54,8 @@
"error_loading_categories": "Error loading categories. Please try again.",
"error_loading_product": "Error loading product details. Please try again.",
"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",
"infinite_stock": "∞ Always available",
"no_description": "No description provided",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index b035fac..c98519b 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -54,6 +54,8 @@
"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.",
"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",
"infinite_stock": "∞ Siempre disponible",
"no_description": "Sin descripción",
diff --git a/src/index.js b/src/index.js
index 4c309e6..dd8559b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,6 +10,12 @@ import callbackRouter from './router/callbackRouter.js';
import messageRouter from './router/messageRouter.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 cleanUpInvalidForeignKeys();
@@ -21,6 +27,7 @@ if (bot && botAvailable) {
const canUse = await userHandler.canUseBot(msg);
if (!canUse) return;
try {
+ await resetUserContext(msg.chat.id);
await userHandler.handleStart(msg);
} catch (error) {
await ErrorHandler.handleError(bot, msg.chat.id, error, 'start command');
@@ -60,10 +67,20 @@ if (bot && botAvailable) {
await bot.answerCallbackQuery(callbackQuery.id);
return;
}
+ const userId = callbackQuery.from?.id;
+ if (userId && !tryAcquireLock(userId)) {
+ await bot.answerCallbackQuery(callbackQuery.id, { text: '⏳' }).catch(() => {});
+ return;
+ }
+ let answered = false;
try {
await callbackRouter.dispatch(callbackQuery);
await bot.answerCallbackQuery(callbackQuery.id);
+ answered = true;
} catch (error) {
+ if (!answered) {
+ await bot.answerCallbackQuery(callbackQuery.id).catch(() => {});
+ }
await ErrorHandler.handleError(bot, callbackQuery.message.chat.id, error, 'callback query');
}
});
diff --git a/src/router/routes.js b/src/router/routes.js
index 419b6cd..29bfd24 100644
--- a/src/router/routes.js
+++ b/src/router/routes.js
@@ -2,6 +2,7 @@ import callbackRouter from './callbackRouter.js';
import messageRouter from './messageRouter.js';
import { isAdmin } from '../middleware/auth.js';
import logger from '../utils/logger.js';
+import { resetUserContext } from '../utils/messageUtils.js';
import userHandler from '../handlers/userHandlers/userHandler.js';
import userPurchaseHandler from '../handlers/userHandlers/userPurchaseHandler.js';
@@ -40,18 +41,22 @@ export function registerRoutes() {
// === Text Commands ===
messageRouter.registerText('keyboard.products', async (msg) => {
+ await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showProducts');
await userProductHandler.showProducts(msg);
});
messageRouter.registerText('keyboard.profile', async (msg) => {
+ await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showProfile');
await userHandler.showProfile(msg);
});
messageRouter.registerText('keyboard.wallets', async (msg) => {
+ await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showBalance');
await userWalletsHandler.showBalance(msg);
});
messageRouter.registerText('keyboard.purchases', async (msg) => {
+ await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showPurchases');
await userPurchaseHandler.showPurchases(msg);
});
diff --git a/src/services/locationService.js b/src/services/locationService.js
index 2a67bc2..5e08cd8 100644
--- a/src/services/locationService.js
+++ b/src/services/locationService.js
@@ -52,6 +52,19 @@ class LocationService {
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;
\ No newline at end of file
diff --git a/src/services/productService.js b/src/services/productService.js
index 1863e70..97ef47f 100644
--- a/src/services/productService.js
+++ b/src/services/productService.js
@@ -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) {
if (!Validators.isValidNumericId(Number(productId))) {
throw new Error('Invalid product ID');
diff --git a/src/utils/callbackLock.js b/src/utils/callbackLock.js
new file mode 100644
index 0000000..d34ff1f
--- /dev/null
+++ b/src/utils/callbackLock.js
@@ -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);
+ }
+}
diff --git a/src/utils/messageUtils.js b/src/utils/messageUtils.js
index e842130..924800d 100644
--- a/src/utils/messageUtils.js
+++ b/src/utils/messageUtils.js
@@ -1,4 +1,20 @@
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 = {}) {
if (messageId) {