diff --git a/API.article.md b/API.article.md index 3ff2f3e..d54e12c 100644 --- a/API.article.md +++ b/API.article.md @@ -128,7 +128,7 @@ Returns the full detail of an article by its SKU, including unit, brand, categor **Returns** -`Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>` — The full article detail. +`Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>` — The full article detail. **Throws** diff --git a/API.brand.md b/API.brand.md index 9fdb566..019125f 100644 --- a/API.brand.md +++ b/API.brand.md @@ -45,13 +45,13 @@ Lists the brands of the user's selected company. **Returns** -`Promise>` — The brands. +`Promise}>>` — The brands. **Example** ```js const brands = await store.brand.list(42); -// => [{ id, name, gsprpinfo }] +// => [{ id, name, gsprpinfo, pics: [{ sort, fullHash, thumbW, thumbH, thumbHash }] }] ``` ## `brand.upsert` @@ -72,7 +72,7 @@ Creates or updates a brand for the user's selected company, keyed by name. **Returns** -`Promise<{id: number, name: string, gsprpinfo: string|null}>` — The upserted brand. +`Promise<{id: number, name: string, gsprpinfo: string|null, pics: Array<{id: number, sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>` — The upserted brand. **Throws** @@ -82,5 +82,5 @@ Creates or updates a brand for the user's selected company, keyed by name. ```js const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' }); -// => { id, name, gsprpinfo } +// => { id, name, gsprpinfo, pics: [] } ``` diff --git a/API.category.md b/API.category.md index 54e7bbb..2182bb1 100644 --- a/API.category.md +++ b/API.category.md @@ -156,13 +156,13 @@ Returns the articles belonging to a single category. **Returns** -`Promise>` — The articles in the category. +`Promise>` — The articles in the category. **Example** ```js const articles = await store.category.getCategoryArticles(42, 5); -// => [{ id, sku, name, ... }] +// => [{ id, sku, name, brand, ... }] ``` ## `category.getTreeForCompany` diff --git a/API.companysetting.md b/API.companysetting.md index 2781196..371ec29 100644 --- a/API.companysetting.md +++ b/API.companysetting.md @@ -34,7 +34,7 @@ const setting = await store.companysetting.get(42, 'ui.theme'); *Source: `src/store/companysetting/list.js`* -Lists the settings of the user's selected company, optionally only frontend-visible ones. Write-only settings (such as `keys.tavily`, `keys.openrouter`) have their value masked with `'***'` by default for usergui presentation. +Lists the settings of the user's selected company, optionally only frontend-visible ones. Write-only settings (such as `keys.tavily`, `keys.openrouter`, `keys.serper`) have their value masked with `'***'` by default for usergui presentation. **Parameters** @@ -72,7 +72,7 @@ Sets (upserts) a company setting by key for the user's selected company. | `input.key` | `string` | The setting key (must be in the allow-list). | | `input.value` | `string` | The setting value. | | `[input.frontend]` | `boolean` | Whether the setting is frontend-visible. | -| `[input.writeonly]` | `boolean` | Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise. | +| `[input.writeonly]` | `boolean` | Whether the setting is write-only. Defaults to true for `keys.tavily`, `keys.openrouter`, and `keys.serper`, false otherwise. | | `[tx]` | `object` | Optional transaction to run within. | **Returns** diff --git a/API.md b/API.md index 601db82..1cd7674 100644 --- a/API.md +++ b/API.md @@ -70,7 +70,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `delete(input: {googleAuthId: number, id: number}, 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>` + - `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise>` - `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array, articles: Array}>` - `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>` - `order(input: {googleAuthId: number, parentId: number, categoryIds: Array}, tx?: object): Promise<{categoryIds: Array}>` @@ -80,7 +80,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}, tx?: object): Promise<{id: number, parentId: number, name: string, sku: string}>` - `delete(googleAuthId: number, sku: string, tx?: object): Promise` - `deleteImage(googleAuthId: number, sku: string, fullHash: string, tx?: object): Promise` - - `getBySku(googleAuthId: number, sku: string, tx?: object): Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>` + - `getBySku(googleAuthId: number, sku: string, tx?: object): Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>` - `getHistoryBySku(googleAuthId: number, sku: string, tx?: object): Promise>` - `importRows(input: {googleAuthId: number, rows: Array}, tx?: object): Promise<{imported: Array, skipped: Array, errors: Array}>` - `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise>` @@ -114,8 +114,8 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `set(googleAuthId: number, input: {key: string, value: string, frontend?: boolean, writeonly?: boolean}, tx?: object): Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>` - [brand](API.brand.md) - `delete(googleAuthId: number, id: number, tx?: object): Promise` - - `list(googleAuthId: number, tx?: object): Promise>` - - `upsert(googleAuthId: number, input: {name: string, gsprpinfo?: string}, tx?: object): Promise<{id: number, name: string, gsprpinfo: string|null}>` + - `list(googleAuthId: number, tx?: object): Promise}>>` + - `upsert(googleAuthId: number, input: {name: string, gsprpinfo?: string}, tx?: object): Promise<{id: number, name: string, gsprpinfo: string|null, pics: Array<{id: number, sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>` - [customergroup](API.customergroup.md) - `create(googleAuthId: number, input: {name: string}, tx?: object): Promise<{id: number, name: string, sort: number}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise` diff --git a/README.md b/README.md index cd8fd8d..78eb39a 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,8 @@ Company-scoped key/value settings. Auth via selected company (`google_auth.selec - `frontend: true` → readable by frontend routes (`frontendOnly: true`). Default on `set` is `false` (backend-only). - With `frontendOnly: true`, backend-only keys are omitted / `get` returns `undefined` (no leak via error type beyond missing). - `writeonly: true` → usergui gets a `"***"` scrambled version when retrieving the key (in `list`, or in `get` when `maskWriteonly` is enabled / `frontendOnly: true`). The backend receives the full value. -- `keys.tavily` and `keys.openrouter` default to `writeonly: true` on `set`. -- `set` only allows a fixed allow-list of keys: `ui.theme`, `keys.tavily`, `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`. +- `keys.tavily`, `keys.openrouter`, and `keys.serper` default to `writeonly: true` on `set`. +- `set` only allows a fixed allow-list of keys: `ui.theme`, `keys.tavily`, `keys.openrouter`, `keys.serper`, `labelprinter.addr`, `labelprinter.type`. - No delete endpoint — settings are upsert / read only. Errors: `No company selected`, `Company not found`, `Not authorized`, `Invalid setting`. diff --git a/src/index.js b/src/index.js index 8656d06..005c0e5 100644 --- a/src/index.js +++ b/src/index.js @@ -41,6 +41,8 @@ import companysettingSet from './store/companysetting/set.js'; import brandList from './store/brand/list.js'; import brandUpsert from './store/brand/upsert.js'; import brandDelete from './store/brand/delete.js'; +import brandAddImage from './store/brand/addImage.js'; +import brandDeleteImage from './store/brand/deleteImage.js'; import customergroupList from './store/customergroup/list.js'; import customergroupCreate from './store/customergroup/create.js'; import customergroupUpdate from './store/customergroup/update.js'; @@ -232,6 +234,8 @@ export default class LibDashStore { list: brandList(provider), upsert: brandUpsert(provider), delete: brandDelete(provider), + addImage: brandAddImage(provider), + deleteImage: brandDeleteImage(provider), }; this.customergroup = { diff --git a/src/providers/mssql/queries/article.js b/src/providers/mssql/queries/article.js index 3f9c3ac..c612c37 100644 --- a/src/providers/mssql/queries/article.js +++ b/src/providers/mssql/queries/article.js @@ -13,6 +13,14 @@ OUTER APPLY ( ORDER BY ap.sort ASC, ap.id ASC ) first_pic LEFT JOIN pic ON pic.id = first_pic.pic_id +LEFT JOIN brand b ON b.id = a.brand_id +OUTER APPLY ( + SELECT TOP 1 bp.pic_id + FROM brandpic bp + WHERE bp.brand_id = b.id + ORDER BY bp.sort ASC, bp.id ASC +) first_brand_pic +LEFT JOIN pic brand_pic ON brand_pic.id = first_brand_pic.pic_id LEFT JOIN article father ON father.id = a.fatherarticle_id `; @@ -49,7 +57,13 @@ export const ARTICLE_LIST_COLUMNS = ` pic.hash AS fullHash, pic.thumbw AS thumbW, pic.thumbh AS thumbH, - pic.thumbhash AS thumbHash + pic.thumbhash AS thumbHash, + b.id AS brandId, + b.name AS brandName, + brand_pic.hash AS brandFullHash, + brand_pic.thumbw AS brandThumbW, + brand_pic.thumbh AS brandThumbH, + brand_pic.thumbhash AS brandThumbHash `; export const GET_CATEGORY_ID_BY_SKU = ` @@ -362,7 +376,9 @@ SELECT u.basesifactor AS unitBasesifactor, ut.symbol AS unitSymbol, ut.name AS unitName, + b.id AS brandId, b.name AS brandName, + bp.hash AS brandFullHash, bp.thumbw AS brandThumbW, bp.thumbh AS brandThumbH, bp.thumbhash AS brandThumbHash, diff --git a/src/providers/mssql/queries/articleDetail.js b/src/providers/mssql/queries/articleDetail.js index 73a7c49..f995020 100644 --- a/src/providers/mssql/queries/articleDetail.js +++ b/src/providers/mssql/queries/articleDetail.js @@ -10,7 +10,9 @@ export const ARTICLE_DETAIL_COLUMNS = ` u.basesifactor AS unitBasesifactor, ut.symbol AS unitSymbol, ut.name AS unitName, + b.id AS brandId, b.name AS brandName, + brand_pic.hash AS brandFullHash, brand_pic.thumbw AS brandThumbW, brand_pic.thumbh AS brandThumbH, brand_pic.thumbhash AS brandThumbHash, diff --git a/src/providers/mssql/queries/brand.js b/src/providers/mssql/queries/brand.js index bbe4e8c..2919f06 100644 --- a/src/providers/mssql/queries/brand.js +++ b/src/providers/mssql/queries/brand.js @@ -1,21 +1,49 @@ export const LIST_BY_COMPANY = ` SELECT - id, - name, - gsprpinfo -FROM brand -WHERE company_id = @companyId -ORDER BY name + b.id, + b.name, + b.gsprpinfo, + ( + SELECT + bp.id, + bp.sort, + p.hash AS fullHash, + p.thumbw AS thumbW, + p.thumbh AS thumbH, + p.thumbhash AS thumbHash + FROM brandpic bp + INNER JOIN pic p ON p.id = bp.pic_id + WHERE bp.brand_id = b.id + ORDER BY bp.sort ASC, bp.id ASC + FOR JSON PATH + ) AS picsJson +FROM brand b +WHERE b.company_id = @companyId +ORDER BY b.name `; export const GET_BY_COMPANY_AND_NAME = ` SELECT - id, - name, - gsprpinfo -FROM brand -WHERE company_id = @companyId - AND name = @name + b.id, + b.name, + b.gsprpinfo, + ( + SELECT + bp.id, + bp.sort, + p.hash AS fullHash, + p.thumbw AS thumbW, + p.thumbh AS thumbH, + p.thumbhash AS thumbHash + FROM brandpic bp + INNER JOIN pic p ON p.id = bp.pic_id + WHERE bp.brand_id = b.id + ORDER BY bp.sort ASC, bp.id ASC + FOR JSON PATH + ) AS picsJson +FROM brand b +WHERE b.company_id = @companyId + AND b.name = @name `; export const CREATE = ` @@ -34,3 +62,13 @@ export const DELETE_BY_COMPANY_AND_ID = ` DELETE FROM brand WHERE id = @id AND company_id = @companyId `; + +export const GET_BY_ID_AND_COMPANY = ` +SELECT + b.id, + b.company_id, + b.name, + b.gsprpinfo +FROM brand b +WHERE b.id = @id AND b.company_id = @companyId +`; diff --git a/src/providers/mssql/queries/brandpic.js b/src/providers/mssql/queries/brandpic.js new file mode 100644 index 0000000..94ed41a --- /dev/null +++ b/src/providers/mssql/queries/brandpic.js @@ -0,0 +1,23 @@ +export const GET_NEXT_SORT = ` +SELECT ISNULL(MAX(sort), -1) + 1 AS sort +FROM brandpic +WHERE brand_id = @brandId +`; + +export const ADD = ` +INSERT INTO brandpic (brand_id, pic_id, sort) +VALUES (@brandId, @picId, @sort) +`; + +export const DELETE_BY_BRAND = ` +DELETE FROM brandpic +WHERE brand_id = @brandId +`; + +export const GET_FIRST_PIC_BY_BRAND = ` +SELECT TOP 1 p.id AS picId, p.thumbw AS thumbW, p.thumbh AS thumbH, p.thumbhash AS thumbHash +FROM brandpic bp +INNER JOIN pic p ON p.id = bp.pic_id +WHERE bp.brand_id = @brandId +ORDER BY bp.sort ASC, bp.id ASC +`; diff --git a/src/providers/mssql/queries/pic.js b/src/providers/mssql/queries/pic.js index 54b6716..040f1a2 100644 --- a/src/providers/mssql/queries/pic.js +++ b/src/providers/mssql/queries/pic.js @@ -23,6 +23,15 @@ FROM ( FROM pic p INNER JOIN categorypic cp ON cp.pic_id = p.id WHERE p.hash = @hash + + UNION + + SELECT c.category_id AS categoryId + FROM pic p + INNER JOIN brandpic bp ON bp.pic_id = p.id + INNER JOIN brand b ON b.id = bp.brand_id + INNER JOIN company c ON c.id = b.company_id + WHERE p.hash = @hash ) AS combined `; diff --git a/src/providers/sqlite3/queries/article.js b/src/providers/sqlite3/queries/article.js index 61bc398..eca1ff7 100644 --- a/src/providers/sqlite3/queries/article.js +++ b/src/providers/sqlite3/queries/article.js @@ -24,6 +24,11 @@ LEFT JOIN pic ON pic.id = ( SELECT ap.pic_id FROM articlepic ap WHERE ap.article_id = a.id ORDER BY ap.sort, ap.id LIMIT 1 ) +LEFT JOIN brand b ON b.id = a.brand_id +LEFT JOIN pic brand_pic ON brand_pic.id = ( + SELECT bp.pic_id FROM brandpic bp + WHERE bp.brand_id = b.id ORDER BY bp.sort, bp.id LIMIT 1 +) LEFT JOIN article father ON father.id = a.fatherarticle_id `; @@ -119,7 +124,9 @@ SELECT auth.*, 'unitamount', a.unitamount, 'weight', a.weight, 'unitSi', u.si, 'unitBasesi', u.basesi, 'unitBasesifactor', u.basesifactor, 'unitSymbol', ut.symbol, - 'unitName', ut.name, 'brandName', b.name, + 'unitName', ut.name, + 'brandId', b.id, 'brandName', b.name, + 'brandFullHash', bp.hash, 'brandThumbW', bp.thumbw, 'brandThumbH', bp.thumbh, 'brandThumbHash', bp.thumbhash, 'optionsCount', (SELECT COUNT(*) FROM optionslistarticle WHERE optionslist_id = a.optionslist_id), diff --git a/src/providers/sqlite3/queries/brand.js b/src/providers/sqlite3/queries/brand.js index 73a2d9d..4c45403 100644 --- a/src/providers/sqlite3/queries/brand.js +++ b/src/providers/sqlite3/queries/brand.js @@ -3,6 +3,59 @@ import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'brand'); export const { - CREATE, DELETE_BY_COMPANY_AND_ID, GET_BY_COMPANY_AND_NAME, - LIST_BY_COMPANY, UPDATE_BY_ID, -} = queries; \ No newline at end of file + CREATE, DELETE_BY_COMPANY_AND_ID, UPDATE_BY_ID, GET_BY_ID_AND_COMPANY, +} = queries; + +export const LIST_BY_COMPANY = ` +SELECT + b.id, + b.name, + b.gsprpinfo, + ( + SELECT COALESCE(json_group_array(json_object( + 'id', id, + 'sort', sort, + 'fullHash', hash, + 'thumbW', thumbw, + 'thumbH', thumbh, + 'thumbHash', thumbhash + )), '[]') + FROM ( + SELECT bp.id, bp.sort, p.hash, p.thumbw, p.thumbh, p.thumbhash + FROM brandpic bp + INNER JOIN pic p ON p.id = bp.pic_id + WHERE bp.brand_id = b.id + ORDER BY bp.sort, bp.id + ) + ) AS picsJson +FROM brand b +WHERE b.company_id = @companyId +ORDER BY b.name +`; + +export const GET_BY_COMPANY_AND_NAME = ` +SELECT + b.id, + b.name, + b.gsprpinfo, + ( + SELECT COALESCE(json_group_array(json_object( + 'id', id, + 'sort', sort, + 'fullHash', hash, + 'thumbW', thumbw, + 'thumbH', thumbh, + 'thumbHash', thumbhash + )), '[]') + FROM ( + SELECT bp.id, bp.sort, p.hash, p.thumbw, p.thumbh, p.thumbhash + FROM brandpic bp + INNER JOIN pic p ON p.id = bp.pic_id + WHERE bp.brand_id = b.id + ORDER BY bp.sort, bp.id + ) + ) AS picsJson +FROM brand b +WHERE b.company_id = @companyId + AND b.name = @name +`; \ No newline at end of file diff --git a/src/providers/sqlite3/queries/brandpic.js b/src/providers/sqlite3/queries/brandpic.js new file mode 100644 index 0000000..c428e52 --- /dev/null +++ b/src/providers/sqlite3/queries/brandpic.js @@ -0,0 +1,5 @@ +import * as source from '../../mssql/queries/brandpic.js'; +import { translateQueries } from './translate.js'; + +const queries = translateQueries(source, 'brandpic'); +export const { ADD, DELETE_BY_BRAND, GET_FIRST_PIC_BY_BRAND, GET_NEXT_SORT } = queries; diff --git a/src/store/article/getBySku.js b/src/store/article/getBySku.js index e2ea795..52d3316 100644 --- a/src/store/article/getBySku.js +++ b/src/store/article/getBySku.js @@ -18,7 +18,7 @@ export default function getBySku(provider) { * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. * @param {object} [tx] - Optional transaction. - * @returns {Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>} The full article detail. + * @returns {Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>} The full article detail. * @throws {Error} If the article is not found or the user is not authorized. * * @example @@ -65,10 +65,12 @@ export default function getBySku(provider) { }, brand: a.brandName ? { + id: a.brandId ?? null, name: a.brandName, - thumbW: a.brandThumbW, - thumbH: a.brandThumbH, - thumbHash: a.brandThumbHash, + fullHash: a.brandFullHash ?? null, + thumbW: a.brandThumbW ?? null, + thumbH: a.brandThumbH ?? null, + thumbHash: a.brandThumbHash ?? null, } : null, categoryIds: parseJsonArray(a.categoryIdsJson).map((r) => r.category_id), diff --git a/src/store/article/queryBySku.js b/src/store/article/queryBySku.js index 35d90c7..beb31ce 100644 --- a/src/store/article/queryBySku.js +++ b/src/store/article/queryBySku.js @@ -19,10 +19,12 @@ function shapeArticleDetail(row, categoryIds, photos, eans, prices) { }, brand: row.brandName ? { + id: row.brandId ?? null, name: row.brandName, - thumbW: row.brandThumbW, - thumbH: row.brandThumbH, - thumbHash: row.brandThumbHash, + fullHash: row.brandFullHash ?? null, + thumbW: row.brandThumbW ?? null, + thumbH: row.brandThumbH ?? null, + thumbHash: row.brandThumbHash ?? null, } : null, categoryIds: categoryIds.map((r) => r.categoryId), diff --git a/src/store/brand/addImage.js b/src/store/brand/addImage.js new file mode 100644 index 0000000..5c01e01 --- /dev/null +++ b/src/store/brand/addImage.js @@ -0,0 +1,104 @@ +import { + ADD as MSSQL_ADD_BRANDPIC, + DELETE_BY_BRAND as MSSQL_DELETE_BRANDPIC, +} from '../../providers/mssql/queries/brandpic.js'; +import { + ADD as SQLITE_ADD_BRANDPIC, + DELETE_BY_BRAND as SQLITE_DELETE_BRANDPIC, +} from '../../providers/sqlite3/queries/brandpic.js'; +import { CREATE as MSSQL_CREATE_PIC } from '../../providers/mssql/queries/pic.js'; +import { CREATE as SQLITE_CREATE_PIC } from '../../providers/sqlite3/queries/pic.js'; +import processAvifImage from '../../util/processAvifImage.js'; +import newFullHash from '../../util/newFullHash.js'; +import resolveBrandAccess from './resolveBrandAccess.js'; +import makeRunner from '../runner.js'; + +export default function addImage(provider) { + const access = resolveBrandAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const deleteBrandPicSql = isSqlite ? SQLITE_DELETE_BRANDPIC : MSSQL_DELETE_BRANDPIC; + const createPicSql = isSqlite ? SQLITE_CREATE_PIC : MSSQL_CREATE_PIC; + const addBrandPicSql = isSqlite ? SQLITE_ADD_BRANDPIC : MSSQL_ADD_BRANDPIC; + + /** + * Adds an image to a brand, processing it into AVIF full and thumb variants. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} brandId - The id of the brand. + * @param {Buffer} image - The source image buffer. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, pics: Array}>} + */ + return async (googleAuthId, brandId, image, tx) => { + const runAdd = async (runner) => { + await access(googleAuthId, brandId, runner); + + const processed = await processAvifImage(image); + + // Clean existing brandpic so the brand has 1 primary logo + await runner.query(deleteBrandPicSql, { brandId }); + + const sort = 0; + const fullHash = newFullHash(); + + if (isSqlite) { + if (runner.writePicFull) { + await runner.writePicFull(fullHash, processed.full.buffer); + await runner.writePicThumb(fullHash, processed.thumb.buffer); + } else if (provider?.writePicFull) { + await provider.writePicFull(fullHash, processed.full.buffer); + await provider.writePicThumb(fullHash, processed.thumb.buffer); + } + } + + const picResult = await runner.query(createPicSql, { + w: processed.full.w, + h: processed.full.h, + size: processed.full.size, + hash: fullHash, + thumbw: processed.thumb.w, + thumbh: processed.thumb.h, + thumbhash: processed.thumb.hash, + source: '', + fullbinary: processed.full.buffer, + thumbbinary: processed.thumb.buffer, + }); + + const picId = picResult.rows[0]?.id ?? picResult.insertId; + + const brandPicResult = await runner.query(addBrandPicSql, { + brandId, + picId, + sort, + }); + const brandPicId = brandPicResult.rows[0]?.id ?? brandPicResult.insertId; + + const picObj = { + id: brandPicId, + sort, + fullHash, + thumbW: processed.thumb.w, + thumbH: processed.thumb.h, + thumbHash: processed.thumb.hash, + }; + + return { + id: brandId, + fullHash, + thumbW: processed.thumb.w, + thumbH: processed.thumb.h, + thumbHash: processed.thumb.hash, + pics: [picObj], + }; + }; + + if (tx) { + return runAdd(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runAdd); + } + return runAdd(makeRunner(provider)); + }; +} diff --git a/src/store/brand/deleteImage.js b/src/store/brand/deleteImage.js new file mode 100644 index 0000000..f09cdfe --- /dev/null +++ b/src/store/brand/deleteImage.js @@ -0,0 +1,28 @@ +import { DELETE_BY_BRAND as MSSQL_DELETE } from '../../providers/mssql/queries/brandpic.js'; +import { DELETE_BY_BRAND as SQLITE_DELETE } from '../../providers/sqlite3/queries/brandpic.js'; +import resolveBrandAccess from './resolveBrandAccess.js'; +import makeRunner from '../runner.js'; + +export default function deleteImage(provider) { + const access = resolveBrandAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; + + return async (googleAuthId, brandId, tx) => { + const runner = makeRunner(provider, tx); + await access(googleAuthId, brandId, runner); + + const result = await runner.query(sql, { brandId }); + + if (!result.rowsAffected) { + throw new Error('Image not found'); + } + + return { + id: brandId, + thumbW: null, + thumbH: null, + thumbHash: null, + pics: [], + }; + }; +} diff --git a/src/store/brand/list.js b/src/store/brand/list.js index 1f0f244..23c8aa1 100644 --- a/src/store/brand/list.js +++ b/src/store/brand/list.js @@ -3,6 +3,12 @@ import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlit import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; +function parsePics(raw) { + if (!raw) return []; + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(parsed) ? parsed : []; +} + export default function list(provider) { const access = resolveSelectedCompanyAccess(provider); const sql = @@ -15,17 +21,22 @@ export default function list(provider) { * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise>} The brands. + * @returns {Promise}>>} The brands. * * @example * const brands = await store.brand.list(42); - * // => [{ id, name, gsprpinfo }] + * // => [{ id, name, gsprpinfo, pics: [{ sort, fullHash, thumbW, thumbH, thumbHash }] }] */ return async (googleAuthId, tx) => { const { companyId } = await access(googleAuthId, tx); const result = await makeRunner(provider, tx).query(sql, { companyId }); - return result.rows; + return result.rows.map((row) => ({ + id: row.id, + name: row.name, + gsprpinfo: row.gsprpinfo, + pics: parsePics(row.picsJson), + })); }; } diff --git a/src/store/brand/resolveBrandAccess.js b/src/store/brand/resolveBrandAccess.js new file mode 100644 index 0000000..9e2a4b2 --- /dev/null +++ b/src/store/brand/resolveBrandAccess.js @@ -0,0 +1,22 @@ +import { GET_BY_ID_AND_COMPANY as MSSQL_GET } from '../../providers/mssql/queries/brand.js'; +import { GET_BY_ID_AND_COMPANY as SQLITE_GET } from '../../providers/sqlite3/queries/brand.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +export default function resolveBrandAccess(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET; + + return async (googleAuthId, brandId, tx) => { + if (!Number.isFinite(brandId) || brandId <= 0) { + throw new Error('Brand not found'); + } + const { companyId } = await access(googleAuthId, tx); + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { id: brandId, companyId }); + if (!result.rows[0]) { + throw new Error('Brand not found'); + } + return { companyId, brand: result.rows[0] }; + }; +} diff --git a/src/store/brand/upsert.js b/src/store/brand/upsert.js index 5893271..3d54415 100644 --- a/src/store/brand/upsert.js +++ b/src/store/brand/upsert.js @@ -11,6 +11,12 @@ import { import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; +function parsePics(raw) { + if (!raw) return []; + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(parsed) ? parsed : []; +} + export default function upsert(provider) { const access = resolveSelectedCompanyAccess(provider); const isSqlite = provider?.dialect === 'sqlite3'; @@ -28,12 +34,12 @@ export default function upsert(provider) { * @param {string} input.name - The brand name. * @param {string} [input.gsprpinfo] - Optional GSPRP info. * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise<{id: number, name: string, gsprpinfo: string|null}>} The upserted brand. + * @returns {Promise<{id: number, name: string, gsprpinfo: string|null, pics: Array<{id: number, sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>} The upserted brand. * @throws {Error} If the brand is invalid. * * @example * const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' }); - * // => { id, name, gsprpinfo } + * // => { id, name, gsprpinfo, pics: [] } */ return async (googleAuthId, input = {}, tx) => { const name = input.name?.trim(); @@ -58,7 +64,12 @@ export default function upsert(provider) { name, gsprpinfo, }); - return { id, name, gsprpinfo }; + return { + id, + name, + gsprpinfo, + pics: parsePics(existing.rows[0].picsJson), + }; } const created = await runner.query(createSql, { @@ -73,6 +84,7 @@ export default function upsert(provider) { id, name, gsprpinfo, + pics: [], }; }; } diff --git a/src/store/category/getCategoryArticles.js b/src/store/category/getCategoryArticles.js index 9a50283..cdeb0f1 100644 --- a/src/store/category/getCategoryArticles.js +++ b/src/store/category/getCategoryArticles.js @@ -11,11 +11,11 @@ export default function getCategoryArticles(provider) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} categoryId - The id of the category. * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise>} The articles in the category. + * @returns {Promise>} The articles in the category. * * @example * const articles = await store.category.getCategoryArticles(42, 5); - * // => [{ id, sku, name, ... }] + * // => [{ id, sku, name, brand, ... }] */ return async (googleAuthId, categoryId, tx) => { const { languageId, posCustomerGroupId } = await access( diff --git a/src/store/category/queryCategoryArticles.js b/src/store/category/queryCategoryArticles.js index 12fa91f..7e76256 100644 --- a/src/store/category/queryCategoryArticles.js +++ b/src/store/category/queryCategoryArticles.js @@ -12,6 +12,29 @@ export default function queryCategoryArticles(provider) { posCustomerGroupId, }); - return result.rows; + return result.rows.map((row) => { + const { + brandId, + brandName, + brandFullHash, + brandThumbW, + brandThumbH, + brandThumbHash, + ...rest + } = row; + return { + ...rest, + brand: brandName + ? { + id: brandId ?? null, + name: brandName, + fullHash: brandFullHash ?? null, + thumbW: brandThumbW ?? null, + thumbH: brandThumbH ?? null, + thumbHash: brandThumbHash ?? null, + } + : null, + }; + }); }; } diff --git a/src/store/companysetting/set.js b/src/store/companysetting/set.js index 072e71c..34c5ba5 100644 --- a/src/store/companysetting/set.js +++ b/src/store/companysetting/set.js @@ -14,6 +14,7 @@ const ALLOWED_KEYS = new Set([ 'ui.theme', 'keys.tavily', 'keys.openrouter', + 'keys.serper', 'labelprinter.addr', 'labelprinter.type', ]); @@ -21,6 +22,7 @@ const ALLOWED_KEYS = new Set([ const WRITEONLY_KEYS = new Set([ 'keys.tavily', 'keys.openrouter', + 'keys.serper', ]); export default function set(provider) { @@ -39,7 +41,7 @@ export default function set(provider) { * @param {string} input.key - The setting key (must be in the allow-list). * @param {string} input.value - The setting value. * @param {boolean} [input.frontend] - Whether the setting is frontend-visible. - * @param {boolean} [input.writeonly] - Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise. + * @param {boolean} [input.writeonly] - Whether the setting is write-only. Defaults to true for `keys.tavily`, `keys.openrouter`, and `keys.serper`, false otherwise. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>} The upserted setting. * @throws {Error} If the input is invalid.