diff --git a/VERSION.md b/VERSION.md
index 5aa3f1c..14a2f99 100644
--- a/VERSION.md
+++ b/VERSION.md
@@ -21,6 +21,8 @@
- **fix**: Added defensive guards in bot purchase display — description fallback and no-photo placeholder (userProductHandler.js)
- **feat**: Added i18n keys `products.no_description` and `products.no_photo` in en/es/de
- **fix**: Marked description and photo fields as required in admin forms (product-edit.ejs, products.ejs, catalog modal)
+- **feat**: Added edit + enable/disable (toggle is_active) UI for locations, categories, and subcategories in admin views (locations.ejs, categories.ejs, catalog.js buildTreeHtml)
+- **feat**: Added Categories nav item in admin sidebar (generated-navigation.ejs)
### v1.2.0 — 2026-07-08
- **fix**: Disabled CSRF checks in admin panel for Tor / onion zone compatibility
diff --git a/src/__tests__/crudActiveFlags.test.js b/src/__tests__/crudActiveFlags.test.js
new file mode 100644
index 0000000..f6adbb4
--- /dev/null
+++ b/src/__tests__/crudActiveFlags.test.js
@@ -0,0 +1,252 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import Database from 'better-sqlite3';
+
+vi.mock('../config/config.js', () => ({
+ __esModule: true,
+ default: {
+ BOT_TOKEN: 'test-token',
+ ADMIN_IDS: ['123456789'],
+ SUPER_ADMIN_IDS: ['123456789'],
+ SUPPORT_LINK: 'https://t.me/support',
+ DEFAULT_LANGUAGE: 'en',
+ ENCRYPTION_KEY: 'x'.repeat(64)
+ }
+}));
+
+vi.mock('../utils/logger.js', () => ({
+ __esModule: true,
+ default: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ fatal: vi.fn()
+ }
+}));
+
+const recorded = {
+ calls: []
+};
+
+vi.mock('../config/database.js', () => {
+ const db = {
+ allAsync: vi.fn(async (sql, params = []) => {
+ recorded.calls.push({ method: 'allAsync', sql, params });
+ return [];
+ }),
+ getAsync: vi.fn(async (sql, params = []) => {
+ recorded.calls.push({ method: 'getAsync', sql, params });
+ return {};
+ }),
+ runAsync: vi.fn(async (sql, params = []) => {
+ recorded.calls.push({ method: 'runAsync', sql, params });
+ return {};
+ })
+ };
+ return { __esModule: true, default: db };
+});
+
+function getIsActiveCount(tableInfoRows) {
+ return tableInfoRows.filter(row => row.name === 'is_active').length;
+}
+
+function wrapDb(rawDb) {
+ return {
+ runAsync: (sql, params = []) => Promise.resolve(rawDb.prepare(sql).run(...params)),
+ allAsync: (sql, params = []) => Promise.resolve(rawDb.prepare(sql).all(...params)),
+ getAsync: (sql, params = []) => Promise.resolve(rawDb.prepare(sql).get(...params))
+ };
+}
+
+async function runMigration011OnDb(rawDb) {
+ const migration011 = (await import('../migrations/011_active_flags.js')).default;
+ const checkColumnExists = async (tableName, columnName) => {
+ const rows = rawDb.prepare(`PRAGMA table_info(${tableName})`).all();
+ return rows.some(row => row.name === columnName);
+ };
+ await migration011(wrapDb(rawDb), checkColumnExists);
+}
+
+function createBaseTables(db) {
+ db.exec(`CREATE TABLE IF NOT EXISTS locations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ country TEXT NOT NULL,
+ city TEXT NOT NULL,
+ district TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(country, city, district)
+ )`);
+
+ db.exec(`CREATE TABLE IF NOT EXISTS categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ location_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE,
+ UNIQUE(location_id, name)
+ )`);
+
+ db.exec(`CREATE TABLE IF NOT EXISTS subcategories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
+ UNIQUE(category_id, name)
+ )`);
+}
+
+describe('Migration 011: is_active flags', () => {
+ let db;
+
+ beforeEach(() => {
+ db = new Database(':memory:');
+ createBaseTables(db);
+ });
+
+ it('adds is_active column to locations with default 1', async () => {
+ await runMigration011OnDb(db);
+ const info = db.prepare('PRAGMA table_info(locations)').all();
+ expect(getIsActiveCount(info)).toBe(1);
+ const col = info.find(row => row.name === 'is_active');
+ expect(col).toBeDefined();
+ expect(col.notnull).toBe(1);
+ expect(col.dflt_value).toBe('1');
+
+ const insert = db.prepare('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)');
+ const { lastInsertRowid } = insert.run('UA', 'Kyiv', 'Pechersk');
+ const row = db.prepare('SELECT is_active FROM locations WHERE id = ?').get(lastInsertRowid);
+ expect(row.is_active).toBe(1);
+ });
+
+ it('adds is_active column to categories with default 1', async () => {
+ await runMigration011OnDb(db);
+ const info = db.prepare('PRAGMA table_info(categories)').all();
+ expect(getIsActiveCount(info)).toBe(1);
+ const col = info.find(row => row.name === 'is_active');
+ expect(col).toBeDefined();
+
+ const locInsert = db.prepare('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)');
+ const { lastInsertRowid: locId } = locInsert.run('UA', 'Kyiv', 'Pechersk');
+
+ const catInsert = db.prepare('INSERT INTO categories (location_id, name) VALUES (?, ?)');
+ const { lastInsertRowid: catId } = catInsert.run(locId, 'Electronics');
+
+ const row = db.prepare('SELECT is_active FROM categories WHERE id = ?').get(catId);
+ expect(row.is_active).toBe(1);
+ });
+
+ it('adds is_active column to subcategories with default 1', async () => {
+ await runMigration011OnDb(db);
+ const info = db.prepare('PRAGMA table_info(subcategories)').all();
+ expect(getIsActiveCount(info)).toBe(1);
+ const col = info.find(row => row.name === 'is_active');
+ expect(col).toBeDefined();
+
+ const locInsert = db.prepare('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)');
+ const { lastInsertRowid: locId } = locInsert.run('UA', 'Kyiv', 'Pechersk');
+
+ const catInsert = db.prepare('INSERT INTO categories (location_id, name) VALUES (?, ?)');
+ const { lastInsertRowid: catId } = catInsert.run(locId, 'Electronics');
+
+ const subInsert = db.prepare('INSERT INTO subcategories (category_id, name) VALUES (?, ?)');
+ const { lastInsertRowid: subId } = subInsert.run(catId, 'Phones');
+
+ const row = db.prepare('SELECT is_active FROM subcategories WHERE id = ?').get(subId);
+ expect(row.is_active).toBe(1);
+ });
+
+ it('is idempotent: running migration 011 twice does not error or duplicate columns', async () => {
+ await runMigration011OnDb(db);
+ await expect(runMigration011OnDb(db)).resolves.toBeUndefined();
+
+ for (const table of ['locations', 'categories', 'subcategories']) {
+ const info = db.prepare(`PRAGMA table_info(${table})`).all();
+ expect(getIsActiveCount(info)).toBe(1);
+ }
+ });
+});
+
+describe('LocationService is_active filtering', () => {
+ beforeEach(() => {
+ recorded.calls = [];
+ vi.clearAllMocks();
+ });
+
+ it('getCountries filters is_active = 1', async () => {
+ const LocationService = (await import('../services/locationService.js')).default;
+ await LocationService.getCountries();
+ const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
+ expect(sql).toContain('is_active = 1');
+ });
+
+ it('getCitiesByCountry filters is_active = 1', async () => {
+ const LocationService = (await import('../services/locationService.js')).default;
+ await LocationService.getCitiesByCountry('UA');
+ const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
+ expect(sql).toContain('AND is_active = 1');
+ });
+
+ it('getDistrictsByCountryAndCity filters is_active = 1', async () => {
+ const LocationService = (await import('../services/locationService.js')).default;
+ await LocationService.getDistrictsByCountryAndCity('UA', 'Kyiv');
+ const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
+ expect(sql).toContain('AND is_active = 1');
+ });
+
+ it('getLocationsByCountryAndCity filters is_active = 1', async () => {
+ const LocationService = (await import('../services/locationService.js')).default;
+ await LocationService.getLocationsByCountryAndCity('UA', 'Kyiv');
+ const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
+ expect(sql).toContain('AND is_active = 1');
+ });
+
+ it('getLocation filters is_active = 1', async () => {
+ const LocationService = (await import('../services/locationService.js')).default;
+ await LocationService.getLocation('UA', 'Kyiv', 'Pechersk');
+ const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
+ expect(sql).toContain('AND is_active = 1');
+ });
+
+ it('getLocationById does NOT filter is_active', async () => {
+ const LocationService = (await import('../services/locationService.js')).default;
+ await LocationService.getLocationById(1);
+ const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
+ expect(sql).not.toContain('is_active');
+ });
+});
+
+describe('CategoryService is_active filtering', () => {
+ beforeEach(() => {
+ recorded.calls = [];
+ vi.clearAllMocks();
+ });
+
+ it('getCategoriesByLocationId filters is_active = 1', async () => {
+ const CategoryService = (await import('../services/categoryService.js')).default;
+ await CategoryService.getCategoriesByLocationId(1);
+ const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
+ expect(sql).toContain('AND is_active = 1');
+ });
+
+ it('getSubcategoriesByCategoryId filters is_active = 1', async () => {
+ const CategoryService = (await import('../services/categoryService.js')).default;
+ await CategoryService.getSubcategoriesByCategoryId(1);
+ const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
+ expect(sql).toContain('AND is_active = 1');
+ });
+
+ it('getCategoryById does NOT filter is_active', async () => {
+ const CategoryService = (await import('../services/categoryService.js')).default;
+ await CategoryService.getCategoryById(1);
+ const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
+ expect(sql).not.toContain('is_active');
+ });
+
+ it('getSubcategoryById does NOT filter is_active', async () => {
+ const CategoryService = (await import('../services/categoryService.js')).default;
+ await CategoryService.getSubcategoryById(1);
+ const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
+ expect(sql).not.toContain('is_active');
+ });
+});
diff --git a/src/admin/routes/catalog.js b/src/admin/routes/catalog.js
index bbbb866..329050d 100644
--- a/src/admin/routes/catalog.js
+++ b/src/admin/routes/catalog.js
@@ -26,20 +26,25 @@ function buildTreeHtml(tree, csrf) {
let subHtml = '';
for (const s of (c.subs||[])) {
const subId = `sub-${s.id}`;
+ const subDisabled = s.is_active === 0;
subHtml += `
`;
}
+ const catDisabled = c.is_active === 0;
catHtml += `
`;
}
+ const locDisabled = ldata.is_active === 0;
districtHtml += `
${catHtml}
+
+
- | ID | Name | Location | Products | Actions |
+ | ID | Name | Location | Products | Status | Actions |
<% if (categories.length === 0) { %>
- | No categories |
+ | No categories |
<% } else { %>
<% categories.forEach(function(c) { %>
-
+
>
| <%= c.id %> |
- <%= c.name %> |
+
+ <%= c.name %>
+ <% if (c.is_active === 0) { %>
+ Disabled
+ <% } %>
+ |
<%= c.country ? c.country + ' / ' + c.city : '-' %> |
<%= c.product_count || 0 %> |
- |
+
+
-
+
@@ -52,13 +72,38 @@
|
<% var subs = subcategoriesByCategory[c.id] || []; %>
<% subs.forEach(function(s) { %>
-
+
|
- <%= s.name %> |
+
+ <%= s.name %>
+ <% if (s.is_active === 0) { %>
+ Disabled
+ <% } %>
+ |
|
<%= s.product_count || 0 %> |
- |
+
+
+
+
@@ -74,7 +119,7 @@
|
- | | |
+ | | | |
<% }); %>
<% } %>
diff --git a/src/admin/views/locations.ejs b/src/admin/views/locations.ejs
index 4c46ec5..c2602c9 100644
--- a/src/admin/views/locations.ejs
+++ b/src/admin/views/locations.ejs
@@ -30,13 +30,13 @@
- | ID | Country | City | District | Categories | Products | Actions |
+ | ID | Country | City | District | Categories | Products | Status | Actions |
<% if (locations.length === 0) { %>
- | No locations |
+ | No locations |
<% } else { %>
<% locations.forEach(function(l) { %>
-
+
>
| <%= l.id %> |
<%= l.country %> |
<%= l.city %> |
@@ -44,6 +44,28 @@
<%= l.category_count || 0 %> |
<%= l.product_count || 0 %> |
+ <% if (l.is_active === 1) { %>
+ Active
+ <% } else { %>
+ Disabled
+ <% } %>
+ |
+
+
+
|