u
This commit is contained in:
@@ -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<void>}
|
||||
* @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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user