From b890843445b0cd73979323d3b3828d008c7c57e4 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 2 Aug 2026 19:01:00 +0200 Subject: [PATCH] u --- README.md | 20 ++++ scripts/test-api.js | 141 +++++++++++++++++++++++ src/index.js | 10 ++ src/sql/article.js | 38 ++++++ src/sql/categorytext.js | 6 + src/store/article/setBrandIdBySku.js | 52 +++++++++ src/store/article/setCategoryIdsBySku.js | 74 ++++++++++++ src/store/article/setEansBySku.js | 65 +++++++++++ src/store/article/setWeightBySku.js | 33 ++++++ src/store/category/rename.js | 41 +++++++ 10 files changed, 480 insertions(+) create mode 100644 src/store/article/setBrandIdBySku.js create mode 100644 src/store/article/setCategoryIdsBySku.js create mode 100644 src/store/article/setEansBySku.js create mode 100644 src/store/article/setWeightBySku.js create mode 100644 src/store/category/rename.js diff --git a/README.md b/README.md index 9f64d07..48230b3 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ Company-scoped brands. Auth via `resolveCompanyAccess`. | Method | Signature | Returns | | --- | --- | --- | | `createUnderParent` | `({ googleAuthId, parentId, name })` | `{ id, parentId, sort, name }` | +| `rename` | `({ googleAuthId, id, name })` | `{ id, name }` | | `delete` | `({ googleAuthId, id })` | `void` — blocked by FK if `categoryarticle` or child categories remain | | `getTreeForCompany` | `(googleAuthId, companyId)` | `{ languageId, tree, articles }` | | `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, fatherArticleId, fatherSku, fullHash, thumbW, thumbH, thumbHash }[]` | @@ -200,6 +201,8 @@ Company-scoped brands. Auth via `resolveCompanyAccess`. `getTreeForCompany` throws `Not authorized` if `googleAuthId` is not a `companyuser` of `companyId`, and `Company not found` if the company is missing, has no `root_category_id`, or no `default_language_id`. +`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 }`. For `PATCH /api/category/:id`. + --- ## `article` @@ -210,6 +213,10 @@ Company-scoped brands. Auth via `resolveCompanyAccess`. | `getBySku` | `(googleAuthId, sku)` | detail object (see below) | | `updateBySku` | `(googleAuthId, sku, { name?, unitamount?, unitSi? })` | same shape as `getBySku` | | `setPricesBySku` | `(googleAuthId, sku, [{ price, tax, minmultiplier? }])` | same shape as `getBySku` | +| `setEansBySku` | `(googleAuthId, sku, string[])` | same shape as `getBySku` | +| `setBrandIdBySku` | `(googleAuthId, sku, brandId \| null)` | same shape as `getBySku` | +| `setWeightBySku` | `(googleAuthId, sku, weight)` | same shape as `getBySku` | +| `setCategoryIdsBySku` | `(googleAuthId, sku, number[])` | same shape as `getBySku` | | `addImage` | `(googleAuthId, sku, imageBuffer)` | image result (see below) | | `deleteImage` | `(googleAuthId, sku, fullHash)` | `void` | | `orderImages` | `(googleAuthId, sku, { fullHashes })` | `{ fullHashes }` | @@ -257,6 +264,14 @@ Auth via the article's `categoryarticle` category + `resolveCategoryAccess`. Err `setPricesBySku` replaces all `price` rows for the article’s company `poscustomergroup_id` in one transaction. Each entry: `price` → `net`, `tax` → tax rate (ensured via `tax.ensure`), `minmultiplier` defaults to `1`. Empty array clears all POS prices. Returns the same object as `getBySku`. Errors: `Article not found`, `Not authorized`, `POS customer group required`, `Invalid prices`. +`setEansBySku` replaces all `articleean` rows for the article in one transaction (`replace-all` semantics). EANs must be non-empty unique strings; empty array clears all EANs. Returns the same object as `getBySku` (whose `eans` reflects the new list). Errors: `Article not found`, `Not authorized`, `Invalid eans`. + +`setBrandIdBySku` sets the article’s `brand_id`. Pass `null` to clear the brand. Requires the brand to belong to the article’s company (else `Brand not found`). Non-null must be a finite id. Errors: `Article not found`, `Not authorized`, `Brand not found`. + +`setWeightBySku` sets the article’s `weight`. Must be finite and `>= 0` (else `Invalid weight`). Errors: `Article not found`, `Not authorized`, `Invalid weight`. + +`setCategoryIdsBySku` replaces the article’s category list (`categoryarticle`) in one transaction. Each category must belong to the article’s company and ids must be unique (else `Invalid categoryIds`); empty array removes the article from all categories. Returns the same object as `getBySku` (whose `categoryIds` reflects the new list). Errors: `Article not found`, `Not authorized`, `Invalid categoryIds`. + `deleteImage` removes `articlepic` and the `pic` row for this article (`fullHash`). Errors: `Article not found`, `Image not found`, `Not authorized`. `orderImages` sets `articlepic.sort` to `0..n-1` in the given order. `fullHashes` must list every image of the article exactly once. Errors: `Article not found`, `Invalid image order`, `Not authorized`. @@ -301,6 +316,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `PUT /api/auth/selected-company` | `google_auth.setSelectedCompany(googleAuthId, companyId)` | | `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 })` | | `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 })` | @@ -315,6 +331,10 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` | | `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name?, unitamount?, unitSi? })` | | `PUT /api/article/:sku/prices` | `article.setPricesBySku(googleAuthId, sku, [{ price, tax, minmultiplier? }])` | +| `PUT /api/article/:sku/eans` | `article.setEansBySku(googleAuthId, sku, string[])` | +| `PUT /api/article/:sku/brand` | `article.setBrandIdBySku(googleAuthId, sku, brandId \| null)` | +| `PUT /api/article/:sku/weight` | `article.setWeightBySku(googleAuthId, sku, weight)` | +| `PUT /api/article/:sku/categories` | `article.setCategoryIdsBySku(googleAuthId, sku, number[])` | | `DELETE /api/article/:sku` | `article.delete(googleAuthId, sku)` | | `POST /api/article/:sku/image` | `article.addImage(googleAuthId, sku, imageBuffer)` | | `DELETE /api/article/:sku/image/:fullHash` | `article.deleteImage(googleAuthId, sku, fullHash)` | diff --git a/scripts/test-api.js b/scripts/test-api.js index b066691..bb914aa 100644 --- a/scripts/test-api.js +++ b/scripts/test-api.js @@ -162,6 +162,7 @@ const MENU = { label: 'category', items: [ { value: 'category.createUnderParent', label: 'createUnderParent', hint: 'googleAuthId, parentId, name → row' }, + { value: 'category.rename', label: 'rename', hint: 'googleAuthId, id, name → row' }, { 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' }, @@ -190,6 +191,26 @@ const MENU = { label: 'setPricesBySku', hint: 'googleAuthId, sku, JSON prices → row', }, + { + value: 'article.setEansBySku', + label: 'setEansBySku', + hint: 'googleAuthId, sku, JSON eans → row', + }, + { + value: 'article.setBrandIdBySku', + label: 'setBrandIdBySku', + hint: 'googleAuthId, sku, brandId|null → row', + }, + { + value: 'article.setWeightBySku', + label: 'setWeightBySku', + hint: 'googleAuthId, sku, weight → row', + }, + { + value: 'article.setCategoryIdsBySku', + label: 'setCategoryIdsBySku', + hint: 'googleAuthId, sku, JSON categoryIds → row', + }, { value: 'article.addImage', label: 'addImage', @@ -631,6 +652,24 @@ async function runMethod(client, action) { }), ]; } + case 'category.rename': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const id = await askInt('Category id'); + if (id == null) return; + const name = await askText('name', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (name == null) return; + return [ + 'row', + await client.category.rename({ + googleAuthId: Number(googleAuthId), + id: Number(id), + name, + }), + ]; + } case 'category.delete': { const googleAuthId = await askInt('google_auth id'); if (googleAuthId == null) return; @@ -755,6 +794,108 @@ async function runMethod(client, action) { ), ]; } + case 'article.setEansBySku': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const sku = await askText('sku', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (sku == null) return; + const eansJson = await askText('eans JSON', { + placeholder: '["4006381333931","4012345678901"]', + 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 (eansJson == null) return; + return [ + 'result', + await client.article.setEansBySku( + Number(googleAuthId), + sku, + JSON.parse(eansJson), + ), + ]; + } + case 'article.setBrandIdBySku': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const sku = await askText('sku', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (sku == null) return; + const raw = await askText('brandId (or "null")', { + validate: (v) => { + if (v === null || v === undefined || v.trim() === '') return 'Required'; + const t = v.trim(); + if (t === 'null') return undefined; + if (!Number.isFinite(Number(t))) return 'Must be a number or null'; + }, + }); + if (raw == null) return; + const brandId = raw.trim() === 'null' ? null : Number(raw.trim()); + return [ + 'result', + await client.article.setBrandIdBySku( + Number(googleAuthId), + sku, + brandId, + ), + ]; + } + case 'article.setWeightBySku': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const sku = await askText('sku', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (sku == null) return; + const weight = await askInt('weight'); + if (weight == null) return; + return [ + 'result', + await client.article.setWeightBySku( + Number(googleAuthId), + sku, + weight, + ), + ]; + } + case 'article.setCategoryIdsBySku': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const sku = await askText('sku', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (sku == null) return; + const categoryIdsJson = await askText('categoryIds JSON', { + placeholder: '[10,23,42]', + 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.article.setCategoryIdsBySku( + Number(googleAuthId), + sku, + JSON.parse(categoryIdsJson), + ), + ]; + } case 'article.addImage': { const googleAuthId = await askInt('google_auth id'); if (googleAuthId == null) return; diff --git a/src/index.js b/src/index.js index 8aeb0b5..6864218 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ import companyList from './store/company/list.js'; 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 categoryDelete from './store/category/delete.js'; import articleDelete from './store/article/delete.js'; import articleDeleteImage from './store/article/deleteImage.js'; @@ -17,6 +18,10 @@ import articleCreateUnderParent from './store/article/createUnderParent.js'; import articleGetBySku from './store/article/getBySku.js'; import articleUpdateBySku from './store/article/updateBySku.js'; import articleSetPricesBySku from './store/article/setPricesBySku.js'; +import articleSetEansBySku from './store/article/setEansBySku.js'; +import articleSetBrandIdBySku from './store/article/setBrandIdBySku.js'; +import articleSetWeightBySku from './store/article/setWeightBySku.js'; +import articleSetCategoryIdsBySku from './store/article/setCategoryIdsBySku.js'; import unitList from './store/unit/list.js'; import unitUpsert from './store/unit/upsert.js'; import unitDelete from './store/unit/delete.js'; @@ -67,6 +72,7 @@ export default class LibDashStore { getTreeForCompany: categoryGetTreeForCompany(pool), getCategoryArticles: categoryGetCategoryArticles(pool), createUnderParent: categoryCreateUnderParent(pool), + rename: categoryRename(pool), delete: categoryDelete(pool), }; @@ -75,6 +81,10 @@ export default class LibDashStore { getBySku: articleGetBySku(pool), updateBySku: articleUpdateBySku(pool), setPricesBySku: articleSetPricesBySku(pool), + setEansBySku: articleSetEansBySku(pool), + setBrandIdBySku: articleSetBrandIdBySku(pool), + setWeightBySku: articleSetWeightBySku(pool), + setCategoryIdsBySku: articleSetCategoryIdsBySku(pool), addImage: articleAddImage(pool), deleteImage: articleDeleteImage(pool), orderImages: articleOrderImages(pool), diff --git a/src/sql/article.js b/src/sql/article.js index 3043fbe..af3257b 100644 --- a/src/sql/article.js +++ b/src/sql/article.js @@ -98,6 +98,18 @@ LEFT JOIN companyuser cu ON cu.company_id = co.id AND cu.google_auth_id = @googleAuthId `; +export const UPDATE_WEIGHT = ` +UPDATE article +SET weight = @weight +WHERE id = @articleId +`; + +export const UPDATE_BRAND = ` +UPDATE article +SET brand_id = @brandId +WHERE id = @articleId +`; + export const UPDATE_UNIT = ` UPDATE article SET unit_id = @unitId @@ -121,3 +133,29 @@ INSERT INTO article (sku, unit_id, unitamount, weight) OUTPUT INSERTED.id VALUES (@sku, @unitId, @unitamount, @weight) `; + +export const DELETE_EANS_BY_ARTICLE = ` +DELETE FROM articleean +WHERE article_id = @articleId +`; + +export const ADD_EAN = ` +INSERT INTO articleean (article_id, ean) +VALUES (@articleId, @ean) +`; + +export const DELETE_CATEGORIES_BY_ARTICLE = ` +DELETE FROM categoryarticle +WHERE article_id = @articleId +`; + +export const ADD_CATEGORY = ` +INSERT INTO categoryarticle (category_id, article_id) +VALUES (@categoryId, @articleId) +`; + +export const GET_BRAND_BY_COMPANY_AND_ID = ` +SELECT id +FROM brand +WHERE id = @brandId AND company_id = @companyId +`; diff --git a/src/sql/categorytext.js b/src/sql/categorytext.js index e35a170..a44ba3d 100644 --- a/src/sql/categorytext.js +++ b/src/sql/categorytext.js @@ -2,3 +2,9 @@ export const CREATE = ` INSERT INTO categorytext (category_id, language_id, name, slug) VALUES (@categoryId, @languageId, @name, @slug) `; + +export const UPDATE_BY_CATEGORY_AND_LANGUAGE = ` +UPDATE categorytext +SET name = @name, slug = @slug +WHERE category_id = @categoryId AND language_id = @languageId +`; diff --git a/src/store/article/setBrandIdBySku.js b/src/store/article/setBrandIdBySku.js new file mode 100644 index 0000000..63f1291 --- /dev/null +++ b/src/store/article/setBrandIdBySku.js @@ -0,0 +1,52 @@ +import { + GET_BRAND_BY_COMPANY_AND_ID, + UPDATE_BRAND, +} from '../../sql/article.js'; +import queryBySku from './queryBySku.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; + +export default function setBrandIdBySku(pool) { + const resolve = resolveArticleBySku(pool); + const load = queryBySku(pool); + + return async (googleAuthId, sku, brandId) => { + const { articleId, languageId, companyId } = await resolve( + googleAuthId, + sku, + ); + + if (brandId !== null && !Number.isFinite(brandId)) { + throw new Error('Brand not found'); + } + + let resolvedBrandId = null; + if (brandId !== null) { + const brand = await pool + .request() + .input('brandId', brandId) + .input('companyId', companyId) + .query(GET_BRAND_BY_COMPANY_AND_ID); + if (!brand.recordset[0]) { + throw new Error('Brand not found'); + } + resolvedBrandId = brandId; + } + + const result = await pool + .request() + .input('articleId', articleId) + .input('brandId', resolvedBrandId) + .query(UPDATE_BRAND); + + if (!result.rowsAffected[0]) { + throw new Error('Article not found'); + } + + const article = await load(articleId, languageId); + if (!article) { + throw new Error('Article not found'); + } + + return article; + }; +} diff --git a/src/store/article/setCategoryIdsBySku.js b/src/store/article/setCategoryIdsBySku.js new file mode 100644 index 0000000..683b76d --- /dev/null +++ b/src/store/article/setCategoryIdsBySku.js @@ -0,0 +1,74 @@ +import { + ADD_CATEGORY, + DELETE_CATEGORIES_BY_ARTICLE, +} from '../../sql/article.js'; +import request from '../request.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import queryBySku from './queryBySku.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; + +export default function setCategoryIdsBySku(pool) { + const resolve = resolveArticleBySku(pool); + const categoryAccess = resolveCategoryAccess(pool); + const load = queryBySku(pool); + + return async (googleAuthId, sku, categoryIds) => { + if (!Array.isArray(categoryIds)) { + throw new Error('Invalid categoryIds'); + } + const seen = new Set(); + const normalized = []; + for (const categoryId of categoryIds) { + if (!Number.isFinite(categoryId) || seen.has(categoryId)) { + throw new Error('Invalid categoryIds'); + } + seen.add(categoryId); + normalized.push(categoryId); + } + + const { articleId, languageId, companyId } = await resolve( + googleAuthId, + sku, + ); + + const tx = pool.transaction(); + await tx.begin(); + + try { + for (const categoryId of normalized) { + // Ensures the category belongs to the same company as the article. + const access = await categoryAccess(googleAuthId, categoryId); + if (access.companyId !== companyId) { + throw new Error('Invalid categoryIds'); + } + } + + await request(pool, tx) + .input('articleId', articleId) + .query(DELETE_CATEGORIES_BY_ARTICLE); + + for (const categoryId of normalized) { + await request(pool, tx) + .input('categoryId', categoryId) + .input('articleId', articleId) + .query(ADD_CATEGORY); + } + + await tx.commit(); + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + + const article = await load(articleId, languageId); + if (!article) { + throw new Error('Article not found'); + } + + return article; + }; +} diff --git a/src/store/article/setEansBySku.js b/src/store/article/setEansBySku.js new file mode 100644 index 0000000..f10cf7e --- /dev/null +++ b/src/store/article/setEansBySku.js @@ -0,0 +1,65 @@ +import { + ADD_EAN, + DELETE_EANS_BY_ARTICLE, +} from '../../sql/article.js'; +import request from '../request.js'; +import queryBySku from './queryBySku.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; + +export default function setEansBySku(pool) { + const resolve = resolveArticleBySku(pool); + const load = queryBySku(pool); + + return async (googleAuthId, sku, eans) => { + if (!Array.isArray(eans)) { + throw new Error('Invalid eans'); + } + const normalized = []; + const seen = new Set(); + for (const ean of eans) { + if (typeof ean !== 'string' || !ean.trim()) { + throw new Error('Invalid eans'); + } + const clean = ean.trim(); + if (seen.has(clean)) { + throw new Error('Invalid eans'); + } + seen.add(clean); + normalized.push(clean); + } + + const { articleId, languageId } = await resolve(googleAuthId, sku); + + const tx = pool.transaction(); + await tx.begin(); + + try { + await request(pool, tx) + .input('articleId', articleId) + .query(DELETE_EANS_BY_ARTICLE); + + for (const ean of normalized) { + await request(pool, tx) + .input('articleId', articleId) + .input('ean', ean) + .query(ADD_EAN); + } + + await tx.commit(); + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + + const article = await load(articleId, languageId); + if (!article) { + throw new Error('Article not found'); + } + + return article; + }; +} diff --git a/src/store/article/setWeightBySku.js b/src/store/article/setWeightBySku.js new file mode 100644 index 0000000..7810b58 --- /dev/null +++ b/src/store/article/setWeightBySku.js @@ -0,0 +1,33 @@ +import { UPDATE_WEIGHT } from '../../sql/article.js'; +import queryBySku from './queryBySku.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; + +export default function setWeightBySku(pool) { + const resolve = resolveArticleBySku(pool); + const load = queryBySku(pool); + + return async (googleAuthId, sku, weight) => { + if (!Number.isFinite(weight) || weight < 0) { + throw new Error('Invalid weight'); + } + + const { articleId, languageId } = await resolve(googleAuthId, sku); + + const result = await pool + .request() + .input('articleId', articleId) + .input('weight', weight) + .query(UPDATE_WEIGHT); + + if (!result.rowsAffected[0]) { + throw new Error('Article not found'); + } + + const article = await load(articleId, languageId); + if (!article) { + throw new Error('Article not found'); + } + + return article; + }; +} diff --git a/src/store/category/rename.js b/src/store/category/rename.js new file mode 100644 index 0000000..e161bd8 --- /dev/null +++ b/src/store/category/rename.js @@ -0,0 +1,41 @@ +import { UPDATE_BY_CATEGORY_AND_LANGUAGE } from '../../sql/categorytext.js'; +import resolveCategoryAccess from './resolveCategoryAccess.js'; + +function slugify(name) { + return name + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/\p{M}/gu, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export default function rename(pool) { + const access = resolveCategoryAccess(pool); + + return async ({ googleAuthId, id, name }) => { + if (!name?.trim()) { + throw new Error('Name required'); + } + const cleaned = name.trim(); + + const { languageId } = await access(googleAuthId, id); + + const slug = slugify(cleaned); + + const result = await pool + .request() + .input('categoryId', id) + .input('languageId', languageId) + .input('name', cleaned) + .input('slug', slug) + .query(UPDATE_BY_CATEGORY_AND_LANGUAGE); + + if (!result.rowsAffected[0]) { + throw new Error('Category not found'); + } + + return { id, name: cleaned }; + }; +}