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