diff --git a/src/context/userStates.js b/src/context/userStates.js index d341377..41704b7 100644 --- a/src/context/userStates.js +++ b/src/context/userStates.js @@ -1,2 +1,11 @@ -const userStates = new Map(); +import { get, set, del, has, initStates } from '../services/stateService.js'; + +const userStates = { + get: (chatId) => get(chatId), + set: (chatId, value) => set(chatId, value), + delete: (chatId) => del(chatId), + has: (chatId) => has(chatId), + initStates, +}; + export default userStates; \ No newline at end of file diff --git a/src/handlers/adminHandlers/adminDumpHandler.js b/src/handlers/adminHandlers/adminDumpHandler.js index 1849a98..3ff70bc 100644 --- a/src/handlers/adminHandlers/adminDumpHandler.js +++ b/src/handlers/adminHandlers/adminDumpHandler.js @@ -84,7 +84,7 @@ export default class AdminDumpHandler { const chatId = callbackQuery.message.chat.id; - userStates.set(chatId, { action: 'upload_database_dump' }); + await userStates.set(chatId, { action: 'upload_database_dump' }); await bot.editMessageText( 'Please upload database dump', @@ -121,7 +121,7 @@ export default class AdminDumpHandler { static async handleDumpImport(msg) { const chatId = msg.chat.id; - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!state || state.action !== 'upload_database_dump') { return false; @@ -145,7 +145,7 @@ export default class AdminDumpHandler { const statistics = await this.getDumpStatistic(); await bot.sendMessage(chatId, JSON.stringify(statistics, null, 2)); - userStates.delete(chatId); + await userStates.delete(chatId); } else { await bot.sendMessage(chatId, 'Please upload a valid .zip file.'); return true; diff --git a/src/handlers/adminHandlers/adminLocationHandler.js b/src/handlers/adminHandlers/adminLocationHandler.js index f431494..4eed60f 100644 --- a/src/handlers/adminHandlers/adminLocationHandler.js +++ b/src/handlers/adminHandlers/adminLocationHandler.js @@ -14,7 +14,7 @@ export default class AdminLocationHandler { const chatId = callbackQuery.message.chat.id; - userStates.set(chatId, { action: 'add_location' }); + await userStates.set(chatId, { action: 'add_location' }); await bot.editMessageText( 'Please enter the location in the following format:\nCountry|City|District', @@ -30,7 +30,7 @@ export default class AdminLocationHandler { static async handleLocationInput(msg) { const chatId = msg.chat.id; - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!state || state.action !== 'add_location') { return false; @@ -87,7 +87,7 @@ export default class AdminLocationHandler { throw new Error('Failed to insert location'); } - userStates.delete(chatId); + await userStates.delete(chatId); } catch (error) { await db.runAsync('ROLLBACK'); @@ -166,7 +166,7 @@ export default class AdminLocationHandler { return; } - userStates.delete(chatId); + await userStates.delete(chatId); try { const locations = await db.allAsync(` @@ -362,6 +362,6 @@ export default class AdminLocationHandler { } ); - userStates.delete(chatId); + await userStates.delete(chatId); } } diff --git a/src/handlers/adminHandlers/adminUserHandler.js b/src/handlers/adminHandlers/adminUserHandler.js index 6ca3829..fff82af 100644 --- a/src/handlers/adminHandlers/adminUserHandler.js +++ b/src/handlers/adminHandlers/adminUserHandler.js @@ -449,7 +449,7 @@ export default class AdminUserHandler { } ); - userStates.set(chatId, { action: "edit_bonus_balance", telegram_id: telegramId }); + await userStates.set(chatId, { action: "edit_bonus_balance", telegram_id: telegramId }); } catch (error) { logger.error({ err: error }, 'Error in handleEditUserBalance'); await bot.sendMessage(chatId, 'Error loading user wallets. Please try again.'); @@ -462,7 +462,7 @@ export default class AdminUserHandler { } const chatId = msg.chat.id; - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!state || state.action !== 'edit_bonus_balance') { return false; @@ -482,6 +482,6 @@ export default class AdminUserHandler { await bot.sendMessage(chatId, 'Something went wrong'); } - userStates.delete(chatId); + await userStates.delete(chatId); } } \ No newline at end of file diff --git a/src/handlers/adminHandlers/product/categoryAddHandler.js b/src/handlers/adminHandlers/product/categoryAddHandler.js index 652c756..ee9add0 100644 --- a/src/handlers/adminHandlers/product/categoryAddHandler.js +++ b/src/handlers/adminHandlers/product/categoryAddHandler.js @@ -10,7 +10,7 @@ export default class CategoryAddHandler { static async handleCategoryInput(msg) { const chatId = msg.chat.id; - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!state || !state.action?.startsWith('add_category_')) { return false; @@ -51,7 +51,7 @@ export default class CategoryAddHandler { } ); - userStates.delete(chatId); + await userStates.delete(chatId); } catch (error) { if (error.code === 'SQLITE_CONSTRAINT') { await bot.sendMessage(chatId, 'This category already exists in this location.'); @@ -72,7 +72,7 @@ export default class CategoryAddHandler { const chatId = callbackQuery.message.chat.id; const locationId = callbackQuery.data.replace('add_category_', ''); - userStates.set(chatId, {action: `add_category_${locationId}`}); + await userStates.set(chatId, {action: `add_category_${locationId}`}); const location = await LocationService.getLocationById(locationId); diff --git a/src/handlers/adminHandlers/product/categoryEditHandler.js b/src/handlers/adminHandlers/product/categoryEditHandler.js index d9eccbb..30937cb 100644 --- a/src/handlers/adminHandlers/product/categoryEditHandler.js +++ b/src/handlers/adminHandlers/product/categoryEditHandler.js @@ -9,7 +9,7 @@ export default class CategoryEditHandler { static async handleCategoryUpdate(msg) { const chatId = msg.chat.id; - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!state || !state.action?.startsWith('edit_category_')) { return false; @@ -48,7 +48,7 @@ export default class CategoryEditHandler { } ); - userStates.delete(chatId); + await userStates.delete(chatId); } catch (error) { logger.error({ err: error }, 'Error updating category'); await bot.sendMessage(chatId, 'Ошибка обновления категории. Пожалуйста, попробуйте снова.'); @@ -65,7 +65,7 @@ export default class CategoryEditHandler { const chatId = callbackQuery.message.chat.id; const [locationId, categoryId] = callbackQuery.data.replace('edit_category_', '').split('_'); - userStates.set(chatId, { action: `edit_category_${locationId}_${categoryId}` }); + await userStates.set(chatId, { action: `edit_category_${locationId}_${categoryId}` }); await bot.editMessageText( 'Пожалуйста, введите новое название категории:', diff --git a/src/handlers/adminHandlers/product/createHandler.js b/src/handlers/adminHandlers/product/createHandler.js index eed9ac1..f9dac0a 100644 --- a/src/handlers/adminHandlers/product/createHandler.js +++ b/src/handlers/adminHandlers/product/createHandler.js @@ -30,7 +30,7 @@ export default class CreateHandler { const jsonExample = JSON.stringify(sampleProducts, null, 2); const message = `To add product, send a JSON file with product in the following format:\n\n
${jsonExample}
\n\nProduct must have all the fields shown above.\n\nYou can either:\n1. Send the JSON as text\n2. Upload a .json file`; - userStates.set(chatId, { + await userStates.set(chatId, { action: 'import_products', locationId, categoryId diff --git a/src/handlers/adminHandlers/product/districtHandler.js b/src/handlers/adminHandlers/product/districtHandler.js index c8840ed..8f26b23 100644 --- a/src/handlers/adminHandlers/product/districtHandler.js +++ b/src/handlers/adminHandlers/product/districtHandler.js @@ -52,7 +52,7 @@ export default class DistrictHandler { const messageId = callbackQuery.message.message_id; const [country, city, district] = callbackQuery.data.replace('prod_district_', '').split('_'); - userStates.delete(chatId); + await userStates.delete(chatId); try { const location = await LocationService.getLocation(country, city, district); diff --git a/src/handlers/adminHandlers/product/editImportHandler.js b/src/handlers/adminHandlers/product/editImportHandler.js index 4059a22..7118ef4 100644 --- a/src/handlers/adminHandlers/product/editImportHandler.js +++ b/src/handlers/adminHandlers/product/editImportHandler.js @@ -9,7 +9,7 @@ import logger from '../../../utils/logger.js'; export default class EditImportHandler { static async handleProductEditImport(msg) { const chatId = msg.chat.id; - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!state || state.action !== 'edit_product') { return false; @@ -84,7 +84,7 @@ export default class EditImportHandler { } }); - userStates.delete(chatId); + await userStates.delete(chatId); } catch (error) { logger.error({ err: error }, 'Error importing products'); await bot.sendMessage(chatId, 'Error importing products. Please check the data and try again.'); diff --git a/src/handlers/adminHandlers/product/editStartHandler.js b/src/handlers/adminHandlers/product/editStartHandler.js index b86be94..e0b33f3 100644 --- a/src/handlers/adminHandlers/product/editStartHandler.js +++ b/src/handlers/adminHandlers/product/editStartHandler.js @@ -39,7 +39,7 @@ export default class EditStartHandler { const jsonExample = JSON.stringify(sampleProduct, null, 2); const message = `To edit product, send a JSON file with product data:\n\n
${jsonExample}
\n\nYou can either:\n1. Send the JSON as text\n2. Upload a .json file`; - userStates.set(chatId, { + await userStates.set(chatId, { action: 'edit_product', locationId, categoryId, diff --git a/src/handlers/adminHandlers/product/importHandler.js b/src/handlers/adminHandlers/product/importHandler.js index 0debb6b..d7139c7 100644 --- a/src/handlers/adminHandlers/product/importHandler.js +++ b/src/handlers/adminHandlers/product/importHandler.js @@ -10,7 +10,7 @@ export default class ImportHandler { static async handleProductImport(msg) { const chatId = msg.chat.id; - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!state || state.action !== 'import_products') return false; if (!isAdmin(msg.from.id)) { await bot.sendMessage(chatId, 'Unauthorized access.'); @@ -46,7 +46,7 @@ export default class ImportHandler { inline_keyboard: [[{ text: '« Back to Products', callback_data: `prod_category_${state.locationId}_${state.categoryId}` }]] } }); - userStates.delete(chatId); + await userStates.delete(chatId); } catch (error) { logger.error({ err: error }, 'Error importing products'); await bot.sendMessage(chatId, 'Error importing products. Please check the data and try again.'); diff --git a/src/handlers/adminHandlers/product/viewHandler.js b/src/handlers/adminHandlers/product/viewHandler.js index cf349aa..620c4ab 100644 --- a/src/handlers/adminHandlers/product/viewHandler.js +++ b/src/handlers/adminHandlers/product/viewHandler.js @@ -76,7 +76,7 @@ export default class ViewHandler { } } - userStates.set(chatId, { + await userStates.set(chatId, { msgToDelete: [photoMessage.message_id, hiddenPhotoMessage.message_id] }) diff --git a/src/handlers/userHandlers/userProductHandler.js b/src/handlers/userHandlers/userProductHandler.js index 8366cf9..fd2790d 100644 --- a/src/handlers/userHandlers/userProductHandler.js +++ b/src/handlers/userHandlers/userProductHandler.js @@ -148,7 +148,7 @@ export default class UserProductHandler { } // Сохраняем текстовое представление локации в состоянии пользователя - userStates.set(chatId, { + await userStates.set(chatId, { location: `${country}_${city}_${district}` }); @@ -189,7 +189,7 @@ export default class UserProductHandler { await bot.deleteMessage(chatId, messageId); // Получаем состояние пользователя - const state = userStates.get(chatId); + const state = await userStates.get(chatId); // Удаляем сообщение с фотографией, если оно существует if (state && state.photoMessageId) { @@ -243,7 +243,7 @@ export default class UserProductHandler { ); // Сохраняем состояние пользователя - userStates.set(chatId, { + await userStates.set(chatId, { ...state, action: 'viewing_category', categoryId, @@ -332,7 +332,7 @@ export default class UserProductHandler { await bot.deleteMessage(chatId, messageId); // Получаем состояние пользователя - const state = userStates.get(chatId); + const state = await userStates.get(chatId); // Удаляем сообщение с фотографией, если оно существует if (state?.photoMessageId) { @@ -390,7 +390,7 @@ export default class UserProductHandler { }); // Сохраняем ID сообщения с фотографией и ID сообщения с товаром в состояние пользователя - userStates.set(chatId, { + await userStates.set(chatId, { action: 'buying_product', productId, quantity: 1, @@ -408,7 +408,7 @@ export default class UserProductHandler { const chatId = callbackQuery.message.chat.id; const messageId = callbackQuery.message.message_id; const productId = callbackQuery.data.replace('increase_quantity_', ''); - const state = userStates.get(chatId); + const state = await userStates.get(chatId); try { const product = await ProductService.getProductById(productId); @@ -428,7 +428,7 @@ export default class UserProductHandler { const newQuantity = Math.min(currentQuantity + 1, product.quantity_in_stock); // Update state - userStates.set(chatId, { + await userStates.set(chatId, { ...state, quantity: newQuantity }); @@ -468,7 +468,7 @@ export default class UserProductHandler { const chatId = callbackQuery.message.chat.id; const messageId = callbackQuery.message.message_id; const productId = callbackQuery.data.replace('decrease_quantity_', ''); - const state = userStates.get(chatId); + const state = await userStates.get(chatId); try { const product = await ProductService.getProductById(productId) @@ -488,7 +488,7 @@ export default class UserProductHandler { const newQuantity = Math.max(currentQuantity - 1, 1); // Update state - userStates.set(chatId, { + await userStates.set(chatId, { ...state, quantity: newQuantity }); @@ -528,7 +528,7 @@ export default class UserProductHandler { const chatId = callbackQuery.message.chat.id; const telegramId = callbackQuery.from.id; const productId = callbackQuery.data.replace('buy_product_', ''); - const state = userStates.get(chatId); + const state = await userStates.get(chatId); try { const user = await UserService.getUserByTelegramId(telegramId); @@ -622,7 +622,7 @@ export default class UserProductHandler { ); // Сохранение ID сообщения с фотографией в состояние пользователя - userStates.set(chatId, { + await userStates.set(chatId, { ...state, photoMessageId: state?.photoMessageId || null, purchaseMessageId: purchaseMessage.message_id @@ -637,7 +637,7 @@ export default class UserProductHandler { const chatId = callbackQuery.message.chat.id; const telegramId = callbackQuery.from.id; const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_'); - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (!Validators.isValidWalletType(walletType)) { await bot.sendMessage(chatId, 'Invalid wallet type.'); @@ -670,7 +670,7 @@ export default class UserProductHandler { const balance = user.total_balance + user.bonus_balance; if (totalPrice > balance) { - userStates.delete(chatId); + await userStates.delete(chatId); await bot.editMessageText(`Not enough money`, { chat_id: chatId, message_id: callbackQuery.message.message_id, @@ -738,7 +738,7 @@ export default class UserProductHandler { await bot.deleteMessage(chatId, callbackQuery.message.message_id); // Сохраняем ID сообщения с Hidden Photo в состояние пользователя - userStates.set(chatId, { + await userStates.set(chatId, { action: 'viewing_purchase', purchaseId, hiddenPhotoMessageId: hiddenPhotoMessage ? hiddenPhotoMessage.message_id : null diff --git a/src/handlers/userHandlers/userPurchaseHandler.js b/src/handlers/userHandlers/userPurchaseHandler.js index 1c99d96..0fe96ee 100644 --- a/src/handlers/userHandlers/userPurchaseHandler.js +++ b/src/handlers/userHandlers/userPurchaseHandler.js @@ -95,7 +95,7 @@ export default class UserPurchaseHandler { } // Удаляем сообщение с Hidden Photo, если оно существует - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (state?.hiddenPhotoMessageId) { try { await bot.deleteMessage(chatId, state.hiddenPhotoMessageId); @@ -114,7 +114,7 @@ export default class UserPurchaseHandler { }); // Удаляем состояние пользователя - userStates.delete(chatId); + await userStates.delete(chatId); } catch (e) { logger.error({ err: e }, 'Error in handlePurchaseListPage'); await bot.sendMessage(chatId, 'Error loading purchase history. Please try again.'); @@ -168,7 +168,7 @@ export default class UserPurchaseHandler { const category = await CategoryService.getCategoryById(product.category_id); // Удаляем старое сообщение с Hidden Photo, если оно существует - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (state?.hiddenPhotoMessageId) { try { await bot.deleteMessage(chatId, state.hiddenPhotoMessageId); @@ -219,7 +219,7 @@ export default class UserPurchaseHandler { await bot.deleteMessage(chatId, callbackQuery.message.message_id); // Сохраняем ID сообщения с Hidden Photo в состояние пользователя - userStates.set(chatId, { + await userStates.set(chatId, { action: 'viewing_purchase', purchaseId, hiddenPhotoMessageId: hiddenPhotoMessage ? hiddenPhotoMessage.message_id : null @@ -279,7 +279,7 @@ export default class UserPurchaseHandler { await bot.deleteMessage(chatId, messageId); // Удаляем Hidden Photo, если оно существует - const state = userStates.get(chatId); + const state = await userStates.get(chatId); if (state?.hiddenPhotoMessageId) { try { await bot.deleteMessage(chatId, state.hiddenPhotoMessageId); @@ -289,7 +289,7 @@ export default class UserPurchaseHandler { } // Удаляем состояние пользователя - userStates.delete(chatId); + await userStates.delete(chatId); // Открываем список покупок для пользователя await this.showPurchases({ chat: { id: chatId }, from: { id: callbackQuery.from.id } }); diff --git a/src/index.js b/src/index.js index dab5227..f3935c1 100644 --- a/src/index.js +++ b/src/index.js @@ -8,8 +8,11 @@ import adminHandler from './handlers/adminHandlers/adminHandler.js'; import callbackRouter from './router/callbackRouter.js'; import messageRouter from './router/messageRouter.js'; +import { initStates } from './services/stateService.js'; + await runMigrations(); await cleanUpInvalidForeignKeys(); +await initStates(); const logDebug = (action, functionName) => { logger.debug({ action, functionName }, 'Button Press'); diff --git a/src/migrations/004_user_states.js b/src/migrations/004_user_states.js new file mode 100644 index 0000000..ecd3cbc --- /dev/null +++ b/src/migrations/004_user_states.js @@ -0,0 +1,10 @@ +export default async function migration004(db) { + await db.runAsync(` + CREATE TABLE IF NOT EXISTS user_states ( + chat_id TEXT PRIMARY KEY, + state_data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + console.log('Migration 004: user_states table created'); +} \ No newline at end of file diff --git a/src/migrations/runner.js b/src/migrations/runner.js index 7d5bd76..09e7355 100644 --- a/src/migrations/runner.js +++ b/src/migrations/runner.js @@ -38,6 +38,7 @@ export async function runMigrations() { (await import('./001_initial_schema.js')).default, (await import('./002_add_columns.js')).default, (await import('./003_add_indexes.js')).default, + (await import('./004_user_states.js')).default, ]; for (let i = currentVersion; i < migrations.length; i++) { diff --git a/src/services/stateService.js b/src/services/stateService.js new file mode 100644 index 0000000..e9478dc --- /dev/null +++ b/src/services/stateService.js @@ -0,0 +1,103 @@ +import db from '../config/database.js'; +import logger from '../utils/logger.js'; + +const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +let initialized = false; + +export async function initStates() { + if (initialized) return; + + await db.runAsync(` + CREATE TABLE IF NOT EXISTS user_states ( + chat_id TEXT PRIMARY KEY, + state_data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + + initialized = true; + logger.info('user_states table initialized'); + + setInterval(cleanExpired, CLEANUP_INTERVAL_MS); + cleanExpired(); +} + +function serialize(value) { + if (value === undefined) return null; + return JSON.stringify(value); +} + +function deserialize(json) { + if (!json) return undefined; + try { + return JSON.parse(json); + } catch { + return undefined; + } +} + +export async function get(chatId) { + const row = await db.getAsync( + 'SELECT state_data, updated_at FROM user_states WHERE chat_id = ?', + [String(chatId)] + ); + + if (!row) return undefined; + + if (Date.now() - row.updated_at > TTL_MS) { + await db.runAsync('DELETE FROM user_states WHERE chat_id = ?', [String(chatId)]); + return undefined; + } + + return deserialize(row.state_data); +} + +export async function set(chatId, value) { + const data = serialize(value); + const now = Date.now(); + + await db.runAsync( + `INSERT INTO user_states (chat_id, state_data, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(chat_id) DO UPDATE SET state_data = ?, updated_at = ?`, + [String(chatId), data, now, data, now] + ); + + return value; +} + +export async function del(chatId) { + await db.runAsync('DELETE FROM user_states WHERE chat_id = ?', [String(chatId)]); +} + +export async function has(chatId) { + const row = await db.getAsync( + 'SELECT updated_at FROM user_states WHERE chat_id = ?', + [String(chatId)] + ); + + if (!row) return false; + + if (Date.now() - row.updated_at > TTL_MS) { + await db.runAsync('DELETE FROM user_states WHERE chat_id = ?', [String(chatId)]); + return false; + } + + return true; +} + +export async function cleanExpired() { + const cutoff = Date.now() - TTL_MS; + const result = await db.runAsync( + 'DELETE FROM user_states WHERE updated_at < ?', + [cutoff] + ); + if (result.changes > 0) { + logger.info({ expiredCount: result.changes }, 'Cleaned expired user states'); + } +} + +const userStates = { get, set, delete: del, has, initStates, cleanExpired }; +export default userStates; \ No newline at end of file