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:
@@ -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)
|
||||||
|
|||||||
@@ -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([])
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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 · 2026-08-04
|
<strong>Current:</strong> v1.2.3 · 2026-08-04
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="fw-bold mb-2">v1.2.2 <span class="text-muted fs-sm">— 2026-08-04</span></h6>
|
<h6 class="fw-bold mb-2">v1.2.3 <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>
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
38
src/migrations/012_fix_typos.js
Normal file
38
src/migrations/012_fix_typos.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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++) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user