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 += `

-
+ ${csrfInput} - - + + +
+
+ ${csrfInput} +
${csrfInput} @@ -49,17 +54,27 @@ function buildTreeHtml(tree, csrf) {
`; } + const catDisabled = c.is_active === 0; catHtml += `

${subHtml} + + ${csrfInput} + + + +
+ ${csrfInput} + +
${csrfInput} @@ -69,17 +84,29 @@ function buildTreeHtml(tree, csrf) {
`; } + const locDisabled = ldata.is_active === 0; districtHtml += `

${catHtml} + + ${csrfInput} + + + + + +
+ ${csrfInput} + +
${csrfInput} @@ -305,7 +332,7 @@ router.get('/', asyncHandler(async (req, res) => { for (const l of locations) { (tree[l.country]??={cities:{}}).cities[l.city]??={districts:{}}; tree[l.country].cities[l.city].districts[l.district] = { - id: l.id, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]})) + id: l.id, is_active: l.is_active, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]})) }; } @@ -337,6 +364,23 @@ router.post('/locations', asyncHandler(async (req, res) => { res.redirect('/catalog?msg=Location+added&msg_type=success'); })); +router.post('/locations/:id/update', asyncHandler(async (req, res) => { + const { country, city, district } = req.body; + const tc = (country || '').trim(); + const tci = (city || '').trim(); + if (!tc || !tci) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error'); + await db.runAsync('UPDATE locations SET country=?, city=?, district=? WHERE id=?', + [tc, tci, (district || '').trim(), req.params.id]); + res.redirect('/catalog?msg=Location+updated&msg_type=success'); +})); + +router.post('/locations/:id/toggle', asyncHandler(async (req, res) => { + const row = await db.getAsync('SELECT is_active FROM locations WHERE id=?', [req.params.id]); + if (!row) return res.redirect('/catalog?msg=Location+not+found&msg_type=error'); + await db.runAsync('UPDATE locations SET is_active=? WHERE id=?', [row.is_active ? 0 : 1, req.params.id]); + res.redirect('/catalog?msg=Location+toggled&msg_type=success'); +})); + router.post('/locations/:id/delete', asyncHandler(async (req, res) => { const c = await db.getAsync('SELECT COUNT(*) as n FROM categories WHERE location_id=?', [req.params.id]); if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+categories&msg_type=error'); @@ -377,6 +421,22 @@ router.post('/categories/json', asyncHandler(async (req, res) => { res.json(cat); })); +router.post('/categories/:id/update', asyncHandler(async (req, res) => { + const { name, location_id } = req.body; + const tn = (name || '').trim(); + if (!tn) return res.redirect('/catalog?msg=Name+required&msg_type=error'); + await db.runAsync('UPDATE categories SET name=?, location_id=? WHERE id=?', + [tn, location_id || null, req.params.id]); + res.redirect('/catalog?msg=Category+updated&msg_type=success'); +})); + +router.post('/categories/:id/toggle', asyncHandler(async (req, res) => { + const row = await db.getAsync('SELECT is_active FROM categories WHERE id=?', [req.params.id]); + if (!row) return res.redirect('/catalog?msg=Category+not+found&msg_type=error'); + await db.runAsync('UPDATE categories SET is_active=? WHERE id=?', [row.is_active ? 0 : 1, req.params.id]); + res.redirect('/catalog?msg=Category+toggled&msg_type=success'); +})); + router.post('/categories/:id/delete', asyncHandler(async (req, res) => { const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE category_id=?', [req.params.id]); if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error'); @@ -405,6 +465,21 @@ router.post('/categories/:id/subcategories/json', asyncHandler(async (req, res) res.json(sub); })); +router.post('/subcategories/:id/update', asyncHandler(async (req, res) => { + const { name } = req.body; + const tn = (name || '').trim(); + if (!tn) return res.redirect('/catalog?msg=Name+required&msg_type=error'); + await db.runAsync('UPDATE subcategories SET name=? WHERE id=?', [tn, req.params.id]); + res.redirect('/catalog?msg=Subcategory+updated&msg_type=success'); +})); + +router.post('/subcategories/:id/toggle', asyncHandler(async (req, res) => { + const row = await db.getAsync('SELECT is_active FROM subcategories WHERE id=?', [req.params.id]); + if (!row) return res.redirect('/catalog?msg=Subcategory+not+found&msg_type=error'); + await db.runAsync('UPDATE subcategories SET is_active=? WHERE id=?', [row.is_active ? 0 : 1, req.params.id]); + res.redirect('/catalog?msg=Subcategory+toggled&msg_type=success'); +})); + router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => { const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE subcategory_id=?', [req.params.id]); if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error'); diff --git a/src/admin/routes/categories.js b/src/admin/routes/categories.js index f2e465d..5ad0587 100644 --- a/src/admin/routes/categories.js +++ b/src/admin/routes/categories.js @@ -37,6 +37,14 @@ router.post('/:id/update', asyncHandler(async (req, res) => { res.redirect('/categories'); })); +router.post('/:id/toggle', asyncHandler(async (req, res) => { + const row = await db.getAsync('SELECT is_active FROM categories WHERE id = ?', [req.params.id]); + if (!row) return res.redirect('/categories'); + const newVal = row.is_active ? 0 : 1; + await db.runAsync('UPDATE categories SET is_active = ? WHERE id = ?', [newVal, req.params.id]); + res.redirect('/categories'); +})); + router.post('/:id/delete', asyncHandler(async (req, res) => { const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]); if (count && count.cnt > 0) { @@ -61,6 +69,24 @@ router.post('/:id/subcategories', asyncHandler(async (req, res) => { res.redirect('/categories'); })); +router.post('/subcategories/:id/update', asyncHandler(async (req, res) => { + const { name } = req.body; + const trimmedName = (name || '').trim(); + if (!trimmedName) { + return res.redirect('/categories?error=Subcategory+name+is+required'); + } + await db.runAsync('UPDATE subcategories SET name = ? WHERE id = ?', [trimmedName, req.params.id]); + res.redirect('/categories'); +})); + +router.post('/subcategories/:id/toggle', asyncHandler(async (req, res) => { + const row = await db.getAsync('SELECT is_active FROM subcategories WHERE id = ?', [req.params.id]); + if (!row) return res.redirect('/categories'); + const newVal = row.is_active ? 0 : 1; + await db.runAsync('UPDATE subcategories SET is_active = ? WHERE id = ?', [newVal, req.params.id]); + res.redirect('/categories'); +})); + router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => { const count = await db.getAsync( 'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?', diff --git a/src/admin/routes/locations.js b/src/admin/routes/locations.js index b3f50e4..53709df 100644 --- a/src/admin/routes/locations.js +++ b/src/admin/routes/locations.js @@ -21,6 +21,28 @@ router.post('/', asyncHandler(async (req, res) => { res.redirect('/locations'); })); +router.post('/:id/update', asyncHandler(async (req, res) => { + const { country, city, district } = req.body; + const trimmedCountry = (country || '').trim(); + const trimmedCity = (city || '').trim(); + if (!trimmedCountry || !trimmedCity) { + return res.redirect('/locations?error=Country+and+city+are+required'); + } + await db.runAsync( + 'UPDATE locations SET country = ?, city = ?, district = ? WHERE id = ?', + [trimmedCountry, trimmedCity, (district || '').trim(), req.params.id] + ); + res.redirect('/locations'); +})); + +router.post('/:id/toggle', asyncHandler(async (req, res) => { + const row = await db.getAsync('SELECT is_active FROM locations WHERE id = ?', [req.params.id]); + if (!row) return res.redirect('/locations'); + const newVal = row.is_active ? 0 : 1; + await db.runAsync('UPDATE locations SET is_active = ? WHERE id = ?', [newVal, req.params.id]); + res.redirect('/locations'); +})); + router.post('/:id/delete', asyncHandler(async (req, res) => { const count = await db.getAsync( 'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?', diff --git a/src/admin/views/categories.ejs b/src/admin/views/categories.ejs index 01a678d..d4162aa 100644 --- a/src/admin/views/categories.ejs +++ b/src/admin/views/categories.ejs @@ -21,19 +21,31 @@
- + <% if (categories.length === 0) { %> - + <% } else { %> <% categories.forEach(function(c) { %> - + > - + + <% var subs = subcategoriesByCategory[c.id] || []; %> <% subs.forEach(function(s) { %> - + - + + - + <% }); %> <% } %> 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 @@
IDNameLocationProductsActions
IDNameLocationProductsStatusActions
No categories
No categories
<%= c.id %><%= c.name %> + <%= c.name %> + <% if (c.is_active === 0) { %> + Disabled + <% } %> + <%= c.country ? c.country + ' / ' + c.city : '-' %> <%= c.product_count || 0 %> - + <% if (c.is_active === 1) { %> + Active + <% } else { %> + Disabled + <% } %> + + -
+ "> + + <% if (c.is_active === 1) { %> + + <% } else { %> + + <% } %> +
+
@@ -52,13 +72,38 @@
<%= s.name %> + <%= s.name %> + <% if (s.is_active === 0) { %> + Disabled + <% } %> + <%= s.product_count || 0 %> -
+ <% if (s.is_active === 1) { %> + Active + <% } else { %> + Disabled + <% } %> +
+ + + + + +
"> + + <% if (s.is_active === 1) { %> + + <% } else { %> + + <% } %> +
+
@@ -74,7 +119,7 @@
- + <% if (locations.length === 0) { %> - + <% } else { %> <% locations.forEach(function(l) { %> - + > @@ -44,6 +44,28 @@ +
IDCountryCityDistrictCategoriesProductsActions
IDCountryCityDistrictCategoriesProductsStatusActions
No locations
No locations
<%= l.id %> <%= l.country %> <%= l.city %><%= l.category_count || 0 %> <%= l.product_count || 0 %> + <% if (l.is_active === 1) { %> + Active + <% } else { %> + Disabled + <% } %> + +
+ + + + + +
+
"> + + <% if (l.is_active === 1) { %> + + <% } else { %> + + <% } %> +
diff --git a/src/admin/views/partials/generated-navigation.ejs b/src/admin/views/partials/generated-navigation.ejs index 9f882b7..bdcb720 100644 --- a/src/admin/views/partials/generated-navigation.ejs +++ b/src/admin/views/partials/generated-navigation.ejs @@ -16,6 +16,13 @@ + +