feat(admin): full CRUD + enable/disable for locations, categories, subcategories

- Migration 011: add is_active INTEGER NOT NULL DEFAULT 1 to locations, categories, subcategories (idempotent)
- Admin routes: locations edit + toggle, categories toggle, subcategories edit + toggle (parallel endpoints on /catalog)
- Bot-side services: filter is_active=1 on locationService/categoryService read methods (getLocationById/getCategoryById left unfiltered for purchase display)
- Views: edit forms, toggle buttons, Active/Disabled badges, disabled row styling in locations.ejs, categories.ejs, catalog tree
- Add Categories nav item in sidebar (folder icon)
- Tests: 14 new tests for migration idempotency + service is_active filtering (23 total pass)
This commit is contained in:
NW
2026-07-19 23:22:21 +01:00
parent 776d0e8552
commit ca2ddefd7a
12 changed files with 512 additions and 27 deletions

View File

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

View File

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

View File

@@ -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 += `
<div class="accordion-item border-0">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${subId}">
<span class="node-label text-decoration-none text-dark" data-sub="${s.id}">${esc(s.name)}</span>
<span class="node-label text-decoration-none${subDisabled ? ' text-muted' : ' text-dark'}" data-sub="${s.id}">${esc(s.name)}${subDisabled ? ' <span class="badge bg-secondary ms-1">Disabled</span>' : ''}</span>
<span class="badge bg-secondary ms-2">${s.pc||0}</span>
</button>
</h2>
<div id="${subId}" class="accordion-collapse collapse" data-bs-parent="#${catId}">
<div class="accordion-body py-2">
<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="d-inline-flex gap-2">
<form method="POST" action="/catalog/subcategories/${s.id}/update" class="d-inline-flex gap-1 align-items-center mb-1">
${csrfInput}
<input name="name" class="form-control form-control-sm" placeholder="+ Subcategory" required size="12" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Add</button>
<input name="name" class="form-control form-control-sm" value="${esc(s.name)}" required size="10" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Save</button>
</form>
<form method="POST" action="/catalog/subcategories/${s.id}/toggle" class="d-inline ms-1"${subDisabled ? '' : ` onsubmit="return confirm('Disable?')"`}>
${csrfInput}
<button class="btn btn-sm ${subDisabled ? 'btn-outline-success' : 'btn-outline-warning'}" title="${subDisabled ? 'Enable' : 'Disable'}">⏻</button>
</form>
<form method="POST" action="/catalog/subcategories/${s.id}/delete" class="d-inline ms-1" onsubmit="return confirm('Delete?')">
${csrfInput}
@@ -49,17 +54,27 @@ function buildTreeHtml(tree, csrf) {
</div>
</div>`;
}
const catDisabled = c.is_active === 0;
catHtml += `
<div class="accordion-item border-0">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${catId}">
<span class="node-label text-decoration-none text-dark" data-cat="${c.id}">${esc(c.name)}</span>
<span class="node-label text-decoration-none${catDisabled ? ' text-muted' : ' text-dark'}" data-cat="${c.id}">${esc(c.name)}${catDisabled ? ' <span class="badge bg-secondary ms-1">Disabled</span>' : ''}</span>
<span class="badge bg-secondary ms-2">${catCount}</span>
</button>
</h2>
<div id="${catId}" class="accordion-collapse collapse" data-bs-parent="#${districtId}">
<div class="accordion-body py-2">
${subHtml}
<form method="POST" action="/catalog/categories/${c.id}/update" class="d-inline-flex gap-1 align-items-center mt-2">
${csrfInput}
<input name="name" class="form-control form-control-sm" value="${esc(c.name)}" required size="10" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Save</button>
</form>
<form method="POST" action="/catalog/categories/${c.id}/toggle" class="d-inline ms-1"${catDisabled ? '' : ` onsubmit="return confirm('Disable?')"`}>
${csrfInput}
<button class="btn btn-sm ${catDisabled ? 'btn-outline-success' : 'btn-outline-warning'}" title="${catDisabled ? 'Enable' : 'Disable'}">⏻</button>
</form>
<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="d-inline-flex gap-2 mt-2">
${csrfInput}
<input name="name" class="form-control form-control-sm" placeholder="+ Subcategory" required size="12" style="width:auto">
@@ -69,17 +84,29 @@ function buildTreeHtml(tree, csrf) {
</div>
</div>`;
}
const locDisabled = ldata.is_active === 0;
districtHtml += `
<div class="accordion-item border-0">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${districtId}">
<span class="node-label text-decoration-none text-dark" data-loc="${ldata.id}">${esc(district)}</span>
<span class="node-label text-decoration-none${locDisabled ? ' text-muted' : ' text-dark'}" data-loc="${ldata.id}">${esc(district)}${locDisabled ? ' <span class="badge bg-secondary ms-1">Disabled</span>' : ''}</span>
<span class="badge bg-secondary ms-2">${districtCount}</span>
</button>
</h2>
<div id="${districtId}" class="accordion-collapse collapse" data-bs-parent="#${cityId}">
<div class="accordion-body py-2">
${catHtml}
<form method="POST" action="/catalog/locations/${ldata.id}/update" class="d-inline-flex gap-1 align-items-center mt-2">
${csrfInput}
<input name="country" class="form-control form-control-sm" value="${esc(country)}" required size="8" style="width:auto">
<input name="city" class="form-control form-control-sm" value="${esc(city)}" required size="8" style="width:auto">
<input name="district" class="form-control form-control-sm" value="${esc(district)}" size="8" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Save</button>
</form>
<form method="POST" action="/catalog/locations/${ldata.id}/toggle" class="d-inline ms-1"${locDisabled ? '' : ` onsubmit="return confirm('Disable?')"`}>
${csrfInput}
<button class="btn btn-sm ${locDisabled ? 'btn-outline-success' : 'btn-outline-warning'}" title="${locDisabled ? 'Enable' : 'Disable'}">⏻</button>
</form>
<form method="POST" action="/catalog/categories" class="d-inline-flex gap-2 mt-2">
${csrfInput}
<input type="hidden" name="location_id" value="${ldata.id}">
@@ -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');

View File

@@ -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 = ?',

View File

@@ -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 = ?',

View File

@@ -21,19 +21,31 @@
</div>
</details>
<table class="table table-striped table-bordered table-hover">
<thead><tr><th>ID</th><th>Name</th><th>Location</th><th>Products</th><th>Actions</th></tr></thead>
<thead><tr><th>ID</th><th>Name</th><th>Location</th><th>Products</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
<% if (categories.length === 0) { %>
<tr><td colspan="5" class="text-center text-muted">No categories</td></tr>
<tr><td colspan="6" class="text-center text-muted">No categories</td></tr>
<% } else { %>
<% categories.forEach(function(c) { %>
<tr>
<tr<%= c.is_active === 0 ? ' class="table-secondary"' : '' %>>
<td><%= c.id %></td>
<td><%= c.name %></td>
<td>
<%= c.name %>
<% if (c.is_active === 0) { %>
<span class="badge bg-secondary ms-1">Disabled</span>
<% } %>
</td>
<td><%= c.country ? c.country + ' / ' + c.city : '-' %></td>
<td><%= c.product_count || 0 %></td>
<td>
<form method="POST" action="/categories/<%= c.id %>/update" class="d-inline-flex gap-2 align-items-center">
<% if (c.is_active === 1) { %>
<span class="badge bg-success">Active</span>
<% } else { %>
<span class="badge bg-secondary">Disabled</span>
<% } %>
</td>
<td>
<form method="POST" action="/categories/<%= c.id %>/update" class="d-inline-flex gap-2 align-items-center mb-1">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="name" class="form-control form-control-sm" value="<%= c.name %>" required>
<select name="location_id" class="form-select form-select-sm">
@@ -44,7 +56,15 @@
</select>
<button class="btn btn-sm btn-primary">Save</button>
</form>
<form method="POST" action="/categories/<%= c.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete category?')">
<form method="POST" action="/categories/<%= c.id %>/toggle" class="d-inline" onsubmit="<%= c.is_active === 1 ? "return confirm('Disable this category?')" : '' %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<% if (c.is_active === 1) { %>
<button class="btn btn-sm btn-warning">Disable</button>
<% } else { %>
<button class="btn btn-sm btn-success">Enable</button>
<% } %>
</form>
<form method="POST" action="/categories/<%= c.id %>/delete" class="d-inline" onsubmit="return confirm('Delete category?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button>
</form>
@@ -52,13 +72,38 @@
</tr>
<% var subs = subcategoriesByCategory[c.id] || []; %>
<% subs.forEach(function(s) { %>
<tr class="table-light">
<tr class="table-light<%= s.is_active === 0 ? ' table-secondary' : '' %>">
<td></td>
<td class="ps-4"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted me-1"><polyline points="9 18 15 12 9 6"/></svg> <%= s.name %></td>
<td class="ps-4">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted me-1"><polyline points="9 18 15 12 9 6"/></svg> <%= s.name %>
<% if (s.is_active === 0) { %>
<span class="badge bg-secondary ms-1">Disabled</span>
<% } %>
</td>
<td></td>
<td><%= s.product_count || 0 %></td>
<td>
<form method="POST" action="/categories/subcategories/<%= s.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete subcategory?')">
<% if (s.is_active === 1) { %>
<span class="badge bg-success">Active</span>
<% } else { %>
<span class="badge bg-secondary">Disabled</span>
<% } %>
</td>
<td>
<form method="POST" action="/categories/subcategories/<%= s.id %>/update" class="d-inline-flex gap-1 align-items-center mb-1">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="name" class="form-control form-control-sm" value="<%= s.name %>" required size="12" style="width:120px">
<button class="btn btn-sm btn-primary">Save</button>
</form>
<form method="POST" action="/categories/subcategories/<%= s.id %>/toggle" class="d-inline" onsubmit="<%= s.is_active === 1 ? "return confirm('Disable this subcategory?')" : '' %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<% if (s.is_active === 1) { %>
<button class="btn btn-sm btn-warning">Disable</button>
<% } else { %>
<button class="btn btn-sm btn-success">Enable</button>
<% } %>
</form>
<form method="POST" action="/categories/subcategories/<%= s.id %>/delete" class="d-inline" onsubmit="return confirm('Delete subcategory?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button>
</form>
@@ -74,7 +119,7 @@
<button class="btn btn-sm btn-primary">Add</button>
</form>
</td>
<td></td><td></td><td></td>
<td></td><td></td><td></td><td></td>
</tr>
<% }); %>
<% } %>

View File

@@ -30,13 +30,13 @@
<div class="panel-container show">
<div class="panel-content">
<table class="table table-striped table-bordered table-hover mb-0">
<thead><tr><th>ID</th><th>Country</th><th>City</th><th>District</th><th>Categories</th><th>Products</th><th>Actions</th></tr></thead>
<thead><tr><th>ID</th><th>Country</th><th>City</th><th>District</th><th>Categories</th><th>Products</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
<% if (locations.length === 0) { %>
<tr><td colspan="7" class="text-muted">No locations</td></tr>
<tr><td colspan="8" class="text-muted">No locations</td></tr>
<% } else { %>
<% locations.forEach(function(l) { %>
<tr>
<tr<%= l.is_active === 0 ? ' class="table-secondary"' : '' %>>
<td><%= l.id %></td>
<td><%= l.country %></td>
<td><%= l.city %></td>
@@ -44,6 +44,28 @@
<td><%= l.category_count || 0 %></td>
<td><%= l.product_count || 0 %></td>
<td>
<% if (l.is_active === 1) { %>
<span class="badge bg-success">Active</span>
<% } else { %>
<span class="badge bg-secondary">Disabled</span>
<% } %>
</td>
<td>
<form method="POST" action="/locations/<%= l.id %>/update" class="d-inline-flex gap-1 align-items-center mb-1">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="country" class="form-control form-control-sm" value="<%= l.country %>" required size="8" style="width:80px">
<input name="city" class="form-control form-control-sm" value="<%= l.city %>" required size="8" style="width:80px">
<input name="district" class="form-control form-control-sm" value="<%= l.district || '' %>" size="8" style="width:80px">
<button class="btn btn-sm btn-primary">Save</button>
</form>
<form method="POST" action="/locations/<%= l.id %>/toggle" class="d-inline" onsubmit="<%= l.is_active === 1 ? "return confirm('Disable this location?')" : '' %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<% if (l.is_active === 1) { %>
<button class="btn btn-sm btn-warning">Disable</button>
<% } else { %>
<button class="btn btn-sm btn-success">Enable</button>
<% } %>
</form>
<form method="POST" action="/locations/<%= l.id %>/delete" class="d-inline" onsubmit="return confirm('Delete location?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button>

View File

@@ -16,6 +16,13 @@
</a>
</li>
<li class="nav-item">
<a href="/categories" title="Categories" data-filter-tags="categories subcategories">
<svg class="sa-icon"><use href="/icons/sprite.svg#folder"></use></svg>
<span class="nav-link-text" data-i18n>Categories</span>
</a>
</li>
<li class="nav-item">
<a href="/users" title="Users" data-filter-tags="users customers">
<svg class="sa-icon"><use href="/icons/sprite.svg#users"></use></svg>

View File

@@ -0,0 +1,33 @@
import logger from '../utils/logger.js';
const TABLES = ['locations', 'categories', 'subcategories'];
export default async function migration011(db, checkColumnExists) {
let allExist = true;
for (const table of TABLES) {
if (!(await checkColumnExists(table, 'is_active'))) {
allExist = false;
break;
}
}
if (allExist) {
logger.info('Migration 011: is_active columns already exist, skipping');
return;
}
await db.runAsync('BEGIN TRANSACTION');
for (const table of TABLES) {
if (await checkColumnExists(table, 'is_active')) {
logger.info(`Migration 011: is_active already exists on ${table}, skipping`);
continue;
}
await db.runAsync(
`ALTER TABLE ${table} ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1`
);
logger.info(`Migration 011: Added is_active column to ${table} table`);
}
await db.runAsync('COMMIT');
logger.info('Migration 011: Added is_active columns to locations, categories, subcategories');
}

View File

@@ -45,6 +45,7 @@ export async function runMigrations() {
(await import('./008_user_language.js')).default,
(await import('./009_user_language_set.js')).default,
(await import('./010_is_mono_product.js')).default,
(await import('./011_active_flags.js')).default,
];
for (let i = currentVersion; i < migrations.length; i++) {

View File

@@ -5,7 +5,7 @@ class CategoryService {
static async getCategoriesByLocationId(locationId) {
try {
const categories = await db.allAsync(
'SELECT * FROM categories WHERE location_id = ?',
'SELECT * FROM categories WHERE location_id = ? AND is_active = 1',
[locationId]
);
return categories;
@@ -17,7 +17,7 @@ class CategoryService {
static async getSubcategoriesByCategoryId(categoryId) {
return await db.allAsync(
'SELECT id, name FROM subcategories WHERE category_id = ? ORDER BY name',
'SELECT id, name FROM subcategories WHERE category_id = ? AND is_active = 1 ORDER BY name',
[categoryId]
);
}

View File

@@ -3,26 +3,26 @@ import logger from "../utils/logger.js";
class LocationService {
static async getCountries() {
return await db.allAsync('SELECT DISTINCT country FROM locations ORDER BY country');
return await db.allAsync('SELECT DISTINCT country FROM locations WHERE is_active = 1 ORDER BY country');
}
static async getCitiesByCountry(country) {
return await db.allAsync(
'SELECT DISTINCT city FROM locations WHERE country = ? ORDER BY city',
'SELECT DISTINCT city FROM locations WHERE country = ? AND is_active = 1 ORDER BY city',
[country]
);
}
static async getDistrictsByCountryAndCity(country, city) {
return await db.allAsync(
'SELECT id, district FROM locations WHERE country = ? AND city = ? ORDER BY district',
'SELECT id, district FROM locations WHERE country = ? AND city = ? AND is_active = 1 ORDER BY district',
[country, city]
);
}
static async getLocationsByCountryAndCity(country, city) {
return await db.allAsync(
'SELECT id, country, city, district FROM locations WHERE country = ? AND city = ? ORDER BY district',
'SELECT id, country, city, district FROM locations WHERE country = ? AND city = ? AND is_active = 1 ORDER BY district',
[country, city]
);
}
@@ -30,7 +30,7 @@ class LocationService {
static async getLocation(country, city, district) {
try {
const location = await db.getAsync(
'SELECT * FROM locations WHERE country = ? AND city = ? AND district = ?',
'SELECT * FROM locations WHERE country = ? AND city = ? AND district = ? AND is_active = 1',
[country, city, district]
);
return location;