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:
@@ -92,7 +92,8 @@ vi.mock('../services/userService.js', () => ({
|
||||
vi.mock('../services/categoryService.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getCategoriesByLocationId: vi.fn().mockResolvedValue([])
|
||||
getCategoriesByLocationId: vi.fn().mockResolvedValue([]),
|
||||
getCategoriesWithProductsByLocationId: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<svg class="sa-icon sa-thin">
|
||||
<use href="/icons/sprite.svg#tag"></use>
|
||||
</svg>
|
||||
<span class="fs-xs opacity-70">v1.2.2</span>
|
||||
<span class="fs-xs opacity-70">v1.2.3</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -43,10 +43,10 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -212,7 +212,24 @@ export default class UserProductHandler {
|
||||
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 = {
|
||||
inline_keyboard: [
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"no_products": "Aktuell keine Produkte verfügbar.",
|
||||
"no_products_category": "Keine Produkte in dieser Kategorie.",
|
||||
"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_cities": "« Zurück zu den Städten",
|
||||
"back_to_subcategories": "« Zurück zu den Unterkategorien",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"no_products": "No products available at the moment.",
|
||||
"no_products_category": "No products available in this category.",
|
||||
"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_cities": "« Back to Cities",
|
||||
"back_to_subcategories": "« Back to Subcategories",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"no_products": "No hay productos disponibles en este momento.",
|
||||
"no_products_category": "No hay productos disponibles en esta categorí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_cities": "« Volver a ciudades",
|
||||
"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('./010_is_mono_product.js')).default,
|
||||
(await import('./011_active_flags.js')).default,
|
||||
(await import('./012_fix_typos.js')).default,
|
||||
];
|
||||
|
||||
for (let i = currentVersion; i < migrations.length; i++) {
|
||||
|
||||
@@ -23,6 +23,22 @@ const logDebug = (action, functionName) => {
|
||||
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() {
|
||||
// === Message Input Handlers (order matters — checked first) ===
|
||||
messageRouter.registerInput(adminLocationHandler.handleLocationInput.bind(adminLocationHandler));
|
||||
@@ -41,21 +57,25 @@ export function registerRoutes() {
|
||||
|
||||
// === Text Commands ===
|
||||
messageRouter.registerText('keyboard.products', async (msg) => {
|
||||
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.products')) return;
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showProducts');
|
||||
await userProductHandler.showProducts(msg);
|
||||
});
|
||||
messageRouter.registerText('keyboard.profile', async (msg) => {
|
||||
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.profile')) return;
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showProfile');
|
||||
await userHandler.showProfile(msg);
|
||||
});
|
||||
messageRouter.registerText('keyboard.wallets', async (msg) => {
|
||||
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.wallets')) return;
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showBalance');
|
||||
await userWalletsHandler.showBalance(msg);
|
||||
});
|
||||
messageRouter.registerText('keyboard.purchases', async (msg) => {
|
||||
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.purchases')) return;
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showPurchases');
|
||||
await userPurchaseHandler.showPurchases(msg);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import db from "../config/database.js";
|
||||
import logger from "../utils/logger.js";
|
||||
import Validators from "../utils/validators.js";
|
||||
|
||||
class CategoryService {
|
||||
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) {
|
||||
return await db.allAsync(
|
||||
'SELECT id, name FROM subcategories WHERE category_id = ? AND is_active = 1 ORDER BY name',
|
||||
|
||||
Reference in New Issue
Block a user