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

@@ -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),

View File

@@ -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),

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 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<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
* 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),
}));
};
}

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

View File

@@ -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<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
* 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(

View File

@@ -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,
};
});
};
}

View File

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