diff --git a/API.category.md b/API.category.md index 9ff2c68..3be5750 100644 --- a/API.category.md +++ b/API.category.md @@ -59,7 +59,7 @@ const category = await store.category.createUnderParent({ googleAuthId: 42, pare *Source: `src/store/category/delete.js`* -Deletes a category, refusing to delete the company's root category. +Deletes a category, refusing to delete the company's root category. If recursive is true, recursively deletes the subtree and articles that only belong to it. **Parameters** @@ -68,6 +68,7 @@ Deletes a category, refusing to delete the company's root category. | `input` | `object` | The delete input. | | `input.googleAuthId` | `number` | The id of the Google auth user. | | `input.id` | `number` | The id of the category to delete. | +| `[input.recursive=false]` | `boolean` | Whether to delete the category subtree recursively. | | `[tx]` | `object` | Optional transaction to run within. | **Returns** @@ -76,12 +77,13 @@ Deletes a category, refusing to delete the company's root category. **Throws** -`Error` — If the category is the root category or contains articles/subcategories. +`Error` — If the category is the root category or contains articles/subcategories (when recursive is false). **Example** ```js await store.category.delete({ googleAuthId: 42, id: 5 }); +await store.category.delete({ googleAuthId: 42, id: 5, recursive: true }); ``` ## `category.deleteImage` diff --git a/API.md b/API.md index dda98ea..e7a9c78 100644 --- a/API.md +++ b/API.md @@ -76,7 +76,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - [category](API.category.md) - `addImage(googleAuthId: number, categoryId: number, image: Buffer, tx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>` - `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}, tx?: object): Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>` - - `delete(input: {googleAuthId: number, id: number}, tx?: object): Promise` + - `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise` - `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>` - `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise` - `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise>` diff --git a/scripts/test-category-delete-recursive.js b/scripts/test-category-delete-recursive.js new file mode 100644 index 0000000..a0ab8cc --- /dev/null +++ b/scripts/test-category-delete-recursive.js @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import LibDashStore from '../src/index.js'; +import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; + +async function run() { + console.log('--- Testing category recursive deletion ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-cat-del-rec-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + const googleAuthId = await store.google_auth.add('tester@example.com'); + const company = await store.company.create({ + name: 'Recursive Test Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + const rootId = company.rootCategoryId; + + // 1. Refuse to delete root category, even with recursive: true + console.log('1. Testing refusal to delete root category...'); + await assert.rejects( + () => store.category.delete({ googleAuthId, id: rootId, recursive: true }), + /Cannot delete root category/ + ); + + // 2. Build category tree: + // Root + // ├── OutsideCat (for preserved articles) + // └── MainCat (to be recursively deleted) + // ├── SubCat1 + // │ └── SubSubCat1 + // └── SubCat2 + console.log('2. Building category tree...'); + const outsideCat = await store.category.createUnderParent({ + googleAuthId, + parentId: rootId, + name: 'Outside Category', + }); + const mainCat = await store.category.createUnderParent({ + googleAuthId, + parentId: rootId, + name: 'Main Category', + }); + const subCat1 = await store.category.createUnderParent({ + googleAuthId, + parentId: mainCat.id, + name: 'Sub Category 1', + }); + const subSubCat1 = await store.category.createUnderParent({ + googleAuthId, + parentId: subCat1.id, + name: 'Sub Sub Category 1', + }); + const subCat2 = await store.category.createUnderParent({ + googleAuthId, + parentId: mainCat.id, + name: 'Sub Category 2', + }); + + // 3. Test non-recursive delete guards + console.log('3. Testing non-recursive delete guards...'); + await assert.rejects( + () => store.category.delete({ googleAuthId, id: mainCat.id, recursive: false }), + /Category has subcategories/ + ); + + // 4. Create articles: + // Article 1: Only in SubSubCat1 -> Should be deleted + // Article 2: In SubCat1 AND OutsideCat -> Should survive, removed from SubCat1 + // Article 3: Parent in SubCat2 with a variant child -> Both should be deleted + // Article 4: Outside article in OutsideCat -> Untouched + console.log('4. Creating articles in categories...'); + const artOnlySub = await store.article.createUnderParent({ + googleAuthId, + parentId: subSubCat1.id, + name: 'Only SubSubCat Article', + }); + + const artMulti = await store.article.createUnderParent({ + googleAuthId, + parentId: subCat1.id, + name: 'Multi Category Article', + }); + // Add outsideCat to artMulti + await store.article.setCategoryIdsBySku(googleAuthId, artMulti.sku, [subCat1.id, outsideCat.id]); + + const attrName = await store.attributename.create(googleAuthId, { name: 'Size' }); + const artParent = await store.article.createUnderParent({ + googleAuthId, + parentId: subCat2.id, + name: 'Parent Article In Sub2', + }); + await store.article.toggleParent(googleAuthId, artParent.sku, { + isParent: true, + attributeNameId: attrName.id, + }); + const artChild = await store.article.addChild(googleAuthId, artParent.sku, { + value: 'M', + }); + + const artOutside = await store.article.createUnderParent({ + googleAuthId, + parentId: outsideCat.id, + name: 'Purely Outside Article', + }); + + // 5. Test POS terminal assignment unlinking on recursive delete + console.log('5. Setting up POS terminal assignment...'); + const posTerminal = await store.posterminal.upsert(googleAuthId, { + name: 'POS Register 1', + pairingpin: '1234', + }); + await store.posterminal.setOn(googleAuthId, posTerminal.id, subCat2.id); + await store.posterminal.setOn(googleAuthId, posTerminal.id, outsideCat.id); + + const posCatsBefore = provider.db.prepare( + 'SELECT category_id FROM posterminalcategory WHERE posterminal_id = ?' + ).all(posTerminal.id).map(r => r.category_id); + assert.ok(posCatsBefore.includes(subCat2.id)); + assert.ok(posCatsBefore.includes(outsideCat.id)); + + // 6. Execute recursive deletion of mainCat + console.log('6. Executing recursive category delete on mainCat...'); + await store.category.delete({ + googleAuthId, + id: mainCat.id, + recursive: true, + }); + + // 7. Verify category tree state + console.log('7. Verifying category tree...'); + const catsRemaining = provider.db.prepare('SELECT id FROM category').all().map(r => r.id); + assert.ok(catsRemaining.includes(rootId)); + assert.ok(catsRemaining.includes(outsideCat.id)); + assert.ok(!catsRemaining.includes(mainCat.id)); + assert.ok(!catsRemaining.includes(subCat1.id)); + assert.ok(!catsRemaining.includes(subSubCat1.id)); + assert.ok(!catsRemaining.includes(subCat2.id)); + + // 8. Verify POS terminal assignment was unlinked for deleted category + console.log('8. Verifying POS terminal categories...'); + const posCatsAfter = provider.db.prepare( + 'SELECT category_id FROM posterminalcategory WHERE posterminal_id = ?' + ).all(posTerminal.id).map(r => r.category_id); + assert.ok(!posCatsAfter.includes(subCat2.id)); + assert.ok(posCatsAfter.includes(outsideCat.id)); + + // 9. Verify article states: + console.log('9. Verifying article states...'); + // artOnlySub: deleted + await assert.rejects( + () => store.article.getBySku(googleAuthId, artOnlySub.sku), + /Article not found/ + ); + // tombstone for artOnlySub exists + const t1 = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artOnlySub.sku); + assert.equal(t1.length, 1); + + // artParent and its child artChild: both deleted + await assert.rejects( + () => store.article.getBySku(googleAuthId, artParent.sku), + /Article not found/ + ); + await assert.rejects( + () => store.article.getBySku(googleAuthId, artChild.article.sku), + /Article not found/ + ); + const tParent = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artParent.sku); + const tChild = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artChild.article.sku); + assert.equal(tParent.length, 1); + assert.equal(tChild.length, 1); + + // artMulti: continues to exist! + const artMultiDetail = await store.article.getBySku(googleAuthId, artMulti.sku); + assert.equal(artMultiDetail.article.sku, artMulti.sku); + // artMulti categoryarticle now ONLY contains outsideCat + const artMultiCats = provider.db.prepare( + 'SELECT category_id FROM categoryarticle WHERE article_id = ?' + ).all(artMultiDetail.article.id).map(r => r.category_id); + assert.deepEqual(artMultiCats, [outsideCat.id]); + + // artOutside: completely untouched + const artOutsideDetail = await store.article.getBySku(googleAuthId, artOutside.sku); + assert.equal(artOutsideDetail.article.sku, artOutside.sku); + + console.log('✅ ALL RECURSIVE CATEGORY DELETE TESTS PASSED'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/src/store/category/delete.js b/src/store/category/delete.js index 836dd35..770425b 100644 --- a/src/store/category/delete.js +++ b/src/store/category/delete.js @@ -1,26 +1,36 @@ import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/category.js'; import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/category.js'; +import * as mssqlArticleDelete from '../../providers/mssql/queries/articleDelete.js'; +import * as sqliteArticleDelete from '../../providers/sqlite3/queries/articleDelete.js'; +import * as mssqlArticle from '../../providers/mssql/queries/article.js'; +import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; import makeRunner from '../runner.js'; export default function del(provider) { const access = resolveCategoryAccess(provider); - const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const articleDeleteQueries = isSqlite ? sqliteArticleDelete : mssqlArticleDelete; + const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; /** * Deletes a category, refusing to delete the company's root category. + * If recursive is true, recursively deletes the subtree and articles that only belong to it. * * @param {object} input - The delete input. * @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.id - The id of the category to delete. + * @param {boolean} [input.recursive=false] - Whether to delete the category subtree recursively. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} - * @throws {Error} If the category is the root category or contains articles/subcategories. + * @throws {Error} If the category is the root category or contains articles/subcategories (when recursive is false). * * @example * await store.category.delete({ googleAuthId: 42, id: 5 }); + * await store.category.delete({ googleAuthId: 42, id: 5, recursive: true }); */ - return async ({ googleAuthId, id }, tx) => { + return async ({ googleAuthId, id, recursive = false }, tx) => { const run = async (runner) => { const { rootCategoryId } = await access(googleAuthId, id, runner); @@ -28,41 +38,183 @@ export default function del(provider) { throw new Error('Cannot delete root category'); } - const childRes = await runner.query( - 'SELECT id FROM category WHERE parent_id = @id', - { id }, - ); - if (childRes.rows?.length > 0) { - throw new Error('Category has subcategories'); - } + if (!recursive) { + const childRes = await runner.query( + 'SELECT id FROM category WHERE parent_id = @id', + { id }, + ); + if (childRes.rows?.length > 0) { + throw new Error('Category has subcategories'); + } - const artRes = await runner.query( - 'SELECT article_id FROM categoryarticle WHERE category_id = @id', - { id }, - ); - if (artRes.rows?.length > 0) { - throw new Error('Category has articles'); - } - - const posRes = await runner.query( - 'SELECT posterminal_id FROM posterminalcategory WHERE category_id = @id', - { id }, - ); - if (posRes.rows?.length > 0) { - throw new Error('Category is assigned to POS terminals'); - } - - try { - await runner.query(sql, { id }); - } catch (err) { - if ( - err.message?.includes('FOREIGN KEY constraint failed') || - err.message?.includes('foreign key constraint') || - err.message?.includes('REFERENCE constraint') - ) { + const artRes = await runner.query( + 'SELECT article_id FROM categoryarticle WHERE category_id = @id', + { id }, + ); + if (artRes.rows?.length > 0) { throw new Error('Category has articles'); } - throw err; + + const posRes = await runner.query( + 'SELECT posterminal_id FROM posterminalcategory WHERE category_id = @id', + { id }, + ); + if (posRes.rows?.length > 0) { + throw new Error('Category is assigned to POS terminals'); + } + + try { + await runner.query(sql, { id }); + } catch (err) { + if ( + err.message?.includes('FOREIGN KEY constraint failed') || + err.message?.includes('foreign key constraint') || + err.message?.includes('REFERENCE constraint') + ) { + throw new Error('Category has articles'); + } + throw err; + } + return; + } + + // 1. Collect all category IDs in the subtree (BFS) + const subtreeIds = []; + const queue = [id]; + while (queue.length > 0) { + const currentId = queue.shift(); + subtreeIds.push(currentId); + const childRes = await runner.query( + 'SELECT id FROM category WHERE parent_id = @id', + { id: currentId }, + ); + for (const row of childRes.rows) { + queue.push(row.id); + } + } + const subtreeSet = new Set(subtreeIds); + + // 2. Unassign POS terminal categories for the subtree + for (const catId of subtreeIds) { + await runner.query( + 'DELETE FROM posterminalcategory WHERE category_id = @categoryId', + { categoryId: catId }, + ); + } + + // 3. Find all articles assigned to any category in the subtree + const affectedArticleIds = new Set(); + for (const catId of subtreeIds) { + const artRes = await runner.query( + 'SELECT article_id FROM categoryarticle WHERE category_id = @categoryId', + { categoryId: catId }, + ); + for (const row of artRes.rows) { + affectedArticleIds.add(row.article_id); + } + } + + // 4. For each article, determine if it has categories outside the subtree + const articlesToDelete = []; + for (const articleId of affectedArticleIds) { + const allCatsRes = await runner.query( + 'SELECT category_id FROM categoryarticle WHERE article_id = @articleId', + { articleId }, + ); + const remainingCats = allCatsRes.rows + .map((r) => r.category_id) + .filter((catId) => !subtreeSet.has(catId)); + + if (remainingCats.length > 0) { + // Article remains in another category: remove assignment to deleted subtree categories + for (const catRow of allCatsRes.rows) { + if (subtreeSet.has(catRow.category_id)) { + await runner.query( + 'DELETE FROM categoryarticle WHERE category_id = @categoryId AND article_id = @articleId', + { categoryId: catRow.category_id, articleId }, + ); + } + } + } else { + // Article only belongs to categories in the deleted subtree: will be deleted + articlesToDelete.push(articleId); + } + } + + // Helper to delete an article and its child articles recursively + const deleteArticleAndLists = async (artId) => { + const listIds = await runner.query(articleDeleteQueries.GET_LIST_IDS, { articleId: artId }); + const { articlelistId, optionslistId } = listIds.rows[0] ?? {}; + + await runner.query(articleDeleteQueries.CREATE_TOMBSTONE, { articleId: artId }); + await runner.query(articleDeleteQueries.DELETE_PICS_BY_ARTICLE, { articleId: artId }); + + const deleted = await runner.query(articleDeleteQueries.DELETE_BY_ID, { articleId: artId }); + if (!deleted.rowsAffected) { + throw new Error('Article not found'); + } + + if (articlelistId != null) { + await runner.query(articleDeleteQueries.DELETE_ARTICLELIST, { id: articlelistId }); + } + + if (optionslistId != null) { + await runner.query(articleDeleteQueries.DELETE_OPTIONSLIST, { id: optionslistId }); + } + }; + + const deleteArticleSubtree = async (artId) => { + const children = await runner.query(articleDeleteQueries.GET_CHILDREN_IDS, { id: artId }); + for (const child of children.rows) { + await deleteArticleSubtree(child.id); + } + await deleteArticleAndLists(artId); + }; + + // 5. Delete articles and their children + for (const artId of articlesToDelete) { + // Check if article still exists (might have already been deleted as a child of another article) + const artRes = await runner.query( + 'SELECT id, parentarticle_id FROM article WHERE id = @articleId', + { articleId: artId }, + ); + if (!artRes.rows || artRes.rows.length === 0) { + continue; + } + + const parentArticleId = artRes.rows[0].parentarticle_id; + + await deleteArticleSubtree(artId); + + // If it was a child of a surviving parent, re-sort the surviving parent's remaining children + if (parentArticleId != null) { + const parentExists = await runner.query( + 'SELECT id FROM article WHERE id = @parentArticleId', + { parentArticleId }, + ); + if (parentExists.rows?.length > 0) { + const remainingChildrenRes = await runner.query( + 'SELECT id, parentsort FROM article WHERE parentarticle_id = @parentArticleId ORDER BY parentsort ASC, id ASC', + { parentArticleId }, + ); + for (let i = 0; i < remainingChildrenRes.rows.length; i++) { + const childRow = remainingChildrenRes.rows[i]; + if (Number(childRow.parentsort) !== i) { + await runner.query(articleQueries.SET_PARENT_SORT, { + articleId: childRow.id, + parentArticleId, + parentsort: i, + }); + } + } + } + } + } + + // 6. Delete categories in bottom-up order (reverse BFS) + const reversedIds = [...subtreeIds].reverse(); + for (const catId of reversedIds) { + await runner.query(sql, { id: catId }); } };