diff --git a/README.md b/README.md index a3ed70f..1983aa8 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ Company-scoped brands. Auth via `resolveCompanyAccess`. | --- | --- | --- | | `createUnderParent` | `({ googleAuthId, parentId, name })` | `{ id, parentId, sort, name, slug }` | | `rename` | `({ googleAuthId, id, name })` | `{ id, name, slug }` | +| `order` | `({ googleAuthId, parentId, categoryIds })` | `{ categoryIds }` | | `delete` | `({ googleAuthId, id })` | `void` — blocked by FK if `categoryarticle` or child categories remain | | `getTreeForCompany` | `(googleAuthId, companyId)` | `{ languageId, tree, articles }` — tree rows carry `name` + `slug` | | `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, fatherArticleId, fatherSku, fullHash, thumbW, thumbH, thumbHash }[]` | @@ -203,6 +204,8 @@ Company-scoped brands. Auth via `resolveCompanyAccess`. `rename` updates the `categorytext` name (and re-slugifies `slug`) for the category in the company default language. Empty name → `Name required`. Auth via `resolveCategoryAccess` on the category (`Parent not found` / `Not authorized`). Missing text row → `Category not found`. Returns `{ id, name, slug }`. For `PATCH /api/category/:id`. +`order` reorders the direct children of `parentId` to the given `categoryIds` order (each child's `sort` set to its index `0..n-1`). Since a category can have multiple parents, `parentId` scopes which sibling list is reordered. `categoryIds` must be exactly the current children of `parentId` (same set, no dups) else `Invalid category order`. Auth via `resolveCategoryAccess` on `parentId`. Returns `{ categoryIds }`. For `PUT /api/category/order`. + --- ## `article` @@ -317,6 +320,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `GET /api/category/tree?companyId=` | `category.getTreeForCompany(googleAuthId, companyId)` | | `POST /api/category` | `category.createUnderParent({ googleAuthId, parentId, name })` | | `PATCH /api/category/:id` | `category.rename({ googleAuthId, id, name })` | +| `PUT /api/category/order` | `category.order({ googleAuthId, parentId, categoryIds })` | | `DELETE /api/category/:id` | `category.delete({ googleAuthId, id })` | | `GET /api/unit?companyId=` | `unit.list(googleAuthId, companyId)` | | `PUT /api/unit` | `unit.upsert(googleAuthId, companyId, { si, basesi, basesifactor, symbol, name })` | diff --git a/scripts/test-api.js b/scripts/test-api.js index bb914aa..a49f877 100644 --- a/scripts/test-api.js +++ b/scripts/test-api.js @@ -163,6 +163,7 @@ const MENU = { items: [ { value: 'category.createUnderParent', label: 'createUnderParent', hint: 'googleAuthId, parentId, name → row' }, { value: 'category.rename', label: 'rename', hint: 'googleAuthId, id, name → row' }, + { value: 'category.order', label: 'order', hint: 'googleAuthId, parentId, JSON categoryIds' }, { value: 'category.delete', label: 'delete', hint: 'googleAuthId, id' }, { value: 'category.getTreeForCompany', label: 'getTreeForCompany', hint: 'googleAuthId, companyId → rows' }, { value: 'category.getCategoryArticles', label: 'getCategoryArticles', hint: 'googleAuthId, categoryId → rows' }, @@ -670,6 +671,33 @@ async function runMethod(client, action) { }), ]; } + case 'category.order': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const parentId = await askInt('parent id'); + if (parentId == null) return; + const categoryIdsJson = await askText('categoryIds JSON', { + placeholder: '[2,4,3,5]', + validate: (v) => { + if (!v?.trim()) return 'Required'; + try { + const parsed = JSON.parse(v); + if (!Array.isArray(parsed)) return 'Must be a JSON array'; + } catch { + return 'Invalid JSON'; + } + }, + }); + if (categoryIdsJson == null) return; + return [ + 'result', + await client.category.order({ + googleAuthId: Number(googleAuthId), + parentId: Number(parentId), + categoryIds: JSON.parse(categoryIdsJson), + }), + ]; + } case 'category.delete': { const googleAuthId = await askInt('google_auth id'); if (googleAuthId == null) return; diff --git a/src/index.js b/src/index.js index 6864218..a0a8d2e 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import categoryGetTreeForCompany from './store/category/getTreeForCompany.js'; import categoryGetCategoryArticles from './store/category/getCategoryArticles.js'; import categoryCreateUnderParent from './store/category/createUnderParent.js'; import categoryRename from './store/category/rename.js'; +import categoryOrder from './store/category/order.js'; import categoryDelete from './store/category/delete.js'; import articleDelete from './store/article/delete.js'; import articleDeleteImage from './store/article/deleteImage.js'; @@ -73,6 +74,7 @@ export default class LibDashStore { getCategoryArticles: categoryGetCategoryArticles(pool), createUnderParent: categoryCreateUnderParent(pool), rename: categoryRename(pool), + order: categoryOrder(pool), delete: categoryDelete(pool), }; diff --git a/src/sql/category.js b/src/sql/category.js index b1ca810..a683979 100644 --- a/src/sql/category.js +++ b/src/sql/category.js @@ -91,6 +91,18 @@ FROM category WHERE parent_id = @parentId `; +export const LIST_CHILD_IDS = ` +SELECT id +FROM category +WHERE parent_id = @parentId +`; + +export const UPDATE_SORT = ` +UPDATE category +SET sort = @sort +WHERE id = @id AND parent_id = @parentId +`; + export const DELETE = ` DELETE FROM category WHERE id = @id diff --git a/src/store/category/order.js b/src/store/category/order.js new file mode 100644 index 0000000..ae1f1a9 --- /dev/null +++ b/src/store/category/order.js @@ -0,0 +1,62 @@ +import { + LIST_CHILD_IDS, + UPDATE_SORT, +} from '../../sql/category.js'; +import request from '../request.js'; +import resolveCategoryAccess from './resolveCategoryAccess.js'; + +export default function order(pool) { + const access = resolveCategoryAccess(pool); + + return async ({ googleAuthId, parentId, categoryIds }) => { + if (!Array.isArray(categoryIds)) { + throw new Error('Invalid category order'); + } + + // Auth + ensures parentId is a real category of the user's company. + await access(googleAuthId, parentId); + + const children = await pool + .request() + .input('parentId', parentId) + .query(LIST_CHILD_IDS); + + const existing = new Set(children.recordset.map((r) => r.id)); + + if (categoryIds.length !== existing.size) { + throw new Error('Invalid category order'); + } + + const seen = new Set(); + for (const categoryId of categoryIds) { + if (!Number.isFinite(categoryId) || !existing.has(categoryId) || seen.has(categoryId)) { + throw new Error('Invalid category order'); + } + seen.add(categoryId); + } + + const tx = pool.transaction(); + await tx.begin(); + + try { + for (let sort = 0; sort < categoryIds.length; sort++) { + await request(pool, tx) + .input('id', categoryIds[sort]) + .input('parentId', parentId) + .input('sort', sort) + .query(UPDATE_SORT); + } + + await tx.commit(); + + return { categoryIds }; + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + }; +}