feat(state): replace in-memory Map with SQLite-backed stateService (#59)

- Create src/services/stateService.js with get/set/delete/has API
- Create migration 004_user_states.js (chat_id PK, state_data JSON, updated_at)
- TTL of 24 hours — expired states auto-deleted
- Cleanup job runs every hour (setInterval)
- Replace src/context/userStates.js Map with async stateService proxy
- Add await to all 45 userStates.get/set/delete/has calls across 13 files
- Add initStates() call in index.js startup sequence
- All state survives bot restarts now

18 files changed, 172 insertions, 46 deletions
This commit is contained in:
NW
2026-06-22 10:02:57 +01:00
parent ce1b6003cb
commit a04e60d751
18 changed files with 172 additions and 46 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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(
'Пожалуйста, введите новое название категории:',

View File

@@ -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<pre>${jsonExample}</pre>\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

View File

@@ -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);

View File

@@ -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.');

View File

@@ -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<pre>${jsonExample}</pre>\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,

View File

@@ -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.');

View File

@@ -76,7 +76,7 @@ export default class ViewHandler {
}
}
userStates.set(chatId, {
await userStates.set(chatId, {
msgToDelete: [photoMessage.message_id, hiddenPhotoMessage.message_id]
})

View File

@@ -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

View File

@@ -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 } });

View File

@@ -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');

View File

@@ -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');
}

View File

@@ -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++) {

View File

@@ -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;