fix(bot): issue #128 — empty category menus, duplicate country lists, DB typo+desync migration

- getCategoriesWithProductsByLocationId: filter districts to categories with real in-stock/mono products; empty → no_categories message + back button + answerCallbackQuery
- Main-menu debounce (1200ms) in routes.js for products/profile/wallets/purchases — no duplicate Select-your-country on rapid taps
- Migration 012: fix typos (Centr→Center, Chiken→Chicken, Brusel→Brussels, Sever→North) + re-enable locations that have in-stock products (desync fix)
- Tests updated (48 pass); bump v1.2.3
This commit is contained in:
NW
2026-08-04 20:53:34 +01:00
parent 5c6760b180
commit b58087f37b
11 changed files with 106 additions and 6 deletions

View File

@@ -9,10 +9,16 @@
## Current Version ## Current Version
**v1.2.2** — 2026-08-04 **v1.2.3** — 2026-08-04
## Changelog ## Changelog
### v1.2.3 — 2026-08-04
- **fix**: empty category menus — districts with no in-stock products show "No products available in this district" (getCategoriesWithProductsByLocationId filters by real stock/mono)
- **fix**: duplicate "Select your country" messages on rapid Products tap — 1200ms main-menu debounce in routes.js
- **fix**: DB typos + desync — migration 012 (Centr→Center, Chiken→Chicken, Brusel→Brussels, Sever→North) and re-enables locations that actually have in-stock products
- **fix**: answerCallbackQuery on no_categories branch (no stuck loading spinner)
### v1.2.2 — 2026-08-04 ### v1.2.2 — 2026-08-04
- **fix**: BUG-01 — disabled locations/categories/subcategories filtered from bot menus (getActiveLocationById, is_active checks) - **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-02 — graceful handling of disabled entity during purchase flow (location_disabled notice + main-menu redirect, no crash)

View File

@@ -92,7 +92,8 @@ vi.mock('../services/userService.js', () => ({
vi.mock('../services/categoryService.js', () => ({ vi.mock('../services/categoryService.js', () => ({
__esModule: true, __esModule: true,
default: { default: {
getCategoriesByLocationId: vi.fn().mockResolvedValue([]) getCategoriesByLocationId: vi.fn().mockResolvedValue([]),
getCategoriesWithProductsByLocationId: vi.fn().mockResolvedValue([])
} }
})); }));

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

@@ -212,7 +212,24 @@ export default class UserProductHandler {
locationId: location.id locationId: location.id
}); });
const categories = await CategoryService.getCategoriesByLocationId(location.id); const categories = await CategoryService.getCategoriesWithProductsByLocationId(location.id);
if (categories.length === 0) {
await bot.answerCallbackQuery(callbackQuery.id);
await bot.editMessageText(
t('products.no_categories'),
{
chat_id: chatId,
message_id: messageId,
reply_markup: {
inline_keyboard: [[
{ text: t('products.back'), callback_data: `shop_city_${encodeURIComponent(location.country)}|${encodeURIComponent(location.city)}` }
]]
}
}
);
return;
}
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [

View File

@@ -36,6 +36,7 @@
"no_products": "Aktuell keine Produkte verfügbar.", "no_products": "Aktuell keine Produkte verfügbar.",
"no_products_category": "Keine Produkte in dieser Kategorie.", "no_products_category": "Keine Produkte in dieser Kategorie.",
"no_products_subcategory": "Keine Produkte in dieser Unterkategorie.", "no_products_subcategory": "Keine Produkte in dieser Unterkategorie.",
"no_categories": "In diesem Bezirk sind keine Produkte verfügbar.",
"back_to_countries": "« Zurück zu den Ländern", "back_to_countries": "« Zurück zu den Ländern",
"back_to_cities": "« Zurück zu den Städten", "back_to_cities": "« Zurück zu den Städten",
"back_to_subcategories": "« Zurück zu den Unterkategorien", "back_to_subcategories": "« Zurück zu den Unterkategorien",

View File

@@ -36,6 +36,7 @@
"no_products": "No products available at the moment.", "no_products": "No products available at the moment.",
"no_products_category": "No products available in this category.", "no_products_category": "No products available in this category.",
"no_products_subcategory": "No products available in this subcategory.", "no_products_subcategory": "No products available in this subcategory.",
"no_categories": "No products available in this district.",
"back_to_countries": "« Back to Countries", "back_to_countries": "« Back to Countries",
"back_to_cities": "« Back to Cities", "back_to_cities": "« Back to Cities",
"back_to_subcategories": "« Back to Subcategories", "back_to_subcategories": "« Back to Subcategories",

View File

@@ -36,6 +36,7 @@
"no_products": "No hay productos disponibles en este momento.", "no_products": "No hay productos disponibles en este momento.",
"no_products_category": "No hay productos disponibles en esta categoría.", "no_products_category": "No hay productos disponibles en esta categoría.",
"no_products_subcategory": "No hay productos disponibles en esta subcategoría.", "no_products_subcategory": "No hay productos disponibles en esta subcategoría.",
"no_categories": "No hay productos disponibles en este distrito.",
"back_to_countries": "« Volver a países", "back_to_countries": "« Volver a países",
"back_to_cities": "« Volver a ciudades", "back_to_cities": "« Volver a ciudades",
"back_to_subcategories": "« Volver a subcategorías", "back_to_subcategories": "« Volver a subcategorías",

View File

@@ -0,0 +1,38 @@
import logger from '../utils/logger.js';
export default async function migration012(db) {
await db.runAsync('BEGIN TRANSACTION');
try {
// Точечные исправления опечаток с контекстом (country/city/name),
// чтобы не задеть другие записи с похожими строками
await db.runAsync(
"UPDATE locations SET district = 'Center' WHERE district = 'Centr' AND country = 'Spain' AND city = 'Barcelona'"
);
await db.runAsync(
"UPDATE locations SET city = 'Brussels' WHERE city = 'Brusel' AND country = 'Belgium'"
);
await db.runAsync(
"UPDATE locations SET district = 'North' WHERE district = 'Sever' AND country = 'Belgium'"
);
await db.runAsync(
"UPDATE categories SET name = 'Chicken' WHERE name = 'Chiken'"
);
// Восстановление синхронизации: локации с реально доступными товарами
// (в наличии или цифровые) включаются, чтобы список стран в боте
// соответствовал фактически покупаемым товарам. Админ мог отключить
// локацию, забыв перенести товары, — это чинит "пропавшие" страны.
await db.runAsync(
`UPDATE locations SET is_active = 1
WHERE id IN (
SELECT DISTINCT p.location_id
FROM products p
WHERE p.quantity_in_stock > 0 OR p.is_mono = 1
)`
);
await db.runAsync('COMMIT');
logger.info('Migration 012: Fixed typos and restored active locations with stock');
} catch (e) {
await db.runAsync('ROLLBACK');
throw e;
}
}

View File

@@ -46,6 +46,7 @@ export async function runMigrations() {
(await import('./009_user_language_set.js')).default, (await import('./009_user_language_set.js')).default,
(await import('./010_is_mono_product.js')).default, (await import('./010_is_mono_product.js')).default,
(await import('./011_active_flags.js')).default, (await import('./011_active_flags.js')).default,
(await import('./012_fix_typos.js')).default,
]; ];
for (let i = currentVersion; i < migrations.length; i++) { for (let i = currentVersion; i < migrations.length; i++) {

View File

@@ -23,6 +23,22 @@ const logDebug = (action, functionName) => {
logger.debug({ action, functionName }, 'Button Press'); logger.debug({ action, functionName }, 'Button Press');
}; };
const lastMainMenuPress = new Map();
const MAIN_MENU_DEBOUNCE_MS = 1200;
function shouldDebounceMainMenu(chatId, textKey) {
const key = `${chatId}:${textKey}`;
const now = Date.now();
const last = lastMainMenuPress.get(key);
if (last && (now - last) < MAIN_MENU_DEBOUNCE_MS) return true;
lastMainMenuPress.set(key, now);
// Prune old entries (>5s)
for (const [k, ts] of lastMainMenuPress) {
if (now - ts > 5000) lastMainMenuPress.delete(k);
}
return false;
}
export function registerRoutes() { export function registerRoutes() {
// === Message Input Handlers (order matters — checked first) === // === Message Input Handlers (order matters — checked first) ===
messageRouter.registerInput(adminLocationHandler.handleLocationInput.bind(adminLocationHandler)); messageRouter.registerInput(adminLocationHandler.handleLocationInput.bind(adminLocationHandler));
@@ -41,21 +57,25 @@ export function registerRoutes() {
// === Text Commands === // === Text Commands ===
messageRouter.registerText('keyboard.products', async (msg) => { messageRouter.registerText('keyboard.products', async (msg) => {
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.products')) return;
await resetUserContext(msg.chat.id); 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) => {
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.profile')) return;
await resetUserContext(msg.chat.id); 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) => {
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.wallets')) return;
await resetUserContext(msg.chat.id); 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) => {
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.purchases')) return;
await resetUserContext(msg.chat.id); await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showPurchases'); logDebug(msg.text, 'showPurchases');
await userPurchaseHandler.showPurchases(msg); await userPurchaseHandler.showPurchases(msg);

View File

@@ -1,5 +1,6 @@
import db from "../config/database.js"; import db from "../config/database.js";
import logger from "../utils/logger.js"; import logger from "../utils/logger.js";
import Validators from "../utils/validators.js";
class CategoryService { class CategoryService {
static async getCategoriesByLocationId(locationId) { static async getCategoriesByLocationId(locationId) {
@@ -15,6 +16,19 @@ class CategoryService {
} }
} }
static async getCategoriesWithProductsByLocationId(locationId) {
if (!Validators.isValidNumericId(Number(locationId))) throw new Error('Invalid location ID');
return await db.allAsync(
`SELECT DISTINCT c.id, c.name
FROM categories c
JOIN products p ON p.category_id = c.id
WHERE c.location_id = ? AND c.is_active = 1
AND (p.quantity_in_stock > 0 OR p.is_mono = 1)
ORDER BY c.name`,
[locationId]
);
}
static async getSubcategoriesByCategoryId(categoryId) { static async getSubcategoriesByCategoryId(categoryId) {
return await db.allAsync( return await db.allAsync(
'SELECT id, name FROM subcategories WHERE category_id = ? AND is_active = 1 ORDER BY name', 'SELECT id, name FROM subcategories WHERE category_id = ? AND is_active = 1 ORDER BY name',