This commit is contained in:
seb
2026-09-27 16:19:45 +02:00
parent e7f92ebef8
commit cd57f21af8
25 changed files with 412 additions and 49 deletions

View File

@@ -128,7 +128,7 @@ Returns the full detail of an article by its SKU, including unit, brand, categor
**Returns** **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** **Throws**

View File

@@ -45,13 +45,13 @@ Lists the brands of the user's selected company.
**Returns** **Returns**
`Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>` — The brands. `Promise<Array<{id: number, name: string, gsprpinfo: string|null, pics: Array<{id: number, sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>>` — The brands.
**Example** **Example**
```js ```js
const brands = await store.brand.list(42); const brands = await store.brand.list(42);
// => [{ id, name, gsprpinfo }] // => [{ id, name, gsprpinfo, pics: [{ sort, fullHash, thumbW, thumbH, thumbHash }] }]
``` ```
## `brand.upsert` ## `brand.upsert`
@@ -72,7 +72,7 @@ Creates or updates a brand for the user's selected company, keyed by name.
**Returns** **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** **Throws**
@@ -82,5 +82,5 @@ Creates or updates a brand for the user's selected company, keyed by name.
```js ```js
const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' }); const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
// => { id, name, gsprpinfo } // => { id, name, gsprpinfo, pics: [] }
``` ```

View File

@@ -156,13 +156,13 @@ Returns the articles belonging to a single category.
**Returns** **Returns**
`Promise<Array<object>>` — The articles in the category. `Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>` — The articles in the category.
**Example** **Example**
```js ```js
const articles = await store.category.getCategoryArticles(42, 5); const articles = await store.category.getCategoryArticles(42, 5);
// => [{ id, sku, name, ... }] // => [{ id, sku, name, brand, ... }]
``` ```
## `category.getTreeForCompany` ## `category.getTreeForCompany`

View File

@@ -34,7 +34,7 @@ const setting = await store.companysetting.get(42, 'ui.theme');
*Source: `src/store/companysetting/list.js`* *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** **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.key` | `string` | The setting key (must be in the allow-list). |
| `input.value` | `string` | The setting value. | | `input.value` | `string` | The setting value. |
| `[input.frontend]` | `boolean` | Whether the setting is frontend-visible. | | `[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. | | `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**

8
API.md
View File

@@ -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<void>` - `delete(input: {googleAuthId: number, id: number}, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>` - `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<number>` - `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<object>>` - `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>`
- `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>` - `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>`
- `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>` - `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<number>}, tx?: object): Promise<{categoryIds: Array<number>}>` - `order(input: {googleAuthId: number, parentId: number, categoryIds: Array<number>}, tx?: object): Promise<{categoryIds: Array<number>}>`
@@ -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}>` - `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<void>` - `delete(googleAuthId: number, sku: string, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, sku: string, fullHash: string, tx?: object): Promise<void>` - `deleteImage(googleAuthId: number, sku: string, fullHash: string, tx?: object): Promise<void>`
- `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<Array<{id: number, companyuserId: number|null, date: string, text: string}>>` - `getHistoryBySku(googleAuthId: number, sku: string, tx?: object): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>`
- `importRows(input: {googleAuthId: number, rows: Array<object>}, tx?: object): Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>` - `importRows(input: {googleAuthId: number, rows: Array<object>}, tx?: object): Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>`
- `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise<Array<object>>` - `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise<Array<object>>`
@@ -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}>` - `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) - [brand](API.brand.md)
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>` - `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, gsprpinfo: string|null, pics: Array<{id: number, sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>>`
- `upsert(googleAuthId: number, input: {name: string, gsprpinfo?: string}, tx?: object): Promise<{id: number, name: string, gsprpinfo: string|null}>` - `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) - [customergroup](API.customergroup.md)
- `create(googleAuthId: number, input: {name: string}, tx?: object): Promise<{id: number, name: string, sort: number}>` - `create(googleAuthId: number, input: {name: string}, tx?: object): Promise<{id: number, name: string, sort: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`

View File

@@ -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). - `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). - 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. - `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`. - `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`, `labelprinter.addr`, `labelprinter.type`. - `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. - No delete endpoint — settings are upsert / read only.
Errors: `No company selected`, `Company not found`, `Not authorized`, `Invalid setting`. Errors: `No company selected`, `Company not found`, `Not authorized`, `Invalid setting`.

View File

@@ -41,6 +41,8 @@ import companysettingSet from './store/companysetting/set.js';
import brandList from './store/brand/list.js'; import brandList from './store/brand/list.js';
import brandUpsert from './store/brand/upsert.js'; import brandUpsert from './store/brand/upsert.js';
import brandDelete from './store/brand/delete.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 customergroupList from './store/customergroup/list.js';
import customergroupCreate from './store/customergroup/create.js'; import customergroupCreate from './store/customergroup/create.js';
import customergroupUpdate from './store/customergroup/update.js'; import customergroupUpdate from './store/customergroup/update.js';
@@ -232,6 +234,8 @@ export default class LibDashStore {
list: brandList(provider), list: brandList(provider),
upsert: brandUpsert(provider), upsert: brandUpsert(provider),
delete: brandDelete(provider), delete: brandDelete(provider),
addImage: brandAddImage(provider),
deleteImage: brandDeleteImage(provider),
}; };
this.customergroup = { this.customergroup = {

View File

@@ -13,6 +13,14 @@ OUTER APPLY (
ORDER BY ap.sort ASC, ap.id ASC ORDER BY ap.sort ASC, ap.id ASC
) first_pic ) first_pic
LEFT JOIN pic ON pic.id = first_pic.pic_id 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 LEFT JOIN article father ON father.id = a.fatherarticle_id
`; `;
@@ -49,7 +57,13 @@ export const ARTICLE_LIST_COLUMNS = `
pic.hash AS fullHash, pic.hash AS fullHash,
pic.thumbw AS thumbW, pic.thumbw AS thumbW,
pic.thumbh AS thumbH, 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 = ` export const GET_CATEGORY_ID_BY_SKU = `
@@ -362,7 +376,9 @@ SELECT
u.basesifactor AS unitBasesifactor, u.basesifactor AS unitBasesifactor,
ut.symbol AS unitSymbol, ut.symbol AS unitSymbol,
ut.name AS unitName, ut.name AS unitName,
b.id AS brandId,
b.name AS brandName, b.name AS brandName,
bp.hash AS brandFullHash,
bp.thumbw AS brandThumbW, bp.thumbw AS brandThumbW,
bp.thumbh AS brandThumbH, bp.thumbh AS brandThumbH,
bp.thumbhash AS brandThumbHash, bp.thumbhash AS brandThumbHash,

View File

@@ -10,7 +10,9 @@ export const ARTICLE_DETAIL_COLUMNS = `
u.basesifactor AS unitBasesifactor, u.basesifactor AS unitBasesifactor,
ut.symbol AS unitSymbol, ut.symbol AS unitSymbol,
ut.name AS unitName, ut.name AS unitName,
b.id AS brandId,
b.name AS brandName, b.name AS brandName,
brand_pic.hash AS brandFullHash,
brand_pic.thumbw AS brandThumbW, brand_pic.thumbw AS brandThumbW,
brand_pic.thumbh AS brandThumbH, brand_pic.thumbh AS brandThumbH,
brand_pic.thumbhash AS brandThumbHash, brand_pic.thumbhash AS brandThumbHash,

View File

@@ -1,21 +1,49 @@
export const LIST_BY_COMPANY = ` export const LIST_BY_COMPANY = `
SELECT SELECT
id, b.id,
name, b.name,
gsprpinfo b.gsprpinfo,
FROM brand (
WHERE company_id = @companyId SELECT
ORDER BY name 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 = ` export const GET_BY_COMPANY_AND_NAME = `
SELECT SELECT
id, b.id,
name, b.name,
gsprpinfo b.gsprpinfo,
FROM brand (
WHERE company_id = @companyId SELECT
AND name = @name 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 = ` export const CREATE = `
@@ -34,3 +62,13 @@ export const DELETE_BY_COMPANY_AND_ID = `
DELETE FROM brand DELETE FROM brand
WHERE id = @id AND company_id = @companyId 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
`;

View File

@@ -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
`;

View File

@@ -23,6 +23,15 @@ FROM (
FROM pic p FROM pic p
INNER JOIN categorypic cp ON cp.pic_id = p.id INNER JOIN categorypic cp ON cp.pic_id = p.id
WHERE p.hash = @hash 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 ) AS combined
`; `;

View File

@@ -24,6 +24,11 @@ LEFT JOIN pic ON pic.id = (
SELECT ap.pic_id FROM articlepic ap SELECT ap.pic_id FROM articlepic ap
WHERE ap.article_id = a.id ORDER BY ap.sort, ap.id LIMIT 1 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 LEFT JOIN article father ON father.id = a.fatherarticle_id
`; `;
@@ -119,7 +124,9 @@ SELECT auth.*,
'unitamount', a.unitamount, 'weight', a.weight, 'unitamount', a.unitamount, 'weight', a.weight,
'unitSi', u.si, 'unitBasesi', u.basesi, 'unitSi', u.si, 'unitBasesi', u.basesi,
'unitBasesifactor', u.basesifactor, 'unitSymbol', ut.symbol, '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, 'brandThumbW', bp.thumbw, 'brandThumbH', bp.thumbh,
'brandThumbHash', bp.thumbhash, 'brandThumbHash', bp.thumbhash,
'optionsCount', (SELECT COUNT(*) FROM optionslistarticle WHERE optionslist_id = a.optionslist_id), 'optionsCount', (SELECT COUNT(*) FROM optionslistarticle WHERE optionslist_id = a.optionslist_id),

View File

@@ -3,6 +3,59 @@ import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'brand'); const queries = translateQueries(source, 'brand');
export const { export const {
CREATE, DELETE_BY_COMPANY_AND_ID, GET_BY_COMPANY_AND_NAME, CREATE, DELETE_BY_COMPANY_AND_ID, UPDATE_BY_ID, GET_BY_ID_AND_COMPANY,
LIST_BY_COMPANY, UPDATE_BY_ID, } = queries;
} = 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
`;

View File

@@ -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;

View File

@@ -18,7 +18,7 @@ export default function getBySku(provider) {
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction. * @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. * @throws {Error} If the article is not found or the user is not authorized.
* *
* @example * @example
@@ -65,10 +65,12 @@ export default function getBySku(provider) {
}, },
brand: a.brandName brand: a.brandName
? { ? {
id: a.brandId ?? null,
name: a.brandName, name: a.brandName,
thumbW: a.brandThumbW, fullHash: a.brandFullHash ?? null,
thumbH: a.brandThumbH, thumbW: a.brandThumbW ?? null,
thumbHash: a.brandThumbHash, thumbH: a.brandThumbH ?? null,
thumbHash: a.brandThumbHash ?? null,
} }
: null, : null,
categoryIds: parseJsonArray(a.categoryIdsJson).map((r) => r.category_id), categoryIds: parseJsonArray(a.categoryIdsJson).map((r) => r.category_id),

View File

@@ -19,10 +19,12 @@ function shapeArticleDetail(row, categoryIds, photos, eans, prices) {
}, },
brand: row.brandName brand: row.brandName
? { ? {
id: row.brandId ?? null,
name: row.brandName, name: row.brandName,
thumbW: row.brandThumbW, fullHash: row.brandFullHash ?? null,
thumbH: row.brandThumbH, thumbW: row.brandThumbW ?? null,
thumbHash: row.brandThumbHash, thumbH: row.brandThumbH ?? null,
thumbHash: row.brandThumbHash ?? null,
} }
: null, : null,
categoryIds: categoryIds.map((r) => r.categoryId), categoryIds: categoryIds.map((r) => r.categoryId),

104
src/store/brand/addImage.js Normal file
View File

@@ -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<object>}>}
*/
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));
};
}

View File

@@ -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: [],
};
};
}

View File

@@ -3,6 +3,12 @@ import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlit
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.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) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider); const access = resolveSelectedCompanyAccess(provider);
const sql = const sql =
@@ -15,17 +21,22 @@ export default function list(provider) {
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>} The brands. * @returns {Promise<Array<{id: number, name: string, gsprpinfo: string|null, pics: Array<{id: number, sort: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>}>>} The brands.
* *
* @example * @example
* const brands = await store.brand.list(42); * const brands = await store.brand.list(42);
* // => [{ id, name, gsprpinfo }] * // => [{ id, name, gsprpinfo, pics: [{ sort, fullHash, thumbW, thumbH, thumbHash }] }]
*/ */
return async (googleAuthId, tx) => { return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId, tx); const { companyId } = await access(googleAuthId, tx);
const result = await makeRunner(provider, tx).query(sql, { companyId }); 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),
}));
}; };
} }

View File

@@ -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] };
};
}

View File

@@ -11,6 +11,12 @@ import {
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.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) { export default function upsert(provider) {
const access = resolveSelectedCompanyAccess(provider); const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3'; const isSqlite = provider?.dialect === 'sqlite3';
@@ -28,12 +34,12 @@ export default function upsert(provider) {
* @param {string} input.name - The brand name. * @param {string} input.name - The brand name.
* @param {string} [input.gsprpinfo] - Optional GSPRP info. * @param {string} [input.gsprpinfo] - Optional GSPRP info.
* @param {object} [tx] - Optional transaction to run within. * @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. * @throws {Error} If the brand is invalid.
* *
* @example * @example
* const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' }); * const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
* // => { id, name, gsprpinfo } * // => { id, name, gsprpinfo, pics: [] }
*/ */
return async (googleAuthId, input = {}, tx) => { return async (googleAuthId, input = {}, tx) => {
const name = input.name?.trim(); const name = input.name?.trim();
@@ -58,7 +64,12 @@ export default function upsert(provider) {
name, name,
gsprpinfo, gsprpinfo,
}); });
return { id, name, gsprpinfo }; return {
id,
name,
gsprpinfo,
pics: parsePics(existing.rows[0].picsJson),
};
} }
const created = await runner.query(createSql, { const created = await runner.query(createSql, {
@@ -73,6 +84,7 @@ export default function upsert(provider) {
id, id,
name, name,
gsprpinfo, gsprpinfo,
pics: [],
}; };
}; };
} }

View File

@@ -11,11 +11,11 @@ export default function getCategoryArticles(provider) {
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category. * @param {number} categoryId - The id of the category.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<object>>} The articles in the category. * @returns {Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>} The articles in the category.
* *
* @example * @example
* const articles = await store.category.getCategoryArticles(42, 5); * const articles = await store.category.getCategoryArticles(42, 5);
* // => [{ id, sku, name, ... }] * // => [{ id, sku, name, brand, ... }]
*/ */
return async (googleAuthId, categoryId, tx) => { return async (googleAuthId, categoryId, tx) => {
const { languageId, posCustomerGroupId } = await access( const { languageId, posCustomerGroupId } = await access(

View File

@@ -12,6 +12,29 @@ export default function queryCategoryArticles(provider) {
posCustomerGroupId, 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,
};
});
}; };
} }

View File

@@ -14,6 +14,7 @@ const ALLOWED_KEYS = new Set([
'ui.theme', 'ui.theme',
'keys.tavily', 'keys.tavily',
'keys.openrouter', 'keys.openrouter',
'keys.serper',
'labelprinter.addr', 'labelprinter.addr',
'labelprinter.type', 'labelprinter.type',
]); ]);
@@ -21,6 +22,7 @@ const ALLOWED_KEYS = new Set([
const WRITEONLY_KEYS = new Set([ const WRITEONLY_KEYS = new Set([
'keys.tavily', 'keys.tavily',
'keys.openrouter', 'keys.openrouter',
'keys.serper',
]); ]);
export default function set(provider) { 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.key - The setting key (must be in the allow-list).
* @param {string} input.value - The setting value. * @param {string} input.value - The setting value.
* @param {boolean} [input.frontend] - Whether the setting is frontend-visible. * @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. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>} The upserted setting. * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>} The upserted setting.
* @throws {Error} If the input is invalid. * @throws {Error} If the input is invalid.