This commit is contained in:
seb
2026-09-22 07:25:16 +02:00
parent 4ed6260e91
commit cca131d83a
148 changed files with 5343 additions and 3466 deletions

40
TODO.md
View File

@@ -9,35 +9,35 @@ Replace raw `pool` usage in `src/store/**` with `provider.query()` /
`provider.transaction()` / `provider.exec()` and the normalized result shape
(`{ rows, rowsAffected, insertId }` instead of `recordset` / `rowsAffected[]`).
For every module:
- [ ] `pool.request().input(...).query(SQL)` → `provider.query(SQL, params)`
- [ ] `pool.transaction(); begin; commit; catch rollback` → `provider.transaction(async (tx) => { ... })`
- [ ] `result.recordset` → `result.rows`; `result.rowsAffected[0]` → `result.rowsAffected`
- [ ] `request.js` helper (`(tx ?? pool).request()`) becomes obsolete — delete when unused
- [x] `pool.request().input(...).query(SQL)` → `provider.query(SQL, params)`
- [x] `pool.transaction(); begin; commit; catch rollback` → `provider.transaction(async (tx) => { ... })`
- [x] `result.recordset` → `result.rows`; `result.rowsAffected[0]` → `result.rowsAffected`
- [x] `request.js` helper (`(tx ?? pool).request()`) becomes obsolete — deleted
- [x] `src/store/runner.js` bridges mixed call sites: converted modules accept a
provider runner *or* a legacy mssql Transaction/`pool` (wrapped on the fly)
so partially converted callers keep working
- [ ] `recordset[0].id` after `OUTPUT INSERTED.id` → keep, but prefer `insertId` where trivial
- [x] `recordset[0].id` after `OUTPUT INSERTED.id` → keep, but prefer `insertId` where trivial
Conversion order (bottom-up by dependency):
- [x] `lang`, `tax` (leaf helpers, used by company create)
- [x] `google_auth`
- [ ] `companyuser` (resolveCompanyAccess / resolveSelectedCompanyAccess first)
- [ ] `company`, `companysetting`
- [ ] `customergroup`, `entity`, `entityaddress`, `entitycontact`, `entityhistory`
- [ ] `unit` (incl. `ensureDefault`), `brand`, `supplier`
- [ ] `numbers` (see §2 — stored procs!)
- [ ] `category` (recursive CTEs!)
- [ ] `article` + articletext/articlepic/articleean/price/articlehistory
- [ ] `articlesupplier`
- [ ] `quote`, `order`, `delivery` (shared manifest helpers: `manifestLines`, `manifestDoc`, `manifestattachment`)
- [ ] `posterminal`, `pic`
- [ ] `initialize.js`, `purge.js` final wiring (drop `provider.pool` fallback from `index.js`)
- [ ] Delete `src/store/request.js` once no module uses it
- [x] `companyuser` (resolveCompanyAccess / resolveSelectedCompanyAccess first)
- [x] `company`, `companysetting`
- [x] `customergroup`, `entity`, `entityaddress`, `entitycontact`, `entityhistory`
- [x] `unit` (incl. `ensureDefault`), `brand`, `supplier`
- [x] `numbers` (see §2 — stored procs!)
- [x] `category` (recursive CTEs!)
- [x] `article` + articletext/articlepic/articleean/price/articlehistory
- [x] `articlesupplier`
- [x] `quote`, `order`, `delivery` (shared manifest helpers: `manifestLines`, `manifestDoc`, `manifestattachment`)
- [x] `posterminal`, `pic`
- [x] `initialize.js`, `purge.js` final wiring (drop `provider.pool` fallback from `index.js`)
- [x] Delete `src/store/request.js` once no module uses it
## 2. Replace MSSQL stored procedures (blocker for SQLite)
- [ ] `numbers.nextNumber` uses proc `getnextnumberwithprefixandpadding` (output param)
- [ ] `numbers.peekNumber` uses proc `peeknextnumberwithprefixandpadding` (output param)
- [ ] Implement both in plain JS inside the store layer: `SELECT next/prefix/padding`,
- [x] `numbers.nextNumber` uses proc `getnextnumberwithprefixandpadding` (output param)
- [x] `numbers.peekNumber` uses proc `peeknextnumberwithprefixandpadding` (output param)
- [x] Implement both in plain JS inside the store layer: `SELECT next/prefix/padding`,
compute `prefix + padded next`, `UPDATE numbers SET next = next + 1 WHERE ...`.
Must stay atomic: use `provider.transaction()` with row locking semantics
(MSSQL: `WITH (UPDLOCK, ROWLOCK)` in the SELECT; SQLite: within its write tx).

24
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"thumbhash": "^0.1.1"
},
"devDependencies": {
"better-sqlite3": "^13.0.3",
"mssql": "^12.7.2"
},
"peerDependencies": {
@@ -984,6 +985,19 @@
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/bl": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
@@ -1466,6 +1480,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "8.9.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",

View File

@@ -41,6 +41,7 @@
}
},
"devDependencies": {
"better-sqlite3": "^13.0.3",
"mssql": "^12.7.2"
}
}

View File

@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function runTests() {
console.log('--- Starting SQLite file and pic directory storage tests ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-sqlite-store-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'custom-storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
// Verify storage subdirectories exist
assert(existsSync(join(storageDir, 'content')), 'content dir should exist');
assert(existsSync(join(storageDir, 'thumbavif')), 'thumbavif dir should exist');
assert(existsSync(join(storageDir, 'fullbinary')), 'fullbinary dir should exist');
assert(existsSync(join(storageDir, 'thumbbinary')), 'thumbbinary dir should exist');
// Verify sqlite tables do NOT contain blob columns
const fileTableInfo = provider.db.pragma('table_info("file")');
const fileColNames = fileTableInfo.map((c) => c.name);
console.log('file table columns:', fileColNames);
assert(!fileColNames.includes('content'), 'file table should NOT have content column');
assert(!fileColNames.includes('thumbavif'), 'file table should NOT have thumbavif column');
const picTableInfo = provider.db.pragma('table_info("pic")');
const picColNames = picTableInfo.map((c) => c.name);
console.log('pic table columns:', picColNames);
assert(!picColNames.includes('fullbinary'), 'pic table should NOT have fullbinary column');
assert(!picColNames.includes('thumbbinary'), 'pic table should NOT have thumbbinary column');
// Setup base data: google_auth, company, unit, article, manifest
const googleAuthId = await store.google_auth.add('test@example.com');
const company = await store.company.create({
name: 'Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// 1. Test Manifest Attachment (file table)
console.log('1. Testing manifestattachment with file table...');
const delivery = await store.delivery.create(googleAuthId, {
externalNumber: 'DEL-1',
date: new Date().toISOString(),
lines: [
{
name: 'Item A',
amount: 5,
net: 10,
taxrate: 19,
},
],
});
const manifestId = delivery.id;
const testContent = Buffer.from('Hello SQLite Storage Engine! PDF or text content test.');
const testThumb = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 1 },
},
})
.avif()
.toBuffer();
const attachment = await store.manifestattachment.add(googleAuthId, manifestId, {
name: 'test-doc.txt',
mimetype: 'text/plain',
content: testContent,
thumbavif: testThumb,
thumbw: 64,
thumbh: 64,
});
console.log('Attachment created with hash:', attachment.hash);
// Verify files on disk have correct extensions according to mimetype
const contentPath = join(storageDir, 'content', attachment.hash + '.txt');
const thumbPath = join(storageDir, 'thumbavif', attachment.hash + '.avif');
assert(existsSync(contentPath), 'Content file must exist on disk at ' + contentPath);
assert(existsSync(thumbPath), 'Thumb file must exist on disk at ' + thumbPath);
assert.deepEqual(readFileSync(contentPath), testContent, 'Content on disk must match');
assert.deepEqual(readFileSync(thumbPath), testThumb, 'Thumb on disk must match');
// Verify getById
const fetched = await store.manifestattachment.getById(googleAuthId, manifestId, attachment.id);
assert.equal(fetched.name, 'test-doc.txt');
assert(Buffer.isBuffer(fetched.content), 'Fetched content must be a Buffer');
assert.deepEqual(fetched.content, testContent, 'Fetched content must match original');
assert(Buffer.isBuffer(fetched.thumbavif), 'Fetched thumbavif must be a Buffer');
assert.deepEqual(fetched.thumbavif, testThumb, 'Fetched thumbavif must match original');
// Verify listByManifestId
const list = await store.manifestattachment.listByManifestId(googleAuthId, manifestId);
assert.equal(list.length, 1);
assert(Buffer.isBuffer(list[0].thumbavif), 'List thumbavif must be a Buffer');
assert.deepEqual(list[0].thumbavif, testThumb);
// Verify remove
await store.manifestattachment.remove(googleAuthId, manifestId, attachment.id);
assert(!existsSync(contentPath), 'Content file should be deleted on remove');
assert(!existsSync(thumbPath), 'Thumb file should be deleted on remove');
// Test delivery creation with inline attached files
console.log('1b. Testing delivery.create with inline attached file...');
const deliveryWithFile = await store.delivery.create(googleAuthId, {
externalNumber: 'DEL-WITH-FILE',
date: new Date().toISOString(),
lines: [{ name: 'Item B', amount: 1, net: 5, taxrate: 19 }],
files: [{
name: 'inline.txt',
mimetype: 'text/plain',
content: Buffer.from('Inline delivery file content'),
}],
});
assert(deliveryWithFile.files && deliveryWithFile.files.length === 1, 'Delivery should have 1 attached file');
const inlineHash = deliveryWithFile.files[0].hash;
const inlineContentPath = join(storageDir, 'content', inlineHash + '.txt');
const inlineThumbPath = join(storageDir, 'thumbavif', inlineHash + '.avif');
assert(existsSync(inlineContentPath), 'Inline file content must exist on disk with .txt extension');
assert(existsSync(inlineThumbPath), 'Inline file thumb must exist on disk with .avif extension');
const fetchedDelivery = await store.delivery.getById(googleAuthId, deliveryWithFile.id);
assert(fetchedDelivery.files && fetchedDelivery.files.length === 1);
assert(fetchedDelivery.files[0].thumbavif, 'Fetched delivery file should include thumbavif');
console.log('Manifest attachment tests passed!');
// 2. Test Article Image (pic table)
console.log('2. Testing article.addImage with pic table...');
const cat = await store.category.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Sub Category',
});
const article = await store.article.createUnderParent({
googleAuthId,
parentId: cat.id,
name: 'Test Article',
});
const sampleImage = await sharp({
create: {
width: 300,
height: 200,
channels: 4,
background: { r: 0, g: 128, b: 255, alpha: 1 },
},
})
.png()
.toBuffer();
const picResult = await store.article.addImage(googleAuthId, article.sku, sampleImage);
const fullHash = picResult.fullHash;
console.log('Article pic added with hash:', fullHash);
const fullPath = join(storageDir, 'fullbinary', fullHash + '.avif');
const picThumbPath = join(storageDir, 'thumbbinary', fullHash + '.avif');
assert(existsSync(fullPath), 'Full picture must exist on disk at ' + fullPath);
assert(existsSync(picThumbPath), 'Thumb picture must exist on disk at ' + picThumbPath);
// Verify getImageByHash
const fullPic = await store.pic.getImageByHash(googleAuthId, fullHash);
assert(Buffer.isBuffer(fullPic.buffer), 'Image buffer must be returned');
assert.deepEqual(fullPic.buffer, readFileSync(fullPath));
// Verify getThumbByHash
const thumbPic = await store.pic.getThumbByHash(googleAuthId, fullHash);
assert(Buffer.isBuffer(thumbPic.buffer), 'Thumbnail buffer must be returned');
assert.deepEqual(thumbPic.buffer, readFileSync(picThumbPath));
// Verify deleteImage
await store.article.deleteImage(googleAuthId, article.sku, fullHash);
assert(!existsSync(fullPath), 'Full pic file should be deleted on deleteImage');
assert(!existsSync(picThumbPath), 'Thumb pic file should be deleted on deleteImage');
console.log('Article pic tests passed!');
// 3. Test purge
console.log('3. Testing purge...');
// Add one more file before purge
await store.manifestattachment.add(googleAuthId, manifestId, {
name: 'purge-test.txt',
mimetype: 'text/plain',
content: Buffer.from('To be purged'),
});
await provider.purge();
assert(!existsSync(contentPath), 'Files should be purged');
console.log('Purge tests passed!');
console.log('\nAll tests PASSED successfully!');
} finally {
provider.close();
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
}
}
runTests().catch((err) => {
console.error('Test failed with error:', err);
process.exit(1);
});

View File

@@ -138,55 +138,52 @@ export default class LibDashStore {
}
this.provider = provider;
// Transitional: store modules still use the raw mssql pool.
// They are converted to provider.query() area by area (see TODO.md).
const pool = provider.pool;
this.pool = pool;
this.pool = provider.pool;
this.companyuser = {
getByEmail: companyuserGetByEmail(pool),
add: companyuserAdd(pool),
delete: companyuserDelete(pool),
getByEmail: companyuserGetByEmail(provider),
add: companyuserAdd(provider),
delete: companyuserDelete(provider),
};
this.company = {
create: companyCreate(pool),
update: companyUpdate(pool),
delete: companyDelete(pool),
list: companyList(pool),
create: companyCreate(provider),
update: companyUpdate(provider),
delete: companyDelete(provider),
list: companyList(provider),
};
this.category = {
getTreeForCompany: categoryGetTreeForCompany(pool),
getCategoryArticles: categoryGetCategoryArticles(pool),
createUnderParent: categoryCreateUnderParent(pool),
ensureByPath: categoryEnsureByPath(pool),
rename: categoryRename(pool),
move: categoryMove(pool),
order: categoryOrder(pool),
delete: categoryDelete(pool),
addImage: categoryAddImage(pool),
deleteImage: categoryDeleteImage(pool),
getTreeForCompany: categoryGetTreeForCompany(provider),
getCategoryArticles: categoryGetCategoryArticles(provider),
createUnderParent: categoryCreateUnderParent(provider),
ensureByPath: categoryEnsureByPath(provider),
rename: categoryRename(provider),
move: categoryMove(provider),
order: categoryOrder(provider),
delete: categoryDelete(provider),
addImage: categoryAddImage(provider),
deleteImage: categoryDeleteImage(provider),
};
this.article = {
createUnderParent: articleCreateUnderParent(pool),
getBySku: articleGetBySku(pool),
getHistoryBySku: articleGetHistoryBySku(pool),
listSearchIndex: articleListSearchIndex(pool),
listExport: articleListExport(pool),
updateBySku: articleUpdateBySku(pool),
setPricesBySku: articleSetPricesBySku(pool),
setEansBySku: articleSetEansBySku(pool),
setBrandIdBySku: articleSetBrandIdBySku(pool),
setWeightBySku: articleSetWeightBySku(pool),
setCategoryIdsBySku: articleSetCategoryIdsBySku(pool),
setFatherBySku: articleSetFatherBySku(pool),
importRows: articleImportRows(pool),
addImage: articleAddImage(pool),
deleteImage: articleDeleteImage(pool),
orderImages: articleOrderImages(pool),
delete: articleDelete(pool),
createUnderParent: articleCreateUnderParent(provider),
getBySku: articleGetBySku(provider),
getHistoryBySku: articleGetHistoryBySku(provider),
listSearchIndex: articleListSearchIndex(provider),
listExport: articleListExport(provider),
updateBySku: articleUpdateBySku(provider),
setPricesBySku: articleSetPricesBySku(provider),
setEansBySku: articleSetEansBySku(provider),
setBrandIdBySku: articleSetBrandIdBySku(provider),
setWeightBySku: articleSetWeightBySku(provider),
setCategoryIdsBySku: articleSetCategoryIdsBySku(provider),
setFatherBySku: articleSetFatherBySku(provider),
importRows: articleImportRows(provider),
addImage: articleAddImage(provider),
deleteImage: articleDeleteImage(provider),
orderImages: articleOrderImages(provider),
delete: articleDelete(provider),
};
this.lang = {
@@ -205,105 +202,105 @@ export default class LibDashStore {
};
this.unit = {
list: unitList(pool),
upsert: unitUpsert(pool),
delete: unitDelete(pool),
list: unitList(provider),
upsert: unitUpsert(provider),
delete: unitDelete(provider),
};
this.companysetting = {
list: companysettingList(pool),
get: companysettingGet(pool),
set: companysettingSet(pool),
list: companysettingList(provider),
get: companysettingGet(provider),
set: companysettingSet(provider),
};
this.brand = {
list: brandList(pool),
upsert: brandUpsert(pool),
delete: brandDelete(pool),
list: brandList(provider),
upsert: brandUpsert(provider),
delete: brandDelete(provider),
};
this.customergroup = {
list: customergroupList(pool),
create: customergroupCreate(pool),
update: customergroupUpdate(pool),
order: customergroupOrder(pool),
delete: customergroupDelete(pool),
list: customergroupList(provider),
create: customergroupCreate(provider),
update: customergroupUpdate(provider),
order: customergroupOrder(provider),
delete: customergroupDelete(provider),
};
this.entity = {
list: entityList(pool),
getById: entityGetById(pool),
create: entityCreate(pool),
update: entityUpdate(pool),
delete: entityDelete(pool),
peekNextNumber: entityPeekNextNumber(pool),
isNumberFree: entityIsNumberFree(pool),
list: entityList(provider),
getById: entityGetById(provider),
create: entityCreate(provider),
update: entityUpdate(provider),
delete: entityDelete(provider),
peekNextNumber: entityPeekNextNumber(provider),
isNumberFree: entityIsNumberFree(provider),
};
this.entityaddress = {
list: entityaddressList(pool),
create: entityaddressCreate(pool),
update: entityaddressUpdate(pool),
delete: entityaddressDelete(pool),
list: entityaddressList(provider),
create: entityaddressCreate(provider),
update: entityaddressUpdate(provider),
delete: entityaddressDelete(provider),
};
this.entitycontact = {
list: entitycontactList(pool),
create: entitycontactCreate(pool),
update: entitycontactUpdate(pool),
delete: entitycontactDelete(pool),
list: entitycontactList(provider),
create: entitycontactCreate(provider),
update: entitycontactUpdate(provider),
delete: entitycontactDelete(provider),
};
this.supplier = {
list: supplierList(pool),
create: supplierCreate(pool),
update: supplierUpdate(pool),
delete: supplierDelete(pool),
list: supplierList(provider),
create: supplierCreate(provider),
update: supplierUpdate(provider),
delete: supplierDelete(provider),
};
this.articlesupplier = {
listByArticleSku: articlesupplierListByArticleSku(pool),
listByEntityAndSkus: articlesupplierListByEntityAndSkus(pool),
listBySupplierAndSkus: articlesupplierListByEntityAndSkus(pool),
create: articlesupplierCreate(pool),
update: articlesupplierUpdate(pool),
order: articlesupplierOrder(pool),
delete: articlesupplierDelete(pool),
listByArticleSku: articlesupplierListByArticleSku(provider),
listByEntityAndSkus: articlesupplierListByEntityAndSkus(provider),
listBySupplierAndSkus: articlesupplierListByEntityAndSkus(provider),
create: articlesupplierCreate(provider),
update: articlesupplierUpdate(provider),
order: articlesupplierOrder(provider),
delete: articlesupplierDelete(provider),
};
this.delivery = {
list: deliveryList(pool),
getById: deliveryGetById(pool),
create: deliveryCreate(pool),
update: deliveryUpdate(pool),
delete: deliveryDelete(pool),
list: deliveryList(provider),
getById: deliveryGetById(provider),
create: deliveryCreate(provider),
update: deliveryUpdate(provider),
delete: deliveryDelete(provider),
};
this.quote = {
list: quoteList(pool),
getById: quoteGetById(pool),
create: quoteCreate(pool),
update: quoteUpdate(pool),
delete: quoteDelete(pool),
list: quoteList(provider),
getById: quoteGetById(provider),
create: quoteCreate(provider),
update: quoteUpdate(provider),
delete: quoteDelete(provider),
};
this.order = {
list: orderList(pool),
getById: orderGetById(pool),
create: orderCreate(pool),
update: orderUpdate(pool),
delete: orderDelete(pool),
list: orderList(provider),
getById: orderGetById(provider),
create: orderCreate(provider),
update: orderUpdate(provider),
delete: orderDelete(provider),
};
this.posterminal = {
list: posterminalList(pool),
upsert: posterminalUpsert(pool),
delete: posterminalDelete(pool),
setOn: posterminalSetOn(pool),
setOff: posterminalSetOff(pool),
setOnAll: posterminalSetOnAll(pool),
setOffAll: posterminalSetOffAll(pool),
getCategoryPosterminalIds: posterminalGetCategoryPosterminalIds(pool),
list: posterminalList(provider),
upsert: posterminalUpsert(provider),
delete: posterminalDelete(provider),
setOn: posterminalSetOn(provider),
setOff: posterminalSetOff(provider),
setOnAll: posterminalSetOnAll(provider),
setOffAll: posterminalSetOffAll(provider),
getCategoryPosterminalIds: posterminalGetCategoryPosterminalIds(provider),
};
this.google_auth = {
@@ -317,21 +314,21 @@ export default class LibDashStore {
};
this.pic = {
getImageByHash: picGetImageByHash(pool),
getThumbByHash: picGetThumbByHash(pool),
getImageByHash: picGetImageByHash(provider),
getThumbByHash: picGetThumbByHash(provider),
};
this.numbers = {
list: numbersList(pool),
update: numbersUpdate(pool),
list: numbersList(provider),
update: numbersUpdate(provider),
};
this.manifestattachment = {
add: manifestattachmentAdd(pool),
remove: manifestattachmentRemove(pool),
listByManifestId: manifestattachmentListByManifestId(pool),
getById: manifestattachmentGetById(pool),
order: manifestattachmentOrder(pool),
add: manifestattachmentAdd(provider),
remove: manifestattachmentRemove(provider),
listByManifestId: manifestattachmentListByManifestId(provider),
getById: manifestattachmentGetById(provider),
order: manifestattachmentOrder(provider),
};
this.purge = storePurge(provider);
@@ -347,3 +344,6 @@ export default class LibDashStore {
}
export { SCHEMA_SCRIPTS } from './providers/mssql/schema.js';
export { BetterSqlite3Provider } from './providers/sqlite3/index.js';
export { MssqlProvider } from './providers/mssql/index.js';

View File

@@ -133,8 +133,12 @@ export class MssqlProvider {
const tx = pool.transaction();
await tx.begin();
try {
const runner = this.makeExecutor(() => tx.request());
const result = await fn(runner);
const executor = this.makeExecutor(() => tx.request());
const runner = async (sql, params) => executor.query(sql, params);
runner.query = runner;
runner.exec = (proc, opts) => executor.exec(proc, opts);
runner.dialect = 'mssql';
const result = await fn(runner, tx);
await tx.commit();
return result;
} catch (err) {
@@ -148,17 +152,32 @@ export class MssqlProvider {
}
async initialize() {
// Implemented by store/initialize.js — no-op here (keeps parity with
// other providers, which may run migrations internally instead).
const pool = await this.ensurePool();
const { SCHEMA_SCRIPTS } = await import('./schema.js');
for (const script of SCHEMA_SCRIPTS) {
if (script && script.trim()) {
await pool.request().query(script);
}
}
}
async purge() {
// Implemented by store/purge.js — no-op here.
const pool = await this.ensurePool();
const { buildPurgeSql } = await import('./purgeSql.js');
await pool.request().query(buildPurgeSql());
}
close() {
return this.pool ? this.pool.close() : Promise.resolve();
}
isUniqueConstraintError(err) {
return isUniqueConstraintError(err);
}
isForeignKeyConstraintError(err) {
return isForeignKeyConstraintError(err);
}
}
/** mssql: 2627 = PK/unique violation, 2601 = unique index violation */

View File

@@ -44,3 +44,14 @@ OUTPUT INSERTED.id, INSERTED.[type], INSERTED.prefix, INSERTED.padding, INSERTED
WHERE id = @id
AND company_id = @companyId
`;
export const GET_FOR_UPDATE = `
SELECT id, [type], prefix, padding, [next] AS [next]
FROM numbers WITH (UPDLOCK, ROWLOCK)
WHERE company_id = @companyId AND [type] = @type;
`;
export const INCREMENT = `
UPDATE numbers SET [next] = [next] + 1 WHERE id = @id;
`;

View File

@@ -23,18 +23,26 @@
/**
* Checks whether an error is a unique-constraint violation.
* @param {unknown} err
* @param {any} err
* @returns {boolean}
*/
export function isUniqueConstraintError(err) {
throw new Error('Not implemented: isUniqueConstraintError');
return (
err?.number === 2627 ||
err?.number === 2601 ||
err?.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
err?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
);
}
/**
* Checks whether an error is a foreign-key-constraint violation.
* @param {unknown} err
* @param {any} err
* @returns {boolean}
*/
export function isForeignKeyConstraintError(err) {
throw new Error('Not implemented: isForeignKeyConstraintError');
return (
err?.number === 547 ||
err?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY'
);
}

View File

@@ -1,15 +1,29 @@
/**
* Provider for a caller-owned `better-sqlite3` Database instance.
* Provider for `better-sqlite3` Database instances or connection options.
*
* The driver is synchronous, but this adapter exposes the asynchronous
* provider contract used by LibDashStore.
*/
import Database from 'better-sqlite3';
import { SCHEMA_SCRIPTS } from './schema.js';
function normalizeParamValue(value) {
if (value === undefined || value === null) return null;
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === 'boolean') {
return value ? 1 : 0;
}
return value;
}
function bindParams(params) {
return Object.fromEntries(
Object.entries(params).map(([key, value]) => [
key.replace(/^[@:$]/, ''),
value === undefined ? null : value,
normalizeParamValue(value),
]),
);
}
@@ -20,10 +34,169 @@ function normalizeInsertId(value) {
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value;
}
import { dirname, join } from 'node:path';
import { mkdirSync, mkdtempSync, promises as fsPromises } from 'node:fs';
import { tmpdir } from 'node:os';
export function extFromMimetype(mimetype) {
if (!mimetype) return '';
const type = mimetype.trim().toLowerCase().split(';')[0];
const map = {
'application/pdf': '.pdf',
'text/plain': '.txt',
'text/csv': '.csv',
'text/html': '.html',
'text/markdown': '.md',
'image/avif': '.avif',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif',
'image/svg+xml': '.svg',
'application/json': '.json',
'application/zip': '.zip',
'application/xml': '.xml',
'application/octet-stream': '.bin',
};
if (map[type]) return map[type];
if (type.startsWith('image/')) return '.' + type.slice(6).replace('+xml', '');
if (type.startsWith('text/')) return '.' + type.slice(5);
return '';
}
async function safeWrite(filePath, buffer) {
if (buffer == null) return;
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
await fsPromises.writeFile(filePath, buf);
}
async function findFileByHash(dir, hash, ext = '') {
if (!dir || !hash) return null;
if (ext) {
const candidate = join(dir, hash + ext);
try {
await fsPromises.access(candidate);
return candidate;
} catch {
// fallback
}
}
const candidateNoExt = join(dir, hash);
try {
await fsPromises.access(candidateNoExt);
return candidateNoExt;
} catch {
// fallback
}
try {
const files = await fsPromises.readdir(dir);
const match = files.find((f) => f === hash || f.startsWith(hash + '.'));
if (match) return join(dir, match);
} catch {
// ignore
}
return null;
}
async function safeReadByHash(dir, hash, ext = '') {
const filePath = await findFileByHash(dir, hash, ext);
if (!filePath) return null;
try {
return await fsPromises.readFile(filePath);
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
async function safeDeleteByHash(dir, hash) {
if (!dir || !hash) return;
try {
const files = await fsPromises.readdir(dir);
const matches = files.filter((f) => f === hash || f.startsWith(hash + '.'));
await Promise.all(matches.map((f) => fsPromises.unlink(join(dir, f))));
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
export class BetterSqlite3Executor {
/** @param {import('better-sqlite3').Database} db */
constructor(db) {
/**
* @param {import('better-sqlite3').Database} db
* @param {string|null} [storageDir=null]
*/
constructor(db, storageDir = null) {
this.db = db;
this.dialect = 'sqlite3';
this.storageDir = storageDir;
}
async writeFileContent(hash, buffer, mimetype = null) {
if (!this.storageDir || !hash) return;
const ext = extFromMimetype(mimetype);
await safeWrite(join(this.storageDir, 'content', hash + ext), buffer);
}
async readFileContent(hash, mimetype = null) {
if (!this.storageDir || !hash) return null;
const ext = extFromMimetype(mimetype);
return safeReadByHash(join(this.storageDir, 'content'), hash, ext);
}
async writeThumbAvif(hash, buffer) {
if (!this.storageDir || !hash) return;
await safeWrite(join(this.storageDir, 'thumbavif', hash + '.avif'), buffer);
}
async readThumbAvif(hash) {
if (!this.storageDir || !hash) return null;
return safeReadByHash(join(this.storageDir, 'thumbavif'), hash, '.avif');
}
async writePicFull(hash, buffer) {
if (!this.storageDir || !hash) return;
await safeWrite(join(this.storageDir, 'fullbinary', hash + '.avif'), buffer);
}
async readPicFull(hash) {
if (!this.storageDir || !hash) return null;
return safeReadByHash(join(this.storageDir, 'fullbinary'), hash, '.avif');
}
async writePicThumb(hash, buffer) {
if (!this.storageDir || !hash) return;
await safeWrite(join(this.storageDir, 'thumbbinary', hash + '.avif'), buffer);
}
async readPicThumb(hash) {
if (!this.storageDir || !hash) return null;
return safeReadByHash(join(this.storageDir, 'thumbbinary'), hash, '.avif');
}
async deleteFile(hash) {
if (!this.storageDir || !hash) return;
await safeDeleteByHash(join(this.storageDir, 'content'), hash);
await safeDeleteByHash(join(this.storageDir, 'thumbavif'), hash);
}
async deletePic(hash) {
if (!this.storageDir || !hash) return;
await safeDeleteByHash(join(this.storageDir, 'fullbinary'), hash);
await safeDeleteByHash(join(this.storageDir, 'thumbbinary'), hash);
}
async purgeFiles() {
if (!this.storageDir) return;
for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary']) {
const dir = join(this.storageDir, sub);
try {
const files = await fsPromises.readdir(dir);
await Promise.all(files.map((f) => fsPromises.unlink(join(dir, f))));
} catch {
// ignore
}
}
}
/** @returns {Promise<import('../provider.js').QueryResult>} */
@@ -49,11 +222,47 @@ export class BetterSqlite3Executor {
}
}
function ensureDirectory(filename) {
if (typeof filename === 'string' && filename !== ':memory:' && !filename.startsWith(':memory:')) {
const dir = dirname(filename);
if (dir && dir !== '.') {
mkdirSync(dir, { recursive: true });
}
}
}
export class BetterSqlite3Provider extends BetterSqlite3Executor {
/**
* @param {import('better-sqlite3').Database} db An open, caller-owned database.
* @param {import('better-sqlite3').Database | string | { filename: string, storageDir?: string, filesDir?: string, [key: string]: any }} [dbOrConfig]
* An open better-sqlite3 Database, a file path (or ':memory:'), or a config object.
*/
constructor(db) {
constructor(dbOrConfig = ':memory:') {
let db = dbOrConfig;
let storageDir = null;
if (typeof dbOrConfig === 'string') {
ensureDirectory(dbOrConfig);
db = new Database(dbOrConfig);
storageDir = (dbOrConfig === ':memory:' || dbOrConfig.startsWith(':memory:'))
? mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-'))
: join(dirname(dbOrConfig), 'storage');
} else if (
dbOrConfig &&
typeof dbOrConfig === 'object' &&
typeof dbOrConfig.prepare !== 'function'
) {
const { filename, storageDir: customStorageDir, filesDir, ...options } = dbOrConfig;
if (filename) ensureDirectory(filename);
db = new Database(filename || ':memory:', options);
storageDir = customStorageDir || filesDir || (
(!filename || filename === ':memory:' || filename.startsWith(':memory:'))
? mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-'))
: join(dirname(filename), 'storage')
);
} else if (db && typeof db.prepare === 'function') {
storageDir = mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-'));
}
if (
!db ||
typeof db.prepare !== 'function' ||
@@ -61,22 +270,29 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
typeof db.pragma !== 'function'
) {
throw new TypeError(
'BetterSqlite3Provider requires an open better-sqlite3 Database',
'BetterSqlite3Provider requires an open better-sqlite3 Database or a filename string',
);
}
super(db);
this.dialect = 'sqlite3';
super(db, storageDir);
if (this.storageDir) {
for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary']) {
mkdirSync(join(this.storageDir, sub), { recursive: true });
}
}
this.pool = this;
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
}
#transactionTail = Promise.resolve();
#transactionDepth = 0;
/**
* Runs `fn(runner)` in an immediate write transaction. Transactions are
* serialized because better-sqlite3 has one synchronous connection.
* Nested calls use SAVEPOINTs.
*
* @template T
* @param {(runner: BetterSqlite3Executor) => Promise<T> | T} fn
@@ -87,15 +303,66 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
return Promise.reject(new TypeError('transaction requires a callback'));
}
const bindRunner = (executor) => {
const runner = async (sql, params) => executor.query(sql, params);
runner.query = runner;
runner.dialect = 'sqlite3';
runner.storageDir = this.storageDir;
runner.writeFileContent = executor.writeFileContent.bind(executor);
runner.readFileContent = executor.readFileContent.bind(executor);
runner.writeThumbAvif = executor.writeThumbAvif.bind(executor);
runner.readThumbAvif = executor.readThumbAvif.bind(executor);
runner.writePicFull = executor.writePicFull.bind(executor);
runner.readPicFull = executor.readPicFull.bind(executor);
runner.writePicThumb = executor.writePicThumb.bind(executor);
runner.readPicThumb = executor.readPicThumb.bind(executor);
runner.deleteFile = executor.deleteFile.bind(executor);
runner.deletePic = executor.deletePic.bind(executor);
runner.purgeFiles = executor.purgeFiles.bind(executor);
return runner;
};
if (this.#transactionDepth > 0 || this.db.inTransaction) {
const savepoint = `sp_${++this.#transactionDepth}`;
this.db.exec(`SAVEPOINT ${savepoint}`);
const executor = new BetterSqlite3Executor(this.db, this.storageDir);
const runner = bindRunner(executor);
return Promise.resolve()
.then(() => fn(runner, executor))
.then(
(result) => {
this.db.exec(`RELEASE SAVEPOINT ${savepoint}`);
this.#transactionDepth--;
return result;
},
(err) => {
try {
this.db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
this.db.exec(`RELEASE SAVEPOINT ${savepoint}`);
} catch {
// ignore
}
this.#transactionDepth--;
throw err;
},
);
}
const run = async () => {
this.#transactionDepth = 1;
this.db.exec('BEGIN IMMEDIATE');
try {
const result = await fn(new BetterSqlite3Executor(this.db));
const executor = new BetterSqlite3Executor(this.db, this.storageDir);
const runner = bindRunner(executor);
const result = await fn(runner, executor);
this.db.exec('COMMIT');
return result;
} catch (err) {
if (this.db.inTransaction) this.db.exec('ROLLBACK');
throw err;
} finally {
this.#transactionDepth = 0;
}
};
@@ -108,18 +375,67 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
throw new Error('Stored procedures are not supported by better-sqlite3');
}
/**
* Initializes the native SQLite schema.
* @returns {Promise<void>}
*/
async initialize() {
// SQLite migrations are provided by the SQLite schema layer.
for (const script of SCHEMA_SCRIPTS) {
if (script && script.trim()) {
this.db.exec(script);
}
}
}
/**
* Purges all tables, views, and triggers from the SQLite database.
* @returns {Promise<void>}
*/
async purge() {
// SQLite schema teardown is provided by the SQLite schema layer.
await this.purgeFiles();
this.db.pragma('foreign_keys = OFF');
try {
const tables = this.db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
)
.all()
.map((r) => r.name);
const views = this.db
.prepare("SELECT name FROM sqlite_master WHERE type = 'view'")
.all()
.map((r) => r.name);
const triggers = this.db
.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger'")
.all()
.map((r) => r.name);
for (const trigger of triggers) {
this.db.exec(`DROP TRIGGER IF EXISTS "${trigger}"`);
}
for (const view of views) {
this.db.exec(`DROP VIEW IF EXISTS "${view}"`);
}
for (const table of tables) {
this.db.exec(`DROP TABLE IF EXISTS "${table}"`);
}
} finally {
this.db.pragma('foreign_keys = ON');
}
}
/** Explicitly closes the caller-owned database. It is never closed implicitly. */
/** Explicitly closes the database. */
close() {
this.db.close();
}
isUniqueConstraintError(err) {
return isUniqueConstraintError(err);
}
isForeignKeyConstraintError(err) {
return isForeignKeyConstraintError(err);
}
}
export function isUniqueConstraintError(err) {
@@ -131,4 +447,7 @@ export function isUniqueConstraintError(err) {
export function isForeignKeyConstraintError(err) {
return err?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY';
}
}
export { BetterSqlite3Provider as SqliteProvider };
export { BetterSqlite3Executor as SqliteExecutor };

View File

@@ -5,10 +5,60 @@ import { ARTICLE_LIST_COLUMNS, ARTICLE_LIST_JOINS } from './article.js';
const queries = translateQueries(source, 'category');
export const {
CREATE, DELETE, GET_BY_PARENT_AND_NAME,
GET_NEXT_SIBLING_SORT, IS_ANCESTOR, LIST_CHILD_IDS,
MOVE, RESOLVE_CATEGORY_ACCESS, UPDATE_SORT,
GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS,
MOVE, UPDATE_SORT,
} = queries;
export const RESOLVE_CATEGORY_ACCESS = `
WITH RECURSIVE ancestors AS (
SELECT id, parent_id
FROM category
WHERE id = @categoryId
UNION ALL
SELECT c.id, c.parent_id
FROM category c
INNER JOIN ancestors a ON c.id = a.parent_id
),
root AS (
SELECT id AS rootCategoryId
FROM ancestors
WHERE parent_id IS NULL
LIMIT 1
)
SELECT
p.id AS categoryId,
r.rootCategoryId,
co.id AS companyId,
co.default_language_id AS languageId,
co.poscustomergroup_id AS posCustomerGroupId,
cu.id AS companyuserId
FROM category p
LEFT JOIN root r ON 1 = 1
LEFT JOIN company co ON co.root_category_id = r.rootCategoryId
LEFT JOIN companyuser cu
ON cu.company_id = co.id AND cu.google_auth_id = @googleAuthId
WHERE p.id = @categoryId
`;
export const IS_ANCESTOR = `
WITH RECURSIVE ancestors AS (
SELECT id, parent_id
FROM category
WHERE id = @parentId
UNION ALL
SELECT c.id, c.parent_id
FROM category c
INNER JOIN ancestors a ON c.id = a.parent_id
)
SELECT id
FROM ancestors
WHERE id = @id
`;
export const GET_CATEGORY_ARTICLES = `
SELECT ${ARTICLE_LIST_COLUMNS}
FROM categoryarticle ca

View File

@@ -5,18 +5,41 @@ const queries = translateQueries(source, 'delivery');
export const {
CREATE_ATTACHMENT, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST,
DELETE_MANIFEST, DELIVERY_TYPE, GET_DELIVERY_HEADER, GET_DELIVERY_LINES,
GET_NEXT_ATTACHMENT_SORT, LIST_DELIVERIES, LIST_DELIVERY_FILES,
GET_NEXT_ATTACHMENT_SORT, LIST_DELIVERIES,
UPDATE_MANIFEST,
} = queries;
export const CREATE_FILE = `
INSERT INTO "file" (
hash, mimetype, name, size, created, modified, content,
thumbavif, thumbx, thumby, meta
hash, mimetype, name, size, created, modified,
thumbx, thumby, meta
)
VALUES (
@hash, @mimetype, @name, @size, @created, @modified, @content,
@thumbavif, @thumbx, @thumby, @meta
@hash, @mimetype, @name, @size, @created, @modified,
@thumbx, @thumby, @meta
)
RETURNING id
`;
export const LIST_DELIVERY_FILES = `
SELECT
ma.id,
ma.manifest_id AS manifestId,
ma.file_id AS fileId,
ma.sort,
f.hash,
f.mimetype,
f.name,
f.size,
f.created,
f.modified,
f.thumbx,
f.thumby,
f.meta
FROM manifestattachment ma
INNER JOIN "file" f ON f.id = ma.file_id
INNER JOIN manifest m ON m.id = ma.manifest_id
WHERE m.company_id = @companyId
AND m."type" = @type
ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC
`;

View File

@@ -4,18 +4,68 @@ import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'manifestattachment');
export const {
CHECK_MANIFEST_ACCESS, CREATE_ATTACHMENT, DELETE_ATTACHMENT,
DELETE_FILE, FIND_ATTACHMENT, GET_BY_ID, GET_NEXT_SORT, LIST_BY_MANIFEST,
DELETE_FILE, GET_NEXT_SORT,
LIST_IDS_BY_MANIFEST, SET_TEMP_SORTS, UPDATE_SORT,
} = queries;
export const FIND_ATTACHMENT = `
SELECT ma.id, ma.file_id AS fileId, f.hash
FROM manifestattachment ma
INNER JOIN "file" f ON f.id = ma.file_id
WHERE ma.manifest_id = @manifestId
AND (ma.id = @id OR ma.file_id = @id)
`;
export const CREATE_FILE = `
INSERT INTO "file" (
hash, mimetype, name, size, created, modified, content,
thumbavif, thumbx, thumby, meta
hash, mimetype, name, size, created, modified,
thumbx, thumby, meta
)
VALUES (
@hash, @mimetype, @name, @size, @created, @modified, @content,
@thumbavif, @thumbx, @thumby, @meta
@hash, @mimetype, @name, @size, @created, @modified,
@thumbx, @thumby, @meta
)
RETURNING id
`;
export const LIST_BY_MANIFEST = `
SELECT
ma.id,
ma.manifest_id AS manifestId,
ma.file_id AS fileId,
ma.sort,
f.hash,
f.mimetype,
f.name,
f.size,
f.created,
f.modified,
f.thumbx,
f.thumby,
f.meta
FROM manifestattachment ma
INNER JOIN "file" f ON f.id = ma.file_id
WHERE ma.manifest_id = @manifestId
ORDER BY ma.sort ASC, ma.id ASC
`;
export const GET_BY_ID = `
SELECT
ma.id,
ma.manifest_id AS manifestId,
ma.file_id AS fileId,
ma.sort,
f.hash,
f.mimetype,
f.name,
f.size,
f.created,
f.modified,
f.thumbx,
f.thumby,
f.meta
FROM manifestattachment ma
INNER JOIN "file" f ON f.id = ma.file_id
WHERE ma.manifest_id = @manifestId
AND (ma.id = @id OR ma.file_id = @id)
`;

View File

@@ -3,9 +3,31 @@ import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'pic');
export const {
CREATE, GET_CATEGORY_ID_BY_HASH, GET_FULL_BY_HASH, GET_THUMB_BY_HASH,
GET_CATEGORY_ID_BY_HASH,
} = queries;
export const CREATE = `
INSERT INTO pic (
w, h, size, hash, thumbw, thumbh, thumbhash, source
)
VALUES (
@w, @h, @size, @hash, @thumbw, @thumbh, @thumbhash, @source
)
RETURNING id
`;
export const GET_FULL_BY_HASH = `
SELECT w, h, size, hash
FROM pic
WHERE hash = @hash
`;
export const GET_THUMB_BY_HASH = `
SELECT thumbw AS w, thumbh AS h, hash
FROM pic
WHERE hash = @hash
`;
export const DELETE_BY_ARTICLE_AND_HASH = `
DELETE FROM pic
WHERE hash = @fullHash

View File

@@ -42,9 +42,7 @@ CREATE TABLE IF NOT EXISTS pic (
thumbw INTEGER NOT NULL,
thumbh INTEGER NOT NULL,
thumbhash TEXT NOT NULL,
source TEXT NOT NULL,
fullbinary BLOB NOT NULL,
thumbbinary BLOB NOT NULL
source TEXT NOT NULL
);
`;
@@ -542,8 +540,6 @@ CREATE TABLE IF NOT EXISTS "file" (
size INTEGER NOT NULL,
created TEXT NOT NULL DEFAULT (${UTC_NOW}),
modified TEXT NOT NULL DEFAULT (${UTC_NOW}),
content BLOB NOT NULL,
thumbavif BLOB,
thumbx INTEGER,
thumby INTEGER,
meta TEXT

View File

@@ -1,12 +1,25 @@
import { ADD as ADD_ARTICLEPIC, GET_NEXT_SORT } from '../../providers/mssql/queries/articlepic.js';
import { CREATE as CREATE_PIC } from '../../providers/mssql/queries/pic.js';
import {
ADD as MSSQL_ADD_ARTICLEPIC,
GET_NEXT_SORT as MSSQL_GET_NEXT_SORT,
} from '../../providers/mssql/queries/articlepic.js';
import {
ADD as SQLITE_ADD_ARTICLEPIC,
GET_NEXT_SORT as SQLITE_GET_NEXT_SORT,
} from '../../providers/sqlite3/queries/articlepic.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 request from '../request.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function addImage(pool) {
const resolve = resolveArticleBySku(pool);
export default function addImage(provider) {
const resolve = resolveArticleBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT;
const createPicSql = isSqlite ? SQLITE_CREATE_PIC : MSSQL_CREATE_PIC;
const addArticlePicSql = isSqlite ? SQLITE_ADD_ARTICLEPIC : MSSQL_ADD_ARTICLEPIC;
/**
* Adds an image to an article by its SKU, processing it into AVIF variants.
@@ -14,50 +27,53 @@ export default function addImage(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Buffer} image - The source image buffer.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>} The added image info.
*
* @example
* const result = await store.article.addImage(42, 'SKU-1', imageBuffer);
* // => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash }
*/
return async (googleAuthId, sku, image) => {
const { articleId } = await resolve(googleAuthId, sku);
return async (googleAuthId, sku, image, tx) => {
const runAdd = async (runner) => {
const { articleId } = await resolve(googleAuthId, sku, runner);
const processed = await processAvifImage(image);
const processed = await processAvifImage(image);
const tx = pool.transaction();
await tx.begin();
try {
const sortResult = await request(pool, tx)
.input('articleId', articleId)
.query(GET_NEXT_SORT);
const sort = sortResult.recordset[0].sort;
const sortResult = await runner.query(getSortSql, { articleId });
const sort = sortResult.rows[0].sort;
const fullHash = newFullHash();
const picResult = await request(pool, tx)
.input('w', processed.full.w)
.input('h', processed.full.h)
.input('size', processed.full.size)
.input('hash', fullHash)
.input('thumbw', processed.thumb.w)
.input('thumbh', processed.thumb.h)
.input('thumbhash', processed.thumb.hash)
.input('source', '')
.input('fullbinary', processed.full.buffer)
.input('thumbbinary', processed.thumb.buffer)
.query(CREATE_PIC);
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 picId = picResult.recordset[0].id;
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,
});
await request(pool, tx)
.input('articleId', articleId)
.input('picId', picId)
.input('sort', sort)
.query(ADD_ARTICLEPIC);
const picId = picResult.rows[0]?.id ?? picResult.insertId;
await tx.commit();
await runner.query(addArticlePicSql, {
articleId,
picId,
sort,
});
return {
picId,
@@ -74,13 +90,15 @@ export default function addImage(pool) {
fullHash,
thumbHash: processed.thumb.hash,
};
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runAdd(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runAdd);
}
return runAdd(makeRunner(provider));
};
}

View File

@@ -1,17 +1,27 @@
import { CREATE } from '../../providers/mssql/queries/article.js';
import { ADD as ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
import { CREATE as CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { CREATE as MSSQL_CREATE_ARTICLE } from '../../providers/mssql/queries/article.js';
import { CREATE as SQLITE_CREATE_ARTICLE } from '../../providers/sqlite3/queries/article.js';
import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js';
import { CREATE as SQLITE_CREATE_ARTICLETEXT } from '../../providers/sqlite3/queries/articletext.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import ensureDefaultUnit from '../unit/ensureDefault.js';
import nextSku from '../numbers/nextSku.js';
import request from '../request.js';
import makeRunner from '../runner.js';
import slugify from '../slugify.js';
export default function createUnderParent(pool) {
const access = resolveCategoryAccess(pool);
const ensureUnit = ensureDefaultUnit(pool);
const allocateSku = nextSku(pool);
export default function createUnderParent(provider) {
const access = resolveCategoryAccess(provider);
const ensureUnit = ensureDefaultUnit(provider);
const allocateSku = nextSku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const createArticleSql = isSqlite ? SQLITE_CREATE_ARTICLE : MSSQL_CREATE_ARTICLE;
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Creates a new article under a given parent category, allocating a SKU and
@@ -21,54 +31,51 @@ export default function createUnderParent(pool) {
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category.
* @param {string} input.name - The article name.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, parentId: number, name: string, sku: string}>} The created article.
*
* @example
* const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' });
* // => { id, parentId, name, sku }
*/
return async ({ googleAuthId, parentId, name }) => {
const { companyId, languageId, companyuserId } = await access(
googleAuthId,
parentId
);
return async ({ googleAuthId, parentId, name }, tx) => {
const runCreate = async (runner) => {
const { companyId, languageId, companyuserId } = await access(
googleAuthId,
parentId,
runner,
);
const tx = pool.transaction();
await tx.begin();
try {
const unitId = await ensureUnit(tx, languageId, companyId);
const sku = await allocateSku(companyId, tx);
const unitId = await ensureUnit(runner, languageId, companyId);
const sku = await allocateSku(companyId, runner);
const slug = slugify(name);
const article = await request(pool, tx)
.input('sku', sku)
.input('unitId', unitId)
.input('unitamount', 1)
.input('weight', 0)
.query(CREATE);
const article = await runner.query(createArticleSql, {
sku,
unitId,
unitamount: 1,
weight: 0,
});
const id = article.recordset[0].id;
const id = article.rows[0]?.id ?? article.insertId;
await request(pool, tx)
.input('categoryId', parentId)
.input('articleId', id)
.query(ADD_CATEGORYARTICLE);
await runner.query(addCategoryArticleSql, {
categoryId: parentId,
articleId: id,
});
await request(pool, tx)
.input('articleId', id)
.input('languageId', languageId)
.input('name', name)
.input('slug', slug)
.query(CREATE_ARTICLETEXT);
await runner.query(createArticleTextSql, {
articleId: id,
languageId,
name,
slug,
});
await request(pool, tx)
.input('articleId', id)
.input('companyuserId', companyuserId)
.input('text', `Artikel "${name}" angelegt`)
.query(INSERT_HISTORY);
await tx.commit();
await runner.query(insertHistorySql, {
articleId: id,
companyuserId,
text: `Artikel "${name}" angelegt`,
});
return {
id,
@@ -76,13 +83,15 @@ export default function createUnderParent(pool) {
name,
sku,
};
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runCreate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
};
}

View File

@@ -1,83 +1,60 @@
import {
DELETE_ARTICLELIST,
DELETE_BY_ID,
DELETE_OPTIONSLIST,
DELETE_PICS_BY_ARTICLE,
GET_CHILD_COUNT,
GET_LIST_IDS,
} from '../../providers/mssql/queries/articleDelete.js';
import * as mssqlQueries from '../../providers/mssql/queries/articleDelete.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articleDelete.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import request from '../request.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const resolve = resolveArticleBySku(pool);
export default function del(provider) {
const resolve = resolveArticleBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
/**
* Deletes an article by its SKU, refusing when it still has children.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the article has children or is not found.
*
* @example
* await store.article.delete(42, 'SKU-1');
*/
return async (googleAuthId, sku) => {
const { articleId } = await resolve(googleAuthId, sku);
return async (googleAuthId, sku, tx) => {
const runDelete = async (runner) => {
const { articleId } = await resolve(googleAuthId, sku, runner);
const childCount = await pool
.request()
.input('articleId', articleId)
.query(GET_CHILD_COUNT);
const childCount = await runner.query(queries.GET_CHILD_COUNT, { articleId });
if (childCount.rows[0].count > 0) {
throw new Error('Article has children');
}
if (childCount.recordset[0].count > 0) {
throw new Error('Article has children');
}
const listIds = await runner.query(queries.GET_LIST_IDS, { articleId });
const { articlelistId, optionslistId } = listIds.rows[0] ?? {};
const listIds = await pool
.request()
.input('articleId', articleId)
.query(GET_LIST_IDS);
await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId });
const { articlelistId, optionslistId } = listIds.recordset[0] ?? {};
const tx = pool.transaction();
await tx.begin();
try {
await request(pool, tx)
.input('articleId', articleId)
.query(DELETE_PICS_BY_ARTICLE);
const deleted = await request(pool, tx)
.input('articleId', articleId)
.query(DELETE_BY_ID);
if (!deleted.rowsAffected[0]) {
const deleted = await runner.query(queries.DELETE_BY_ID, { articleId });
if (!deleted.rowsAffected) {
throw new Error('Article not found');
}
if (articlelistId != null) {
await request(pool, tx)
.input('id', articlelistId)
.query(DELETE_ARTICLELIST);
await runner.query(queries.DELETE_ARTICLELIST, { id: articlelistId });
}
if (optionslistId != null) {
await request(pool, tx)
.input('id', optionslistId)
.query(DELETE_OPTIONSLIST);
await runner.query(queries.DELETE_OPTIONSLIST, { id: optionslistId });
}
};
await tx.commit();
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
if (tx) {
return runDelete(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runDelete);
}
return runDelete(makeRunner(provider));
};
}

View File

@@ -1,8 +1,11 @@
import { DELETE_BY_ARTICLE_AND_HASH } from '../../providers/mssql/queries/pic.js';
import { DELETE_BY_ARTICLE_AND_HASH as MSSQL_QUERY } from '../../providers/mssql/queries/pic.js';
import { DELETE_BY_ARTICLE_AND_HASH as SQLITE_QUERY } from '../../providers/sqlite3/queries/pic.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function deleteImage(pool) {
const resolve = resolveArticleBySku(pool);
export default function deleteImage(provider) {
const resolve = resolveArticleBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY;
/**
* Deletes an image from an article by its SKU and full image hash.
@@ -10,23 +13,29 @@ export default function deleteImage(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {string} fullHash - The full image hash to delete.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
* @throws {Error} If the image is not found.
*
* @example
* await store.article.deleteImage(42, 'SKU-1', 'abc123');
*/
return async (googleAuthId, sku, fullHash) => {
const { articleId } = await resolve(googleAuthId, sku);
return async (googleAuthId, sku, fullHash, tx) => {
const { articleId } = await resolve(googleAuthId, sku, tx);
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { articleId, fullHash });
const result = await pool
.request()
.input('articleId', articleId)
.input('fullHash', fullHash)
.query(DELETE_BY_ARTICLE_AND_HASH);
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Image not found');
}
if (provider?.dialect === 'sqlite3') {
if (runner.deletePic) {
await runner.deletePic(fullHash);
} else if (provider?.deletePic) {
await provider.deletePic(fullHash);
}
}
};
}

View File

@@ -1,4 +1,6 @@
import { GET_ARTICLE_BY_SKU_FULL } from '../../providers/mssql/queries/article.js';
import { GET_ARTICLE_BY_SKU_FULL as MSSQL_QUERY } from '../../providers/mssql/queries/article.js';
import { GET_ARTICLE_BY_SKU_FULL as SQLITE_QUERY } from '../../providers/sqlite3/queries/article.js';
import makeRunner from '../runner.js';
function parseJsonArray(raw) {
if (!raw) return [];
@@ -6,13 +8,16 @@ function parseJsonArray(raw) {
return Array.isArray(arr) ? arr : [];
}
export default function getBySku(pool) {
export default function getBySku(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY;
/**
* Returns the full detail of an article by its SKU, including unit, brand,
* categories, photos, EANs and prices.
*
* @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.
* @throws {Error} If the article is not found or the user is not authorized.
*
@@ -20,16 +25,13 @@ export default function getBySku(pool) {
* const article = await store.article.getBySku(42, 'SKU-1');
* // => { article: { id, sku, name, unit, brand, categoryIds, photos, eans, prices, ... } }
*/
return async (googleAuthId, sku) => {
return async (googleAuthId, sku, tx) => {
const t0 = performance.now();
const result = await pool
.request()
.input('sku', sku)
.input('googleAuthId', googleAuthId)
.query(GET_ARTICLE_BY_SKU_FULL);
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { sku, googleAuthId });
console.log(`[getBySku] single query ${(performance.now() - t0).toFixed(1)}ms`);
const row = result.recordset[0];
const row = result.rows[0];
if (!row) {
throw new Error('Article not found');
}
@@ -43,6 +45,7 @@ export default function getBySku(pool) {
throw new Error('Not authorized');
}
const a = typeof row.articleJson === 'string' ? JSON.parse(row.articleJson) : row.articleJson;
return {

View File

@@ -1,29 +1,30 @@
import { LIST_BY_ARTICLE_ID } from '../../providers/mssql/queries/articlehistory.js';
import { LIST_BY_ARTICLE_ID as MSSQL_QUERY } from '../../providers/mssql/queries/articlehistory.js';
import { LIST_BY_ARTICLE_ID as SQLITE_QUERY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function getHistoryBySku(pool) {
const resolve = resolveArticleBySku(pool);
export default function getHistoryBySku(provider) {
const resolve = resolveArticleBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY;
/**
* Returns the change history of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>} The article history entries.
*
* @example
* const history = await store.article.getHistoryBySku(42, 'SKU-1');
* // => [{ id, companyuserId, date, text }]
*/
return async (googleAuthId, sku) => {
const { articleId } = await resolve(googleAuthId, sku);
return async (googleAuthId, sku, tx) => {
const { articleId } = await resolve(googleAuthId, sku, tx);
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { articleId });
const result = await pool
.request()
.input('articleId', articleId)
.query(LIST_BY_ARTICLE_ID);
return result.recordset.map((row) => ({
return result.rows.map((row) => ({
id: row.id,
companyuserId: row.companyuserId ?? null,
date: row.date,
@@ -31,3 +32,4 @@ export default function getHistoryBySku(pool) {
}));
};
}

View File

@@ -1,24 +1,35 @@
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js';
import { CREATE as SQLITE_CREATE_ARTICLETEXT } from '../../providers/sqlite3/queries/articletext.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js';
import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js';
import {
ADD_EAN,
CREATE as CREATE_ARTICLE,
EAN_EXISTS,
GET_ID_BY_SKU,
SET_FATHER_SORT,
SET_IS_FATHER,
UPDATE_BRAND,
UPDATE_FATHER_BY_SKU,
} from '../../providers/mssql/queries/article.js';
import { ADD as ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
import { CREATE as CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js';
import { CREATE as CREATE_BRAND, GET_BY_COMPANY_AND_NAME } from '../../providers/mssql/queries/brand.js';
import { CREATE as CREATE_PRICE } from '../../providers/mssql/queries/price.js';
import { CREATE as CREATE_TAX, GET_BY_RATE } from '../../providers/mssql/queries/tax.js';
import request from '../request.js';
CREATE as MSSQL_CREATE_BRAND,
GET_BY_COMPANY_AND_NAME as MSSQL_GET_BRAND_BY_NAME,
} from '../../providers/mssql/queries/brand.js';
import {
CREATE as SQLITE_CREATE_BRAND,
GET_BY_COMPANY_AND_NAME as SQLITE_GET_BRAND_BY_NAME,
} from '../../providers/sqlite3/queries/brand.js';
import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js';
import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js';
import {
CREATE as MSSQL_CREATE_TAX,
GET_BY_RATE as MSSQL_GET_TAX_BY_RATE,
} from '../../providers/mssql/queries/tax.js';
import {
CREATE as SQLITE_CREATE_TAX,
GET_BY_RATE as SQLITE_GET_TAX_BY_RATE,
} from '../../providers/sqlite3/queries/tax.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import ensureByPath from '../category/ensureByPath.js';
import ensureDefaultUnit from '../unit/ensureDefault.js';
import makeRunner from '../runner.js';
import slugify from '../slugify.js';
// Normalizes a numeric value that may arrive as a string using German decimal
@@ -34,29 +45,41 @@ function toNumber(value) {
return Number.isFinite(n) ? n : null;
}
export default function importRows(pool) {
const companyAccess = resolveSelectedCompanyAccess(pool);
const ensureUnit = ensureDefaultUnit(pool);
const ensureCategoryByPath = ensureByPath(pool);
export default function importRows(provider) {
const companyAccess = resolveSelectedCompanyAccess(provider);
const ensureUnit = ensureDefaultUnit(provider);
const ensureCategoryByPath = ensureByPath(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT;
const getBrandByNameSql = isSqlite ? SQLITE_GET_BRAND_BY_NAME : MSSQL_GET_BRAND_BY_NAME;
const createBrandSql = isSqlite ? SQLITE_CREATE_BRAND : MSSQL_CREATE_BRAND;
const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE;
const getTaxByRateSql = isSqlite ? SQLITE_GET_TAX_BY_RATE : MSSQL_GET_TAX_BY_RATE;
const createTaxSql = isSqlite ? SQLITE_CREATE_TAX : MSSQL_CREATE_TAX;
// Find or create a brand for a company by name (within the current tx).
async function ensureBrand(tx, companyId, name) {
const existing = await request(pool, tx)
.input('companyId', companyId)
.input('name', name)
.query(GET_BY_COMPANY_AND_NAME);
async function ensureBrand(runner, companyId, name) {
const existing = await runner.query(getBrandByNameSql, {
companyId,
name,
});
if (existing.recordset[0]) {
return existing.recordset[0].id;
if (existing.rows[0]) {
return existing.rows[0].id;
}
const created = await request(pool, tx)
.input('companyId', companyId)
.input('name', name)
.input('gsprpinfo', null)
.query(CREATE_BRAND);
const created = await runner.query(createBrandSql, {
companyId,
name,
gsprpinfo: null,
});
return created.recordset[0].id;
return created.rows[0]?.id ?? created.insertId;
}
/**
@@ -82,6 +105,7 @@ export default function importRows(pool) {
* @param {Array<string>|string} [input.rows[].eans] - The EANs to set.
* @param {string} [input.rows[].fatherSku] - The father article SKU for a V/K compound link.
* @param {number|string} [input.rows[].deposit] - The deposit (Pfand) value; currently ignored.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>} The import outcome.
*
* @example
@@ -94,14 +118,15 @@ export default function importRows(pool) {
* });
* // => { imported: [...], skipped: [...], errors: [...] }
*/
return async ({ googleAuthId, rows }) => {
return async ({ googleAuthId, rows }, tx) => {
const baseRunner = makeRunner(provider, tx);
const {
companyId,
languageId,
rootCategoryId,
posCustomerGroupId,
companyuserId,
} = await companyAccess(googleAuthId);
} = await companyAccess(googleAuthId, baseRunner);
if (!Array.isArray(rows)) {
throw new Error('Invalid rows');
@@ -112,6 +137,16 @@ export default function importRows(pool) {
const errors = [];
const usedEans = new Set();
const runInTx = async (fn) => {
if (tx) {
return fn(baseRunner);
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(fn);
}
return fn(baseRunner);
};
// Pass 1: create articles (skip existing SKUs).
for (const row of rows) {
const sku = String(row?.sku ?? '').trim();
@@ -122,123 +157,113 @@ export default function importRows(pool) {
continue;
}
const existing = await pool.request().input('sku', sku).query(GET_ID_BY_SKU);
if (existing.recordset[0]) {
const existing = await baseRunner.query(articleQueries.GET_ID_BY_SKU, { sku });
if (existing.rows[0]) {
skipped.push({ sku, name, reason: 'duplicate sku' });
continue;
}
let tx;
try {
tx = pool.transaction();
await tx.begin();
await runInTx(async (runner) => {
const unitId = await ensureUnit(runner, companyId, languageId);
const slug = slugify(name);
const unitId = await ensureUnit(tx, companyId, languageId);
const slug = slugify(name);
const article = await runner.query(articleQueries.CREATE, {
sku,
unitId,
unitamount: 1,
weight: 0,
});
const article = await request(pool, tx)
.input('sku', sku)
.input('unitId', unitId)
.input('unitamount', 1)
.input('weight', 0)
.query(CREATE_ARTICLE);
const articleId = article.rows[0]?.id ?? article.insertId;
const articleId = article.recordset[0].id;
// Resolve the parent category id (explicit id or breadcrumb path).
let parentId = row.parentId;
if (parentId == null && row.parentCategory) {
parentId = await ensureCategoryByPath(
{ googleAuthId, parentId: rootCategoryId, path: row.parentCategory },
tx,
);
}
if (parentId == null) {
throw new Error('Parent category required');
}
await request(pool, tx)
.input('categoryId', parentId)
.input('articleId', articleId)
.query(ADD_CATEGORYARTICLE);
await request(pool, tx)
.input('articleId', articleId)
.input('languageId', languageId)
.input('name', name)
.input('slug', slug)
.query(CREATE_ARTICLETEXT);
await request(pool, tx)
.input('articleId', articleId)
.input('companyuserId', companyuserId)
.input('text', `Artikel "${name}" importiert`)
.query(INSERT_HISTORY);
// Brand (upsert by name).
const brandName = row.brand != null ? String(row.brand).trim() : '';
if (brandName) {
const brandId = await ensureBrand(tx, companyId, brandName);
await request(pool, tx)
.input('articleId', articleId)
.input('brandId', brandId)
.query(UPDATE_BRAND);
}
// POS price (net/tax).
const net = toNumber(row.net);
const taxRate = toNumber(row.tax) ?? 0;
if (net != null) {
const taxResult = await request(pool, tx)
.input('rate', taxRate)
.query(GET_BY_RATE);
let taxId = taxResult.recordset[0]?.id;
if (!taxId) {
const created = await request(pool, tx)
.input('rate', taxRate)
.query(CREATE_TAX);
taxId = created.recordset[0].id;
// Resolve the parent category id (explicit id or breadcrumb path).
let parentId = row.parentId;
if (parentId == null && row.parentCategory) {
parentId = await ensureCategoryByPath(
{ googleAuthId, parentId: rootCategoryId, path: row.parentCategory },
runner,
);
}
if (parentId == null) {
throw new Error('Parent category required');
}
await request(pool, tx)
.input('taxId', taxId)
.input('articleId', articleId)
.input('minmultiplier', 1)
.input('customerGroupId', posCustomerGroupId)
.input('net', net)
.query(CREATE_PRICE);
}
// EANs (array, or a single value; skip duplicates within the import).
const rawEans = row.eans;
const eans = Array.isArray(rawEans)
? rawEans
: rawEans == null || rawEans === ''
? []
: [rawEans];
for (const ean of eans) {
const clean = String(ean ?? '').trim();
if (!clean || usedEans.has(clean)) continue;
const used = await request(pool, tx)
.input('ean', clean)
.query(EAN_EXISTS);
if (used.recordset[0]) continue;
await request(pool, tx)
.input('articleId', articleId)
.input('ean', clean)
.query(ADD_EAN);
usedEans.add(clean);
}
await runner.query(addCategoryArticleSql, {
categoryId: parentId,
articleId,
});
await tx.commit();
imported.push({ id: articleId, sku, name });
await runner.query(createArticleTextSql, {
articleId,
languageId,
name,
slug,
});
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Artikel "${name}" importiert`,
});
// Brand (upsert by name).
const brandName = row.brand != null ? String(row.brand).trim() : '';
if (brandName) {
const brandId = await ensureBrand(runner, companyId, brandName);
await runner.query(articleQueries.UPDATE_BRAND, {
articleId,
brandId,
});
}
// POS price (net/tax).
const net = toNumber(row.net);
const taxRate = toNumber(row.tax) ?? 0;
if (net != null) {
const taxResult = await runner.query(getTaxByRateSql, {
rate: taxRate,
});
let taxId = taxResult.rows[0]?.id;
if (!taxId) {
const created = await runner.query(createTaxSql, {
rate: taxRate,
});
taxId = created.rows[0]?.id ?? created.insertId;
}
await runner.query(createPriceSql, {
taxId,
articleId,
minmultiplier: 1,
customerGroupId: posCustomerGroupId,
net,
});
}
// EANs (array, or a single value; skip duplicates within the import).
const rawEans = row.eans;
const eans = Array.isArray(rawEans)
? rawEans
: rawEans == null || rawEans === ''
? []
: [rawEans];
for (const ean of eans) {
const clean = String(ean ?? '').trim();
if (!clean || usedEans.has(clean)) continue;
const used = await runner.query(articleQueries.EAN_EXISTS, {
ean: clean,
});
if (used.rows[0]) continue;
await runner.query(articleQueries.ADD_EAN, {
articleId,
ean: clean,
});
usedEans.add(clean);
}
imported.push({ id: articleId, sku, name });
});
} catch (err) {
if (tx) {
try {
await tx.rollback();
} catch {
// ignore
}
}
errors.push({ sku, name, error: err.message ?? String(err) });
}
}
@@ -250,50 +275,35 @@ export default function importRows(pool) {
row?.fatherSku != null ? String(row.fatherSku).trim() : '';
if (!sku || !fatherSku) continue;
let tx;
try {
tx = pool.transaction();
await tx.begin();
const child = await request(pool, tx)
.input('sku', sku)
.query(GET_ID_BY_SKU);
const childId = child.recordset[0]?.articleId;
if (childId) {
const father = await request(pool, tx)
.input('sku', fatherSku)
.query(GET_ID_BY_SKU);
const fatherId = father.recordset[0]?.articleId;
if (fatherId && fatherId !== childId) {
await request(pool, tx)
.input('sku', sku)
.input('fatherArticleId', fatherId)
.query(UPDATE_FATHER_BY_SKU);
await request(pool, tx)
.input('articleId', fatherId)
.query(SET_IS_FATHER);
const childCount = await request(pool, tx)
.input('articleId', fatherId)
.query(GET_CHILD_COUNT);
const count = childCount.recordset[0].count;
// The child's sort is its 0-based position among the father's children.
await request(pool, tx)
.input('articleId', childId)
.input('fatherArticleId', fatherId)
.input('fathersort', count - 1)
.query(SET_FATHER_SORT);
await runInTx(async (runner) => {
const child = await runner.query(articleQueries.GET_ID_BY_SKU, { sku });
const childId = child.rows[0]?.articleId;
if (childId) {
const father = await runner.query(articleQueries.GET_ID_BY_SKU, { sku: fatherSku });
const fatherId = father.rows[0]?.articleId;
if (fatherId && fatherId !== childId) {
await runner.query(articleQueries.UPDATE_FATHER_BY_SKU, {
sku,
fatherArticleId: fatherId,
});
await runner.query(articleQueries.SET_IS_FATHER, {
articleId: fatherId,
});
const childCount = await runner.query(getChildCountSql, {
articleId: fatherId,
});
const count = childCount.rows[0].count;
// The child's sort is its 0-based position among the father's children.
await runner.query(articleQueries.SET_FATHER_SORT, {
articleId: childId,
fatherArticleId: fatherId,
fathersort: count - 1,
});
}
}
}
await tx.commit();
});
} catch {
if (tx) {
try {
await tx.rollback();
} catch {
// ignore
}
}
// Non-fatal: article was already imported in pass 1.
}
}

View File

@@ -1,6 +1,8 @@
import { GET_ARTICLE_EXPORT, GET_CATEGORY_TREE_FLAT } from '../../providers/mssql/queries/article.js';
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import makeRunner from '../runner.js';
const ROOT_CATEGORY_NAME = 'Home';
@@ -10,9 +12,11 @@ function parseJsonArray(raw) {
return Array.isArray(arr) ? arr : [];
}
export default function listExport(pool) {
const companyAccess = resolveSelectedCompanyAccess(pool);
const categoryAccess = resolveCategoryAccess(pool);
export default function listExport(provider) {
const companyAccess = resolveSelectedCompanyAccess(provider);
const categoryAccess = resolveCategoryAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteArticle : mssqlArticle;
/**
* Returns articles for export, optionally scoped to a category, including
@@ -20,6 +24,7 @@ export default function listExport(pool) {
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number|null} [categoryId] - Optional category id to scope the export to; defaults to the company root.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<object>>} The articles for export.
* @throws {Error} If the category is not reachable from the selected company.
*
@@ -27,16 +32,17 @@ export default function listExport(pool) {
* const articles = await store.article.listExport(42, null);
* // => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }]
*/
return async (googleAuthId, categoryId) => {
return async (googleAuthId, categoryId, tx) => {
const runner = makeRunner(provider, tx);
const { languageId, posCustomerGroupId, rootCategoryId } =
await companyAccess(googleAuthId);
await companyAccess(googleAuthId, runner);
// Default to the company's root category when no specific category is given.
let exportCategoryId = rootCategoryId;
if (categoryId != null) {
// The requested category must be reachable via the selected company's root.
const access = await categoryAccess(googleAuthId, categoryId);
const access = await categoryAccess(googleAuthId, categoryId, runner);
if (access.rootCategoryId !== rootCategoryId) {
throw new Error('Not authorized');
}
@@ -44,22 +50,20 @@ export default function listExport(pool) {
}
const [result, treeResult] = await Promise.all([
pool
.request()
.input('categoryId', exportCategoryId)
.input('languageId', languageId)
.input('posCustomerGroupId', posCustomerGroupId)
.query(GET_ARTICLE_EXPORT),
pool
.request()
.input('rootId', rootCategoryId)
.input('languageId', languageId)
.query(GET_CATEGORY_TREE_FLAT),
runner.query(queries.GET_ARTICLE_EXPORT, {
categoryId: exportCategoryId,
languageId,
posCustomerGroupId,
}),
runner.query(queries.GET_CATEGORY_TREE_FLAT, {
rootId: rootCategoryId,
languageId,
}),
]);
// id -> { id, parentId, name } for breadcrumb resolution.
const catById = new Map();
for (const node of treeResult.recordset) {
for (const node of treeResult.rows) {
catById.set(String(node.id), node);
}
@@ -67,10 +71,10 @@ export default function listExport(pool) {
const displayName = (node) =>
String(node.id) === String(rootCategoryId) ? ROOT_CATEGORY_NAME : (node.name ?? '').trim();
const breadcrumb = (categoryId) => {
const breadcrumb = (cId) => {
const crumbs = [];
const seen = new Set();
let id = categoryId;
let id = cId;
while (id != null && !seen.has(String(id))) {
seen.add(String(id));
const node = catById.get(String(id));
@@ -82,7 +86,7 @@ export default function listExport(pool) {
return crumbs.join('·');
};
return result.recordset.map((row) => ({
return result.rows.map((row) => ({
id: row.id,
sku: row.sku,
name: row.name,
@@ -102,3 +106,4 @@ export default function listExport(pool) {
}));
};
}

View File

@@ -1,29 +1,33 @@
import { SEARCH_ARTICLES } from '../../providers/mssql/queries/article.js';
import { SEARCH_ARTICLES as MSSQL_SEARCH_ARTICLES } from '../../providers/mssql/queries/article.js';
import { SEARCH_ARTICLES as SQLITE_SEARCH_ARTICLES } from '../../providers/sqlite3/queries/article.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function listSearchIndex(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function listSearchIndex(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_SEARCH_ARTICLES : MSSQL_SEARCH_ARTICLES;
/**
* Returns a lightweight search index of all articles for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>} The article search index.
*
* @example
* const articles = await store.article.listSearchIndex(42);
* // => [{ id, sku, name, eans, categoryIds }]
*/
return async (googleAuthId) => {
const { companyId, rootCategoryId, languageId } = await access(googleAuthId);
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { rootCategoryId, languageId } = await access(googleAuthId, runner);
const result = await pool
.request()
.input('rootCategoryId', rootCategoryId)
.input('languageId', languageId)
.query(SEARCH_ARTICLES);
const result = await runner.query(sql, {
rootCategoryId,
languageId,
});
return result.recordset.map((row) => ({
return result.rows.map((row) => ({
id: row.id,
sku: row.sku,
name: row.name,
@@ -34,3 +38,4 @@ export default function listSearchIndex(pool) {
}));
};
}

View File

@@ -1,13 +1,12 @@
import {
LIST_HASHES_BY_ARTICLE,
SET_TEMP_SORTS,
UPDATE_SORT_BY_HASH,
} from '../../providers/mssql/queries/articlepic.js';
import * as mssqlPic from '../../providers/mssql/queries/articlepic.js';
import * as sqlitePic from '../../providers/sqlite3/queries/articlepic.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import request from '../request.js';
import makeRunner from '../runner.js';
export default function orderImages(pool) {
const resolve = resolveArticleBySku(pool);
export default function orderImages(provider) {
const resolve = resolveArticleBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqlitePic : mssqlPic;
/**
* Reorders the images of an article by its SKU.
@@ -16,6 +15,7 @@ export default function orderImages(pool) {
* @param {string} sku - The article SKU.
* @param {object} input - The reorder input.
* @param {Array<string>} input.fullHashes - The full image hashes in their new order.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{fullHashes: Array<string>}>} The applied order.
* @throws {Error} If the order is invalid.
*
@@ -23,62 +23,53 @@ export default function orderImages(pool) {
* const result = await store.article.orderImages(42, 'SKU-1', { fullHashes: ['a', 'b'] });
* // => { fullHashes: ['a', 'b'] }
*/
return async (googleAuthId, sku, { fullHashes }) => {
return async (googleAuthId, sku, { fullHashes }, tx) => {
if (!Array.isArray(fullHashes)) {
throw new Error('Invalid image order');
}
const { articleId } = await resolve(googleAuthId, sku);
const runOrder = async (runner) => {
const { articleId } = await resolve(googleAuthId, sku, runner);
const current = await pool
.request()
.input('articleId', articleId)
.query(LIST_HASHES_BY_ARTICLE);
const current = await runner.query(queries.LIST_HASHES_BY_ARTICLE, { articleId });
const existing = new Set(current.rows.map((r) => r.fullHash));
const existing = new Set(current.recordset.map((r) => r.fullHash));
if (fullHashes.length !== existing.size) {
throw new Error('Invalid image order');
}
const seen = new Set();
for (const fullHash of fullHashes) {
if (!existing.has(fullHash) || seen.has(fullHash)) {
if (fullHashes.length !== existing.size) {
throw new Error('Invalid image order');
}
seen.add(fullHash);
}
const tx = pool.transaction();
await tx.begin();
const seen = new Set();
for (const fullHash of fullHashes) {
if (!existing.has(fullHash) || seen.has(fullHash)) {
throw new Error('Invalid image order');
}
seen.add(fullHash);
}
try {
await request(pool, tx)
.input('articleId', articleId)
.query(SET_TEMP_SORTS);
await runner.query(queries.SET_TEMP_SORTS, { articleId });
for (let sort = 0; sort < fullHashes.length; sort++) {
const update = await request(pool, tx)
.input('articleId', articleId)
.input('fullHash', fullHashes[sort])
.input('sort', sort)
.query(UPDATE_SORT_BY_HASH);
const update = await runner.query(queries.UPDATE_SORT_BY_HASH, {
articleId,
fullHash: fullHashes[sort],
sort,
});
if (!update.rowsAffected[0]) {
if (!update.rowsAffected) {
throw new Error('Invalid image order');
}
}
await tx.commit();
return { fullHashes };
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runOrder(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runOrder);
}
return runOrder(makeRunner(provider));
};
}

View File

@@ -1,10 +1,6 @@
import {
GET_CATEGORY_IDS_BY_ARTICLE_ID,
GET_DETAIL_BY_ARTICLE_ID,
GET_EANS_BY_ARTICLE_ID,
GET_PHOTOS_BY_ARTICLE_ID,
GET_PRICES_BY_ARTICLE_ID,
} from '../../providers/mssql/queries/articleDetail.js';
import * as mssqlDetail from '../../providers/mssql/queries/articleDetail.js';
import * as sqliteDetail from '../../providers/sqlite3/queries/articleDetail.js';
import makeRunner from '../runner.js';
function shapeArticleDetail(row, categoryIds, photos, eans, prices) {
return {
@@ -41,31 +37,32 @@ function shapeArticleDetail(row, categoryIds, photos, eans, prices) {
};
}
export default function queryBySku(pool) {
return async (articleId, languageId) => {
export default function queryBySku(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteDetail : mssqlDetail;
return async (articleId, languageId, tx) => {
const runner = makeRunner(provider, tx);
const [detail, categories, photos, eans, prices] = await Promise.all([
pool
.request()
.input('articleId', articleId)
.input('languageId', languageId)
.query(GET_DETAIL_BY_ARTICLE_ID),
pool.request().input('articleId', articleId).query(GET_CATEGORY_IDS_BY_ARTICLE_ID),
pool.request().input('articleId', articleId).query(GET_PHOTOS_BY_ARTICLE_ID),
pool.request().input('articleId', articleId).query(GET_EANS_BY_ARTICLE_ID),
pool.request().input('articleId', articleId).query(GET_PRICES_BY_ARTICLE_ID),
runner.query(queries.GET_DETAIL_BY_ARTICLE_ID, { articleId, languageId }),
runner.query(queries.GET_CATEGORY_IDS_BY_ARTICLE_ID, { articleId }),
runner.query(queries.GET_PHOTOS_BY_ARTICLE_ID, { articleId }),
runner.query(queries.GET_EANS_BY_ARTICLE_ID, { articleId }),
runner.query(queries.GET_PRICES_BY_ARTICLE_ID, { articleId }),
]);
const row = detail.recordset[0];
const row = detail.rows[0];
if (!row) {
return null;
}
return shapeArticleDetail(
row,
categories.recordset,
photos.recordset,
eans.recordset,
prices.recordset,
categories.rows,
photos.rows,
eans.rows,
prices.rows,
);
};
}

View File

@@ -1,14 +1,16 @@
import { RESOLVE_ARTICLE_ACCESS_BY_SKU } from '../../providers/mssql/queries/article.js';
import { RESOLVE_ARTICLE_ACCESS_BY_SKU as MSSQL_RESOLVE } from '../../providers/mssql/queries/article.js';
import { RESOLVE_ARTICLE_ACCESS_BY_SKU as SQLITE_RESOLVE } from '../../providers/sqlite3/queries/article.js';
import makeRunner from '../runner.js';
export default function resolveArticleBySku(pool) {
return async (googleAuthId, sku) => {
const result = await pool
.request()
.input('sku', sku)
.input('googleAuthId', googleAuthId)
.query(RESOLVE_ARTICLE_ACCESS_BY_SKU);
export default function resolveArticleBySku(provider) {
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_RESOLVE : MSSQL_RESOLVE;
const row = result.recordset[0];
return async (googleAuthId, sku, tx) => {
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { sku, googleAuthId });
const row = result.rows[0];
if (!row?.articleId || !row?.categoryId) {
throw new Error('Article not found');
}
@@ -30,3 +32,4 @@ export default function resolveArticleBySku(pool) {
};
};
}

View File

@@ -1,13 +1,22 @@
import {
GET_BRAND_BY_COMPANY_AND_ID,
UPDATE_BRAND,
GET_BRAND_BY_COMPANY_AND_ID as MSSQL_GET_BRAND,
UPDATE_BRAND as MSSQL_UPDATE_BRAND,
} from '../../providers/mssql/queries/article.js';
import {
GET_BRAND_BY_COMPANY_AND_ID as SQLITE_GET_BRAND,
UPDATE_BRAND as SQLITE_UPDATE_BRAND,
} from '../../providers/sqlite3/queries/article.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setBrandIdBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
export default function setBrandIdBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getBrandSql = isSqlite ? SQLITE_GET_BRAND : MSSQL_GET_BRAND;
const updateBrandSql = isSqlite ? SQLITE_UPDATE_BRAND : MSSQL_UPDATE_BRAND;
/**
* Sets (or clears) the brand of an article by its SKU.
@@ -15,6 +24,7 @@ export default function setBrandIdBySku(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {number|null} brandId - The brand id, or `null` to clear the brand.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated article.
* @throws {Error} If the brand or article is not found.
*
@@ -22,10 +32,12 @@ export default function setBrandIdBySku(pool) {
* const article = await store.article.setBrandIdBySku(42, 'SKU-1', 3);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, brandId) => {
return async (googleAuthId, sku, brandId, tx) => {
const runner = makeRunner(provider, tx);
const { articleId, languageId, companyId } = await resolve(
googleAuthId,
sku,
runner,
);
if (brandId !== null && !Number.isFinite(brandId)) {
@@ -34,28 +46,26 @@ export default function setBrandIdBySku(pool) {
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]) {
const brand = await runner.query(getBrandSql, {
brandId,
companyId,
});
if (!brand.rows[0]) {
throw new Error('Brand not found');
}
resolvedBrandId = brandId;
}
const result = await pool
.request()
.input('articleId', articleId)
.input('brandId', resolvedBrandId)
.query(UPDATE_BRAND);
const result = await runner.query(updateBrandSql, {
articleId,
brandId: resolvedBrandId,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Article not found');
}
const article = await load(articleId, languageId);
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
@@ -63,3 +73,4 @@ export default function setBrandIdBySku(pool) {
return article;
};
}

View File

@@ -1,16 +1,24 @@
import {
ADD_CATEGORY,
DELETE_CATEGORIES_BY_ARTICLE,
ADD_CATEGORY as MSSQL_ADD_CATEGORY,
DELETE_CATEGORIES_BY_ARTICLE as MSSQL_DELETE_CATEGORIES,
} from '../../providers/mssql/queries/article.js';
import request from '../request.js';
import {
ADD_CATEGORY as SQLITE_ADD_CATEGORY,
DELETE_CATEGORIES_BY_ARTICLE as SQLITE_DELETE_CATEGORIES,
} from '../../providers/sqlite3/queries/article.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setCategoryIdsBySku(pool) {
const resolve = resolveArticleBySku(pool);
const categoryAccess = resolveCategoryAccess(pool);
const load = queryBySku(pool);
export default function setCategoryIdsBySku(provider) {
const resolve = resolveArticleBySku(provider);
const categoryAccess = resolveCategoryAccess(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const addCategorySql = isSqlite ? SQLITE_ADD_CATEGORY : MSSQL_ADD_CATEGORY;
const deleteCategoriesSql = isSqlite ? SQLITE_DELETE_CATEGORIES : MSSQL_DELETE_CATEGORIES;
/**
* Replaces the category membership of an article by its SKU.
@@ -18,6 +26,7 @@ export default function setCategoryIdsBySku(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Array<number>} categoryIds - The category ids the article should belong to (at least one).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<object>} The updated article.
* @throws {Error} If the category ids are invalid or the article is not found.
*
@@ -25,7 +34,7 @@ export default function setCategoryIdsBySku(pool) {
* const article = await store.article.setCategoryIdsBySku(42, 'SKU-1', [5, 9]);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, categoryIds) => {
return async (googleAuthId, sku, categoryIds, tx) => {
if (!Array.isArray(categoryIds)) {
throw new Error('Invalid categoryIds');
}
@@ -42,49 +51,45 @@ export default function setCategoryIdsBySku(pool) {
throw new Error('Article must have at least one category');
}
const { articleId, languageId, companyId } = await resolve(
googleAuthId,
sku,
);
const runSet = async (runner) => {
const { articleId, languageId, companyId } = await resolve(
googleAuthId,
sku,
runner,
);
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);
const access = await categoryAccess(googleAuthId, categoryId, runner);
if (access.companyId !== companyId) {
throw new Error('Invalid categoryIds');
}
}
await request(pool, tx)
.input('articleId', articleId)
.query(DELETE_CATEGORIES_BY_ARTICLE);
await runner.query(deleteCategoriesSql, { articleId });
for (const categoryId of normalized) {
await request(pool, tx)
.input('categoryId', categoryId)
.input('articleId', articleId)
.query(ADD_CATEGORY);
await runner.query(addCategorySql, {
categoryId,
articleId,
});
}
await tx.commit();
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
throw err;
}
const article = await load(articleId, languageId);
if (!article) {
throw new Error('Article not found');
}
return article;
};
return article;
if (tx) {
return runSet(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runSet);
}
return runSet(makeRunner(provider));
};
}

View File

@@ -1,14 +1,22 @@
import {
ADD_EAN,
DELETE_EANS_BY_ARTICLE,
ADD_EAN as MSSQL_ADD_EAN,
DELETE_EANS_BY_ARTICLE as MSSQL_DELETE_EANS,
} from '../../providers/mssql/queries/article.js';
import request from '../request.js';
import {
ADD_EAN as SQLITE_ADD_EAN,
DELETE_EANS_BY_ARTICLE as SQLITE_DELETE_EANS,
} from '../../providers/sqlite3/queries/article.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setEansBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
export default function setEansBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const addEanSql = isSqlite ? SQLITE_ADD_EAN : MSSQL_ADD_EAN;
const deleteEansSql = isSqlite ? SQLITE_DELETE_EANS : MSSQL_DELETE_EANS;
/**
* Replaces the EAN list of an article by its SKU.
@@ -16,6 +24,7 @@ export default function setEansBySku(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Array<string>} eans - The EANs to set (must be unique, non-empty strings).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<object>} The updated article.
* @throws {Error} If the EANs are invalid or the article is not found.
*
@@ -23,7 +32,7 @@ export default function setEansBySku(pool) {
* const article = await store.article.setEansBySku(42, 'SKU-1', ['4001234567890']);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, eans) => {
return async (googleAuthId, sku, eans, tx) => {
if (!Array.isArray(eans)) {
throw new Error('Invalid eans');
}
@@ -41,38 +50,30 @@ export default function setEansBySku(pool) {
normalized.push(clean);
}
const { articleId, languageId } = await resolve(googleAuthId, sku);
const runSet = async (runner) => {
const { articleId, languageId } = await resolve(googleAuthId, sku, runner);
const tx = pool.transaction();
await tx.begin();
try {
await request(pool, tx)
.input('articleId', articleId)
.query(DELETE_EANS_BY_ARTICLE);
await runner.query(deleteEansSql, { articleId });
for (const ean of normalized) {
await request(pool, tx)
.input('articleId', articleId)
.input('ean', ean)
.query(ADD_EAN);
await runner.query(addEanSql, { articleId, ean });
}
await tx.commit();
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
throw err;
}
const article = await load(articleId, languageId);
if (!article) {
throw new Error('Article not found');
}
return article;
};
return article;
if (tx) {
return runSet(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runSet);
}
return runSet(makeRunner(provider));
};
}

View File

@@ -1,17 +1,18 @@
import {
GET_ID_BY_SKU,
SET_FATHER_SORT,
SET_IS_FATHER,
UPDATE_FATHER_BY_SKU,
} from '../../providers/mssql/queries/article.js';
import { GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js';
import request from '../request.js';
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js';
import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setFatherBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
export default function setFatherBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteArticle : mssqlArticle;
const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT;
/**
* Links an article to a father article (V/K compound) by their SKUs.
@@ -19,6 +20,7 @@ export default function setFatherBySku(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The child article SKU.
* @param {string|null} fatherSku - The father article SKU, or `null` to unlink.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<object>} The updated child article.
* @throws {Error} If the article or father is not found, or the link is invalid.
*
@@ -26,73 +28,67 @@ export default function setFatherBySku(pool) {
* const article = await store.article.setFatherBySku(42, '42001190-1', '42001190');
* // => { id, sku, name, fatherSku: '42001190', ... }
*/
return async (googleAuthId, sku, fatherSku) => {
const { articleId, languageId } = await resolve(googleAuthId, sku);
return async (googleAuthId, sku, fatherSku, tx) => {
const runSet = async (runner) => {
const { articleId, languageId } = await resolve(googleAuthId, sku, runner);
if (fatherSku == null || fatherSku === '') {
await request(pool)
.input('sku', sku)
.input('fatherArticleId', null)
.query(UPDATE_FATHER_BY_SKU);
const unlinked = await load(articleId, languageId);
if (!unlinked) {
throw new Error('Article not found');
if (fatherSku == null || fatherSku === '') {
await runner.query(queries.UPDATE_FATHER_BY_SKU, {
sku,
fatherArticleId: null,
});
const unlinked = await load(articleId, languageId, runner);
if (!unlinked) {
throw new Error('Article not found');
}
return unlinked;
}
return unlinked;
}
const father = await pool
.request()
.input('sku', fatherSku)
.query(GET_ID_BY_SKU);
const fatherId = father.recordset[0]?.articleId;
if (!fatherId) {
throw new Error('Father not found');
}
if (fatherId === articleId) {
throw new Error('Invalid father');
}
const father = await runner.query(queries.GET_ID_BY_SKU, { sku: fatherSku });
const fatherId = father.rows[0]?.articleId;
if (!fatherId) {
throw new Error('Father not found');
}
if (fatherId === articleId) {
throw new Error('Invalid father');
}
const tx = pool.transaction();
await tx.begin();
await runner.query(queries.UPDATE_FATHER_BY_SKU, {
sku,
fatherArticleId: fatherId,
});
try {
await request(pool, tx)
.input('sku', sku)
.input('fatherArticleId', fatherId)
.query(UPDATE_FATHER_BY_SKU);
await runner.query(queries.SET_IS_FATHER, {
articleId: fatherId,
});
await request(pool, tx)
.input('articleId', fatherId)
.query(SET_IS_FATHER);
const childCount = await request(pool, tx)
.input('articleId', fatherId)
.query(GET_CHILD_COUNT);
const count = childCount.recordset[0].count;
const childCount = await runner.query(getChildCountSql, {
articleId: fatherId,
});
const count = childCount.rows[0].count;
// The child's sort is its 0-based position among the father's children.
await request(pool, tx)
.input('articleId', articleId)
.input('fatherArticleId', fatherId)
.input('fathersort', count - 1)
.query(SET_FATHER_SORT);
await runner.query(queries.SET_FATHER_SORT, {
articleId,
fatherArticleId: fatherId,
fathersort: count - 1,
});
await tx.commit();
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
throw err;
}
const article = await load(articleId, languageId);
if (!article) {
throw new Error('Article not found');
}
return article;
};
return article;
if (tx) {
return runSet(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runSet);
}
return runSet(makeRunner(provider));
};
}

View File

@@ -1,11 +1,15 @@
import {
CREATE as CREATE_PRICE,
DELETE_BY_ARTICLE_AND_CUSTOMERGROUP,
CREATE as MSSQL_CREATE_PRICE,
DELETE_BY_ARTICLE_AND_CUSTOMERGROUP as MSSQL_DELETE_PRICE,
} from '../../providers/mssql/queries/price.js';
import request from '../request.js';
import {
CREATE as SQLITE_CREATE_PRICE,
DELETE_BY_ARTICLE_AND_CUSTOMERGROUP as SQLITE_DELETE_PRICE,
} from '../../providers/sqlite3/queries/price.js';
import taxEnsure from '../tax/ensure.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
function normalizePrices(prices) {
if (!Array.isArray(prices)) {
@@ -42,10 +46,14 @@ function normalizePrices(prices) {
return normalized;
}
export default function setPricesBySku(pool) {
const resolve = resolveArticleBySku(pool);
const ensureTax = taxEnsure(pool);
const load = queryBySku(pool);
export default function setPricesBySku(provider) {
const resolve = resolveArticleBySku(provider);
const ensureTax = taxEnsure(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE;
const deletePriceSql = isSqlite ? SQLITE_DELETE_PRICE : MSSQL_DELETE_PRICE;
/**
* Replaces the price list of an article by its SKU.
@@ -53,6 +61,7 @@ export default function setPricesBySku(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Array<{price: number, tax: number, minmultiplier?: number}>} prices - The price entries (minmultiplier defaults to 1).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<object>} The updated article.
* @throws {Error} If the prices are invalid or the article is not found.
*
@@ -60,58 +69,56 @@ export default function setPricesBySku(pool) {
* const article = await store.article.setPricesBySku(42, 'SKU-1', [{ price: 1.5, tax: 19, minmultiplier: 1 }]);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, prices) => {
return async (googleAuthId, sku, prices, tx) => {
const normalized = normalizePrices(prices);
const { articleId, languageId, posCustomerGroupId } = await resolve(
googleAuthId,
sku,
);
if (!posCustomerGroupId) {
throw new Error('POS customer group required');
}
const runSet = async (runner) => {
const { articleId, languageId, posCustomerGroupId } = await resolve(
googleAuthId,
sku,
runner,
);
if (!posCustomerGroupId) {
throw new Error('POS customer group required');
}
const tx = pool.transaction();
await tx.begin();
try {
const taxIds = new Map();
for (const entry of normalized) {
if (!taxIds.has(entry.tax)) {
taxIds.set(entry.tax, await ensureTax(entry.tax, tx));
taxIds.set(entry.tax, await ensureTax(entry.tax, runner));
}
}
await request(pool, tx)
.input('articleId', articleId)
.input('customerGroupId', posCustomerGroupId)
.query(DELETE_BY_ARTICLE_AND_CUSTOMERGROUP);
await runner.query(deletePriceSql, {
articleId,
customerGroupId: posCustomerGroupId,
});
for (const entry of normalized) {
await request(pool, tx)
.input('taxId', taxIds.get(entry.tax))
.input('articleId', articleId)
.input('minmultiplier', entry.minmultiplier)
.input('customerGroupId', posCustomerGroupId)
.input('net', entry.price)
.query(CREATE_PRICE);
await runner.query(createPriceSql, {
taxId: taxIds.get(entry.tax),
articleId,
minmultiplier: entry.minmultiplier,
customerGroupId: posCustomerGroupId,
net: entry.price,
});
}
await tx.commit();
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
throw err;
}
const article = await load(articleId, languageId);
if (!article) {
throw new Error('Article not found');
}
return article;
};
return article;
if (tx) {
return runSet(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runSet);
}
return runSet(makeRunner(provider));
};
}

View File

@@ -1,10 +1,13 @@
import { UPDATE_WEIGHT } from '../../providers/mssql/queries/article.js';
import { UPDATE_WEIGHT as MSSQL_UPDATE_WEIGHT } from '../../providers/mssql/queries/article.js';
import { UPDATE_WEIGHT as SQLITE_UPDATE_WEIGHT } from '../../providers/sqlite3/queries/article.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setWeightBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
export default function setWeightBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE_WEIGHT : MSSQL_UPDATE_WEIGHT;
/**
* Sets the weight of an article by its SKU.
@@ -12,6 +15,7 @@ export default function setWeightBySku(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {number} weight - The weight (must be >= 0).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated article.
* @throws {Error} If the weight is invalid or the article is not found.
*
@@ -19,24 +23,24 @@ export default function setWeightBySku(pool) {
* const article = await store.article.setWeightBySku(42, 'SKU-1', 1.5);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, weight) => {
return async (googleAuthId, sku, weight, tx) => {
if (!Number.isFinite(weight) || weight < 0) {
throw new Error('Invalid weight');
}
const { articleId, languageId } = await resolve(googleAuthId, sku);
const runner = makeRunner(provider, tx);
const { articleId, languageId } = await resolve(googleAuthId, sku, runner);
const result = await pool
.request()
.input('articleId', articleId)
.input('weight', weight)
.query(UPDATE_WEIGHT);
const result = await runner.query(sql, {
articleId,
weight,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Article not found');
}
const article = await load(articleId, languageId);
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
@@ -44,3 +48,4 @@ export default function setWeightBySku(pool) {
return article;
};
}

View File

@@ -1,15 +1,16 @@
import {
UPDATE_UNIT,
UPDATE_UNITAMOUNT,
UPDATE_UNIT_AND_AMOUNT,
} from '../../providers/mssql/queries/article.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { UPDATE_BY_ARTICLE_AND_LANGUAGE } from '../../providers/mssql/queries/articletext.js';
import { GET_DETAIL_BY_ARTICLE_ID } from '../../providers/mssql/queries/articleDetail.js';
import { GET_FULL_BY_SI } from '../../providers/mssql/queries/unit.js';
import request from '../request.js';
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import { UPDATE_BY_ARTICLE_AND_LANGUAGE as MSSQL_UPDATE_TEXT } from '../../providers/mssql/queries/articletext.js';
import { UPDATE_BY_ARTICLE_AND_LANGUAGE as SQLITE_UPDATE_TEXT } from '../../providers/sqlite3/queries/articletext.js';
import { GET_DETAIL_BY_ARTICLE_ID as MSSQL_GET_DETAIL } from '../../providers/mssql/queries/articleDetail.js';
import { GET_DETAIL_BY_ARTICLE_ID as SQLITE_GET_DETAIL } from '../../providers/sqlite3/queries/articleDetail.js';
import { GET_FULL_BY_SI as MSSQL_GET_UNIT } from '../../providers/mssql/queries/unit.js';
import { GET_FULL_BY_SI as SQLITE_GET_UNIT } from '../../providers/sqlite3/queries/unit.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
import slugify from '../slugify.js';
function formatUnitLabel(amount, symbol) {
@@ -20,9 +21,16 @@ function formatUnitLabel(amount, symbol) {
return `${Number(amount)}${sym}`;
}
export default function updateBySku(pool) {
const resolve = resolveArticleBySku(pool);
const load = queryBySku(pool);
export default function updateBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
const updateTextSql = isSqlite ? SQLITE_UPDATE_TEXT : MSSQL_UPDATE_TEXT;
const getDetailSql = isSqlite ? SQLITE_GET_DETAIL : MSSQL_GET_DETAIL;
const getUnitSql = isSqlite ? SQLITE_GET_UNIT : MSSQL_GET_UNIT;
/**
* Updates an article's name and/or unit (SI and amount), recording history.
@@ -33,6 +41,7 @@ export default function updateBySku(pool) {
* @param {string} [patch.name] - The new article name.
* @param {string} [patch.unitSi] - The new unit SI code.
* @param {number} [patch.unitamount] - The new unit amount (must be > 0).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<object>} The updated article.
* @throws {Error} If no valid field is provided or the article is not found.
*
@@ -40,7 +49,7 @@ export default function updateBySku(pool) {
* const article = await store.article.updateBySku(42, 'SKU-1', { name: 'Cola 1L', unitSi: 'l', unitamount: 1 });
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, patch = {}) => {
return async (googleAuthId, sku, patch = {}, tx) => {
const hasName = Object.prototype.hasOwnProperty.call(patch, 'name');
const hasUnitamount = Object.prototype.hasOwnProperty.call(patch, 'unitamount');
const hasUnitSi = Object.prototype.hasOwnProperty.call(patch, 'unitSi');
@@ -57,122 +66,118 @@ export default function updateBySku(pool) {
throw new Error('Invalid unitamount');
}
const { articleId, languageId, companyId, companyuserId } = await resolve(
googleAuthId,
sku,
);
const currentResult = await pool
.request()
.input('articleId', articleId)
.input('languageId', languageId)
.query(GET_DETAIL_BY_ARTICLE_ID);
const current = currentResult.recordset[0];
if (!current) {
throw new Error('Article not found');
}
let unitId;
let newUnitSymbol = current.unitSymbol ?? current.unitSi ?? '';
if (hasUnitSi) {
if (!patch.unitSi?.trim()) {
throw new Error('Unit not found');
}
const unit = await pool
.request()
.input('companyId', companyId)
.input('si', patch.unitSi.trim())
.input('languageId', languageId)
.query(GET_FULL_BY_SI);
const unitRow = unit.recordset[0];
unitId = unitRow?.id;
if (!unitId) {
throw new Error('Unit not found');
}
newUnitSymbol = unitRow.symbol ?? unitRow.si ?? '';
}
const historyLines = [];
if (hasName) {
const oldName = current.name ?? '';
const newName = patch.name.trim();
if (oldName !== newName) {
historyLines.push(`Name geändert von "${oldName}" auf "${newName}"`);
}
}
if (hasUnitSi || hasUnitamount) {
const oldLabel = formatUnitLabel(
current.unitamount,
current.unitSymbol ?? current.unitSi ?? '',
const runUpdate = async (runner) => {
const { articleId, languageId, companyId, companyuserId } = await resolve(
googleAuthId,
sku,
runner,
);
const newLabel = formatUnitLabel(
hasUnitamount ? patch.unitamount : current.unitamount,
newUnitSymbol,
);
if (oldLabel !== newLabel) {
historyLines.push(`Einheit geändert von "${oldLabel}" auf "${newLabel}"`);
const currentResult = await runner.query(getDetailSql, {
articleId,
languageId,
});
const current = currentResult.rows[0];
if (!current) {
throw new Error('Article not found');
}
}
const tx = pool.transaction();
await tx.begin();
let unitId;
let newUnitSymbol = current.unitSymbol ?? current.unitSi ?? '';
if (hasUnitSi) {
if (!patch.unitSi?.trim()) {
throw new Error('Unit not found');
}
const unit = await runner.query(getUnitSql, {
companyId,
si: patch.unitSi.trim(),
languageId,
});
const unitRow = unit.rows[0];
unitId = unitRow?.id;
if (!unitId) {
throw new Error('Unit not found');
}
newUnitSymbol = unitRow.symbol ?? unitRow.si ?? '';
}
const historyLines = [];
if (hasName) {
const oldName = current.name ?? '';
const newName = patch.name.trim();
if (oldName !== newName) {
historyLines.push(`Name geändert von "${oldName}" auf "${newName}"`);
}
}
if (hasUnitSi || hasUnitamount) {
const oldLabel = formatUnitLabel(
current.unitamount,
current.unitSymbol ?? current.unitSi ?? '',
);
const newLabel = formatUnitLabel(
hasUnitamount ? patch.unitamount : current.unitamount,
newUnitSymbol,
);
if (oldLabel !== newLabel) {
historyLines.push(`Einheit geändert von "${oldLabel}" auf "${newLabel}"`);
}
}
try {
if (hasName) {
const slug = slugify(patch.name);
const update = await request(pool, tx)
.input('articleId', articleId)
.input('languageId', languageId)
.input('name', patch.name.trim())
.input('slug', slug)
.query(UPDATE_BY_ARTICLE_AND_LANGUAGE);
const update = await runner.query(updateTextSql, {
articleId,
languageId,
name: patch.name.trim(),
slug,
});
if (!update.rowsAffected[0]) {
if (!update.rowsAffected) {
throw new Error('Article not found');
}
}
if (hasUnitSi && hasUnitamount) {
await request(pool, tx)
.input('articleId', articleId)
.input('unitId', unitId)
.input('unitamount', patch.unitamount)
.query(UPDATE_UNIT_AND_AMOUNT);
await runner.query(articleQueries.UPDATE_UNIT_AND_AMOUNT, {
articleId,
unitId,
unitamount: patch.unitamount,
});
} else if (hasUnitSi) {
await request(pool, tx)
.input('articleId', articleId)
.input('unitId', unitId)
.query(UPDATE_UNIT);
await runner.query(articleQueries.UPDATE_UNIT, {
articleId,
unitId,
});
} else if (hasUnitamount) {
await request(pool, tx)
.input('articleId', articleId)
.input('unitamount', patch.unitamount)
.query(UPDATE_UNITAMOUNT);
await runner.query(articleQueries.UPDATE_UNITAMOUNT, {
articleId,
unitamount: patch.unitamount,
});
}
if (historyLines.length) {
await request(pool, tx)
.input('articleId', articleId)
.input('companyuserId', companyuserId)
.input('text', historyLines.join('\n'))
.query(INSERT_HISTORY);
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: historyLines.join('\n'),
});
}
await tx.commit();
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
throw err;
}
const article = await load(articleId, languageId);
if (!article) {
throw new Error('Article not found');
}
return article;
};
return article;
if (tx) {
return runUpdate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runUpdate);
}
return runUpdate(makeRunner(provider));
};
}

View File

@@ -1,11 +1,7 @@
import {
CHECK_EXISTS,
CREATE as CREATE_ARTICLE_SUPPLIER,
GET_BY_ID_AND_ARTICLE_ID,
GET_NEXT_SORT,
} from '../../providers/mssql/queries/articlesupplier.js';
import { CREATE as CREATE_PRICE } from '../../providers/mssql/queries/price.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js';
import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js';
import taxEnsure from '../tax/ensure.js';
@@ -15,10 +11,15 @@ import {
resolveCompanyDefaultTax,
shapeArticleSupplier,
} from './helpers.js';
import makeRunner from '../runner.js';
export default function create(pool) {
const resolve = resolveArticleBySku(pool);
const ensureTax = taxEnsure(pool);
export default function create(provider) {
const resolve = resolveArticleBySku(provider);
const ensureTax = taxEnsure(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE;
/**
* Assigns a supplier to an article, optionally creating a purchase price.
@@ -34,6 +35,7 @@ export default function create(pool) {
* @param {number|null} [input.netPrice] - Net purchase price.
* @param {number} [input.taxId] - The tax id for the purchase price.
* @param {number} [input.taxRate] - The tax rate in percent (used if taxId is not provided).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{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}>} The created assignment.
* @throws {Error} If input is invalid, article or supplier is not found, or supplier is already assigned.
*
@@ -46,7 +48,7 @@ export default function create(pool) {
* });
* // => { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }
*/
return async (googleAuthId, sku, input = {}) => {
return async (googleAuthId, sku, input = {}, tx) => {
const entityId = input.entityId;
if (entityId == null || !Number.isFinite(entityId)) {
throw new Error('Supplier required');
@@ -79,30 +81,26 @@ export default function create(pool) {
throw new Error('Invalid price');
}
const { articleId, companyId } = await resolve(googleAuthId, sku);
const runCreate = async (runner) => {
const { articleId, companyId } = await resolve(googleAuthId, sku, runner);
await assertSupplier(pool, null, companyId, entityId);
await assertSupplier(runner, companyId, entityId);
const existingCheck = await pool
.request()
.input('articleId', articleId)
.input('entityId', entityId)
.query(CHECK_EXISTS);
const existingCheck = await runner.query(queries.CHECK_EXISTS, {
articleId,
entityId,
});
if (existingCheck.recordset[0]) {
throw new Error('Supplier already assigned');
}
if (existingCheck.rows[0]) {
throw new Error('Supplier already assigned');
}
const tx = pool.transaction();
await tx.begin();
try {
let sort = input.sort;
if (sort === undefined || sort === null) {
const sortRes = await request(pool, tx)
.input('articleId', articleId)
.query(GET_NEXT_SORT);
sort = sortRes.recordset[0].sort;
const sortRes = await runner.query(queries.GET_NEXT_SORT, {
articleId,
});
sort = sortRes.rows[0].sort;
}
let priceId = null;
@@ -111,63 +109,60 @@ export default function create(pool) {
let taxId = input.taxId;
if (taxId == null || !Number.isFinite(taxId)) {
if (input.taxRate != null && Number.isFinite(input.taxRate)) {
taxId = await ensureTax(input.taxRate, tx);
taxId = await ensureTax(input.taxRate, runner);
} else {
const defaultTaxId = await resolveCompanyDefaultTax(
pool,
tx,
runner,
companyId,
);
taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, tx);
taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, runner);
}
}
const customerGroupId = await ensureLieferantenGroup(
pool,
runner,
companyId,
tx,
);
const priceRes = await request(pool, tx)
.input('taxId', taxId)
.input('articleId', articleId)
.input('minmultiplier', 1)
.input('customerGroupId', customerGroupId)
.input('net', netPrice)
.query(CREATE_PRICE);
const priceRes = await runner.query(createPriceSql, {
taxId,
articleId,
minmultiplier: 1,
customerGroupId,
net: netPrice,
});
priceId = priceRes.recordset[0].id;
priceId = priceRes.rows[0]?.id ?? priceRes.insertId;
}
const createdRes = await request(pool, tx)
.input('articleId', articleId)
.input('entityId', entityId)
.input('priceId', priceId)
.input('sku', supplierSku)
.input('stock', stock)
.input('pu', pu)
.input('sort', sort)
.query(CREATE_ARTICLE_SUPPLIER);
const createdRes = await runner.query(queries.CREATE, {
articleId,
entityId,
priceId,
sku: supplierSku,
stock,
pu,
sort,
});
const createdId = createdRes.recordset[0].id;
const createdId = createdRes.rows[0]?.id ?? createdRes.insertId;
await tx.commit();
const loadRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
id: createdId,
articleId,
companyId,
});
const loadRes = await pool
.request()
.input('id', createdId)
.input('articleId', articleId)
.input('companyId', companyId)
.query(GET_BY_ID_AND_ARTICLE_ID);
return shapeArticleSupplier(loadRes.rows[0]);
};
return shapeArticleSupplier(loadRes.recordset[0]);
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
if (tx) {
return runCreate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
};
}

View File

@@ -1,13 +1,12 @@
import {
DELETE_BY_ID_AND_ARTICLE_ID,
DELETE_PRICE,
GET_BY_ID_AND_ARTICLE_ID,
} from '../../providers/mssql/queries/articlesupplier.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const resolve = resolveArticleBySku(pool);
export default function del(provider) {
const resolve = resolveArticleBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
/**
* Deletes a supplier assignment and cleans up its associated purchase price.
@@ -15,55 +14,52 @@ export default function del(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {number} id - The id of the articlesupplier record.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the supplier assignment is not found.
*
* @example
* await store.articlesupplier.delete(42, 'SKU-1', 12);
*/
return async (googleAuthId, sku, id) => {
return async (googleAuthId, sku, id, tx) => {
if (id == null || !Number.isFinite(id)) {
throw new Error('Supplier assignment not found');
}
const { articleId, companyId } = await resolve(googleAuthId, sku);
const runDelete = async (runner) => {
const { articleId, companyId } = await resolve(googleAuthId, sku, runner);
const existingRes = await pool
.request()
.input('id', id)
.input('articleId', articleId)
.input('companyId', companyId)
.query(GET_BY_ID_AND_ARTICLE_ID);
const existingRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
id,
articleId,
companyId,
});
const existing = existingRes.recordset[0];
if (!existing) {
throw new Error('Supplier assignment not found');
}
const existing = existingRes.rows[0];
if (!existing) {
throw new Error('Supplier assignment not found');
}
const tx = pool.transaction();
await tx.begin();
try {
await request(pool, tx)
.input('id', id)
.input('articleId', articleId)
.query(DELETE_BY_ID_AND_ARTICLE_ID);
await runner.query(queries.DELETE_BY_ID_AND_ARTICLE_ID, {
id,
articleId,
});
if (existing.priceId != null) {
await request(pool, tx)
.input('id', existing.priceId)
.input('articleId', articleId)
.query(DELETE_PRICE);
await runner.query(queries.DELETE_PRICE, {
id: existing.priceId,
articleId,
});
}
};
await tx.commit();
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
if (tx) {
return runDelete(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runDelete);
}
return runDelete(makeRunner(provider));
};
}

View File

@@ -1,6 +1,14 @@
import { GET_SUPPLIER_BY_ID, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import { GET_COMPANY_DEFAULT_TAX } from '../../providers/mssql/queries/articlesupplier.js';
import request from '../request.js';
import {
GET_SUPPLIER_BY_ID as MSSQL_GET_SUPPLIER_BY_ID,
SUPPLIER_GROUP_NAME as MSSQL_SUPPLIER_GROUP_NAME,
} from '../../providers/mssql/queries/entity.js';
import {
GET_SUPPLIER_BY_ID as SQLITE_GET_SUPPLIER_BY_ID,
SUPPLIER_GROUP_NAME as SQLITE_SUPPLIER_GROUP_NAME,
} from '../../providers/sqlite3/queries/entity.js';
import { GET_COMPANY_DEFAULT_TAX as MSSQL_DEFAULT_TAX } from '../../providers/mssql/queries/articlesupplier.js';
import { GET_COMPANY_DEFAULT_TAX as SQLITE_DEFAULT_TAX } from '../../providers/sqlite3/queries/articlesupplier.js';
import makeRunner from '../runner.js';
export function optionalString(value) {
if (value == null || value === '') return null;
@@ -30,26 +38,58 @@ export function shapeArticleSupplier(row) {
};
}
export async function assertSupplier(pool, tx, companyId, supplierId) {
export async function assertSupplier(runnerOrPool, txOrCompanyId, maybeCompanyId, maybeSupplierId) {
let runner;
let companyId;
let supplierId;
if (maybeSupplierId !== undefined) {
runner = makeRunner(runnerOrPool, txOrCompanyId);
companyId = maybeCompanyId;
supplierId = maybeSupplierId;
} else {
runner = makeRunner(runnerOrPool);
companyId = txOrCompanyId;
supplierId = maybeCompanyId;
}
if (supplierId == null || !Number.isFinite(supplierId)) {
throw new Error('Supplier not found');
}
const result = await request(pool, tx)
.input('id', supplierId)
.input('companyId', companyId)
.input('groupName', SUPPLIER_GROUP_NAME)
.query(GET_SUPPLIER_BY_ID);
const isSqlite = runner?.dialect === 'sqlite3';
const getSupplierSql = isSqlite ? SQLITE_GET_SUPPLIER_BY_ID : MSSQL_GET_SUPPLIER_BY_ID;
const groupName = isSqlite ? SQLITE_SUPPLIER_GROUP_NAME : MSSQL_SUPPLIER_GROUP_NAME;
if (!result.recordset[0]) {
const result = await runner.query(getSupplierSql, {
id: supplierId,
companyId,
groupName,
});
if (!result.rows[0]) {
throw new Error('Supplier not found');
}
return supplierId;
}
export async function resolveCompanyDefaultTax(pool, tx, companyId) {
const result = await request(pool, tx)
.input('companyId', companyId)
.query(GET_COMPANY_DEFAULT_TAX);
return result.recordset[0]?.taxrate ?? null;
export async function resolveCompanyDefaultTax(runnerOrPool, txOrCompanyId, maybeCompanyId) {
let runner;
let companyId;
if (maybeCompanyId !== undefined) {
runner = makeRunner(runnerOrPool, txOrCompanyId);
companyId = maybeCompanyId;
} else {
runner = makeRunner(runnerOrPool);
companyId = txOrCompanyId;
}
const isSqlite = runner?.dialect === 'sqlite3';
const defaultTaxSql = isSqlite ? SQLITE_DEFAULT_TAX : MSSQL_DEFAULT_TAX;
const result = await runner.query(defaultTaxSql, {
companyId,
});
return result.rows[0]?.taxrate ?? null;
}

View File

@@ -1,15 +1,19 @@
import { LIST_BY_ARTICLE_ID } from '../../providers/mssql/queries/articlesupplier.js';
import { LIST_BY_ARTICLE_ID as MSSQL_QUERY } from '../../providers/mssql/queries/articlesupplier.js';
import { LIST_BY_ARTICLE_ID as SQLITE_QUERY } from '../../providers/sqlite3/queries/articlesupplier.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import { shapeArticleSupplier } from './helpers.js';
import makeRunner from '../runner.js';
export default function listByArticleSku(pool) {
const resolve = resolveArticleBySku(pool);
export default function listByArticleSku(provider) {
const resolve = resolveArticleBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY;
/**
* Lists the supplier assignments for an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<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}>>} The supplier assignments.
* @throws {Error} If the article is not found or the user is not authorized.
*
@@ -17,15 +21,16 @@ export default function listByArticleSku(pool) {
* const suppliers = await store.articlesupplier.listByArticleSku(42, 'SKU-1');
* // => [{ id, articleId, entityId, supplierName, sku, pu, stock, priceId, price }]
*/
return async (googleAuthId, sku) => {
const { articleId, companyId } = await resolve(googleAuthId, sku);
return async (googleAuthId, sku, tx) => {
const runner = makeRunner(provider, tx);
const { articleId, companyId } = await resolve(googleAuthId, sku, runner);
const result = await pool
.request()
.input('articleId', articleId)
.input('companyId', companyId)
.query(LIST_BY_ARTICLE_ID);
const result = await runner.query(sql, {
articleId,
companyId,
});
return result.recordset.map(shapeArticleSupplier);
return result.rows.map(shapeArticleSupplier);
};
}

View File

@@ -1,6 +1,8 @@
import { LIST_BY_ENTITY_AND_SKUS } from '../../providers/mssql/queries/articlesupplier.js';
import { LIST_BY_ENTITY_AND_SKUS as MSSQL_QUERY } from '../../providers/mssql/queries/articlesupplier.js';
import { LIST_BY_ENTITY_AND_SKUS as SQLITE_QUERY } from '../../providers/sqlite3/queries/articlesupplier.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { assertSupplier } from './helpers.js';
import makeRunner from '../runner.js';
function parseJsonArray(raw) {
if (!raw) return [];
@@ -12,8 +14,9 @@ function parseJsonArray(raw) {
}
}
export default function listByEntityAndSkus(pool) {
const resolveAccess = resolveSelectedCompanyAccess(pool);
export default function listByEntityAndSkus(provider) {
const resolveAccess = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY;
/**
* Lists article-supplier assignments for a given supplier (entityId) and SKU list,
@@ -22,6 +25,7 @@ export default function listByEntityAndSkus(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number|{entityId?: number, supplierId?: number, skus?: string[]|string}} entityIdOrOptions - The supplier entity id or an options object.
* @param {string[]|string} [maybeSkus] - The list of SKUs to filter by (or single SKU string).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<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, article: {id: number, sku: string, name: string|null, slug: string|null, photos: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>} The supplier assignments with article details.
* @throws {Error} If user is not authorized, entityId is missing, or supplier does not exist.
*
@@ -29,7 +33,7 @@ export default function listByEntityAndSkus(pool) {
* const items = await store.articlesupplier.listByEntityAndSkus(42, 5, ['SKU-1', 'SKU-2']);
* // => [{ id, articleId, entityId, supplierName, sku, pu, stock, sort, price, article: { id, sku, name, slug, photos, eans } }]
*/
return async (googleAuthId, entityIdOrOptions, maybeSkus) => {
return async (googleAuthId, entityIdOrOptions, maybeSkus, tx) => {
let entityId;
let skus;
@@ -68,19 +72,19 @@ export default function listByEntityAndSkus(pool) {
skusJson = JSON.stringify(Array.from(new Set(cleanList)));
}
const { companyId, languageId } = await resolveAccess(googleAuthId);
const runner = makeRunner(provider, tx);
const { companyId, languageId } = await resolveAccess(googleAuthId, runner);
await assertSupplier(pool, null, companyId, parsedEntityId);
await assertSupplier(runner, companyId, parsedEntityId);
const result = await pool
.request()
.input('entityId', parsedEntityId)
.input('companyId', companyId)
.input('languageId', languageId)
.input('skusJson', skusJson)
.query(LIST_BY_ENTITY_AND_SKUS);
const result = await runner.query(sql, {
entityId: parsedEntityId,
companyId,
languageId,
skusJson,
});
return result.recordset.map((row) => {
return result.rows.map((row) => {
const rawPhotos = parseJsonArray(row.photosJson);
const photos = rawPhotos.map((p) => ({
sort: p.sort,
@@ -128,3 +132,4 @@ export default function listByEntityAndSkus(pool) {
});
};
}

View File

@@ -1,13 +1,12 @@
import {
LIST_IDS_BY_ARTICLE,
SET_TEMP_SORTS,
UPDATE_SORT,
} from '../../providers/mssql/queries/articlesupplier.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function order(pool) {
const resolve = resolveArticleBySku(pool);
export default function order(provider) {
const resolve = resolveArticleBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
/**
* Reorders the supplier assignments of an article by its SKU.
@@ -16,6 +15,7 @@ export default function order(pool) {
* @param {string} sku - The article SKU.
* @param {object} input - The reorder input.
* @param {Array<number>} input.ids - The articlesupplier ids in their new order.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{ids: Array<number>}>} The applied order.
* @throws {Error} If the order is invalid.
*
@@ -23,62 +23,58 @@ export default function order(pool) {
* const result = await store.articlesupplier.order(42, 'SKU-1', { ids: [12, 10, 15] });
* // => { ids: [12, 10, 15] }
*/
return async (googleAuthId, sku, { ids } = {}) => {
return async (googleAuthId, sku, { ids } = {}, tx) => {
if (!Array.isArray(ids)) {
throw new Error('Invalid supplier order');
}
const { articleId } = await resolve(googleAuthId, sku);
const runOrder = async (runner) => {
const { articleId } = await resolve(googleAuthId, sku, runner);
const current = await pool
.request()
.input('articleId', articleId)
.query(LIST_IDS_BY_ARTICLE);
const current = await runner.query(queries.LIST_IDS_BY_ARTICLE, {
articleId,
});
const existing = new Set(current.recordset.map((r) => r.id));
const existing = new Set(current.rows.map((r) => r.id));
if (ids.length !== existing.size) {
throw new Error('Invalid supplier order');
}
const seen = new Set();
for (const id of ids) {
if (!Number.isFinite(id) || !existing.has(id) || seen.has(id)) {
if (ids.length !== existing.size) {
throw new Error('Invalid supplier order');
}
seen.add(id);
}
const tx = pool.transaction();
await tx.begin();
const seen = new Set();
for (const id of ids) {
if (!Number.isFinite(id) || !existing.has(id) || seen.has(id)) {
throw new Error('Invalid supplier order');
}
seen.add(id);
}
try {
await request(pool, tx)
.input('articleId', articleId)
.query(SET_TEMP_SORTS);
await runner.query(queries.SET_TEMP_SORTS, {
articleId,
});
for (let sort = 0; sort < ids.length; sort++) {
const updateRes = await request(pool, tx)
.input('id', ids[sort])
.input('articleId', articleId)
.input('sort', sort)
.query(UPDATE_SORT);
const updateRes = await runner.query(queries.UPDATE_SORT, {
id: ids[sort],
articleId,
sort,
});
if (!updateRes.rowsAffected[0]) {
if (!updateRes.rowsAffected) {
throw new Error('Invalid supplier order');
}
}
await tx.commit();
return { ids };
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runOrder(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runOrder);
}
return runOrder(makeRunner(provider));
};
}

View File

@@ -1,11 +1,7 @@
import {
DELETE_PRICE,
GET_BY_ID_AND_ARTICLE_ID,
UPDATE as UPDATE_ARTICLE_SUPPLIER,
UPDATE_PRICE,
} from '../../providers/mssql/queries/articlesupplier.js';
import { CREATE as CREATE_PRICE } from '../../providers/mssql/queries/price.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js';
import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js';
import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js';
import taxEnsure from '../tax/ensure.js';
@@ -14,10 +10,15 @@ import {
resolveCompanyDefaultTax,
shapeArticleSupplier,
} from './helpers.js';
import makeRunner from '../runner.js';
export default function update(pool) {
const resolve = resolveArticleBySku(pool);
const ensureTax = taxEnsure(pool);
export default function update(provider) {
const resolve = resolveArticleBySku(provider);
const ensureTax = taxEnsure(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE;
/**
* Updates an existing supplier assignment and its associated purchase price.
@@ -33,6 +34,7 @@ export default function update(pool) {
* @param {number|null} [patch.netPrice] - Net purchase price.
* @param {number} [patch.taxId] - The tax id for the purchase price.
* @param {number} [patch.taxRate] - The tax rate in percent.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{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}>} The updated assignment.
* @throws {Error} If input is invalid or supplier assignment is not found.
*
@@ -44,7 +46,7 @@ export default function update(pool) {
* });
* // => { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }
*/
return async (googleAuthId, sku, id, patch = {}) => {
return async (googleAuthId, sku, id, patch = {}, tx) => {
if (id == null || !Number.isFinite(id)) {
throw new Error('Supplier assignment not found');
}
@@ -73,24 +75,20 @@ export default function update(pool) {
}
}
const { articleId, companyId } = await resolve(googleAuthId, sku);
const runUpdate = async (runner) => {
const { articleId, companyId } = await resolve(googleAuthId, sku, runner);
const existingRes = await pool
.request()
.input('id', id)
.input('articleId', articleId)
.input('companyId', companyId)
.query(GET_BY_ID_AND_ARTICLE_ID);
const existingRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
id,
articleId,
companyId,
});
const existing = existingRes.recordset[0];
if (!existing) {
throw new Error('Supplier assignment not found');
}
const existing = existingRes.rows[0];
if (!existing) {
throw new Error('Supplier assignment not found');
}
const tx = pool.transaction();
await tx.begin();
try {
let priceId = existing.priceId;
if (patch.netPrice !== undefined) {
@@ -99,20 +97,20 @@ export default function update(pool) {
const oldPriceId = priceId;
priceId = null;
await request(pool, tx)
.input('id', id)
.input('articleId', articleId)
.input('sku', existing.sku)
.input('stock', existing.stock)
.input('pu', existing.pu)
.input('sort', existing.sort)
.input('priceId', null)
.query(UPDATE_ARTICLE_SUPPLIER);
await runner.query(queries.UPDATE, {
id,
articleId,
sku: existing.sku,
stock: existing.stock,
pu: existing.pu,
sort: existing.sort,
priceId: null,
});
await request(pool, tx)
.input('id', oldPriceId)
.input('articleId', articleId)
.query(DELETE_PRICE);
await runner.query(queries.DELETE_PRICE, {
id: oldPriceId,
articleId,
});
}
} else {
// netPrice is a valid number
@@ -120,49 +118,47 @@ export default function update(pool) {
let taxId = patch.taxId;
if (taxId == null || !Number.isFinite(taxId)) {
if (patch.taxRate != null && Number.isFinite(patch.taxRate)) {
taxId = await ensureTax(patch.taxRate, tx);
taxId = await ensureTax(patch.taxRate, runner);
} else {
taxId = existing.priceTaxId;
}
}
await request(pool, tx)
.input('id', priceId)
.input('articleId', articleId)
.input('net', patch.netPrice)
.input('taxId', taxId)
.query(UPDATE_PRICE);
await runner.query(queries.UPDATE_PRICE, {
id: priceId,
articleId,
net: patch.netPrice,
taxId,
});
} else {
// create new price row
let taxId = patch.taxId;
if (taxId == null || !Number.isFinite(taxId)) {
if (patch.taxRate != null && Number.isFinite(patch.taxRate)) {
taxId = await ensureTax(patch.taxRate, tx);
taxId = await ensureTax(patch.taxRate, runner);
} else {
const defaultTaxId = await resolveCompanyDefaultTax(
pool,
tx,
runner,
companyId,
);
taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, tx);
taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, runner);
}
}
const customerGroupId = await ensureLieferantenGroup(
pool,
runner,
companyId,
tx,
);
const priceRes = await request(pool, tx)
.input('taxId', taxId)
.input('articleId', articleId)
.input('minmultiplier', 1)
.input('customerGroupId', customerGroupId)
.input('net', patch.netPrice)
.query(CREATE_PRICE);
const priceRes = await runner.query(createPriceSql, {
taxId,
articleId,
minmultiplier: 1,
customerGroupId,
net: patch.netPrice,
});
priceId = priceRes.recordset[0].id;
priceId = priceRes.rows[0]?.id ?? priceRes.insertId;
}
}
}
@@ -178,33 +174,32 @@ export default function update(pool) {
const nextPu = patch.pu !== undefined ? patch.pu : existing.pu;
const nextSort = patch.sort !== undefined ? patch.sort : existing.sort;
await request(pool, tx)
.input('id', id)
.input('articleId', articleId)
.input('sku', nextSku)
.input('stock', nextStock)
.input('pu', nextPu)
.input('sort', nextSort)
.input('priceId', priceId)
.query(UPDATE_ARTICLE_SUPPLIER);
await runner.query(queries.UPDATE, {
id,
articleId,
sku: nextSku,
stock: nextStock,
pu: nextPu,
sort: nextSort,
priceId,
});
await tx.commit();
const loadRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
id,
articleId,
companyId,
});
const loadRes = await pool
.request()
.input('id', id)
.input('articleId', articleId)
.input('companyId', companyId)
.query(GET_BY_ID_AND_ARTICLE_ID);
return shapeArticleSupplier(loadRes.rows[0]);
};
return shapeArticleSupplier(loadRes.recordset[0]);
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
if (tx) {
return runUpdate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runUpdate);
}
return runUpdate(makeRunner(provider));
};
}

View File

@@ -1,39 +1,44 @@
import { DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/brand.js';
import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/brand.js';
import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/brand.js';
import { isForeignKeyConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a brand for the user's selected company by its id.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the brand to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the brand is not found.
*
* @example
* await store.brand.delete(42, 3);
*/
return async (googleAuthId, id) => {
return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Brand not found');
}
const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
try {
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.query(DELETE_BY_COMPANY_AND_ID);
const result = await runner.query(sql, {
id,
companyId,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Brand not found');
}
} catch (err) {
if (err.number === 547) {
if (isForeignKeyConstraintError(err)) {
throw new Error('Brand in use');
}
throw err;

View File

@@ -1,27 +1,31 @@
import { LIST_BY_COMPANY } from '../../providers/mssql/queries/brand.js';
import { LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY } from '../../providers/mssql/queries/brand.js';
import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlite3/queries/brand.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_LIST_BY_COMPANY
: MSSQL_LIST_BY_COMPANY;
/**
* Lists the brands of the user's selected company.
*
* @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.
*
* @example
* const brands = await store.brand.list(42);
* // => [{ id, name, gsprpinfo }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId, tx);
const result = await pool
.request()
.input('companyId', companyId)
.query(LIST_BY_COMPANY);
const result = await makeRunner(provider, tx).query(sql, { companyId });
return result.recordset;
return result.rows;
};
}

View File

@@ -1,12 +1,24 @@
import {
CREATE,
GET_BY_COMPANY_AND_NAME,
UPDATE_BY_ID,
CREATE as MSSQL_CREATE,
GET_BY_COMPANY_AND_NAME as MSSQL_GET_BY_COMPANY_AND_NAME,
UPDATE_BY_ID as MSSQL_UPDATE_BY_ID,
} from '../../providers/mssql/queries/brand.js';
import {
CREATE as SQLITE_CREATE,
GET_BY_COMPANY_AND_NAME as SQLITE_GET_BY_COMPANY_AND_NAME,
UPDATE_BY_ID as SQLITE_UPDATE_BY_ID,
} from '../../providers/sqlite3/queries/brand.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function upsert(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function upsert(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite
? SQLITE_GET_BY_COMPANY_AND_NAME
: MSSQL_GET_BY_COMPANY_AND_NAME;
const updateSql = isSqlite ? SQLITE_UPDATE_BY_ID : MSSQL_UPDATE_BY_ID;
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Creates or updates a brand for the user's selected company, keyed by name.
@@ -15,6 +27,7 @@ export default function upsert(pool) {
* @param {object} input - The brand input.
* @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.
* @throws {Error} If the brand is invalid.
*
@@ -22,42 +35,42 @@ export default function upsert(pool) {
* const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
* // => { id, name, gsprpinfo }
*/
return async (googleAuthId, input = {}) => {
return async (googleAuthId, input = {}, tx) => {
const name = input.name?.trim();
if (!name) {
throw new Error('Invalid brand');
}
const gsprpinfo = input.gsprpinfo ?? null;
const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
const existing = await pool
.request()
.input('companyId', companyId)
.input('name', name)
.query(GET_BY_COMPANY_AND_NAME);
const existing = await runner.query(getSql, {
companyId,
name,
});
if (existing.recordset[0]) {
const { id } = existing.recordset[0];
await pool
.request()
.input('id', id)
.input('companyId', companyId)
.input('name', name)
.input('gsprpinfo', gsprpinfo)
.query(UPDATE_BY_ID);
if (existing.rows[0]) {
const { id } = existing.rows[0];
await runner.query(updateSql, {
id,
companyId,
name,
gsprpinfo,
});
return { id, name, gsprpinfo };
}
const created = await pool
.request()
.input('companyId', companyId)
.input('name', name)
.input('gsprpinfo', gsprpinfo)
.query(CREATE);
const created = await runner.query(createSql, {
companyId,
name,
gsprpinfo,
});
const id = created.rows[0]?.id ?? created.insertId;
return {
id: created.recordset[0].id,
id,
name,
gsprpinfo,
};

View File

@@ -1,12 +1,25 @@
import { ADD as ADD_CATEGORYPIC, GET_NEXT_SORT } from '../../providers/mssql/queries/categorypic.js';
import { CREATE as CREATE_PIC } from '../../providers/mssql/queries/pic.js';
import {
ADD as MSSQL_ADD_CATEGORYPIC,
GET_NEXT_SORT as MSSQL_GET_NEXT_SORT,
} from '../../providers/mssql/queries/categorypic.js';
import {
ADD as SQLITE_ADD_CATEGORYPIC,
GET_NEXT_SORT as SQLITE_GET_NEXT_SORT,
} from '../../providers/sqlite3/queries/categorypic.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 request from '../request.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function addImage(pool) {
const access = resolveCategoryAccess(pool);
export default function addImage(provider) {
const access = resolveCategoryAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT;
const createPicSql = isSqlite ? SQLITE_CREATE_PIC : MSSQL_CREATE_PIC;
const addCategoryPicSql = isSqlite ? SQLITE_ADD_CATEGORYPIC : MSSQL_ADD_CATEGORYPIC;
/**
* Adds an image to a category, processing it into AVIF full and thumb variants.
@@ -14,50 +27,53 @@ export default function addImage(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category.
* @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}>} The added image info.
*
* @example
* const result = await store.category.addImage(42, 5, imageBuffer);
* // => { id, fullHash, thumbW, thumbH, thumbHash }
*/
return async (googleAuthId, categoryId, image) => {
await access(googleAuthId, categoryId);
return async (googleAuthId, categoryId, image, tx) => {
const runAdd = async (runner) => {
await access(googleAuthId, categoryId, runner);
const processed = await processAvifImage(image);
const processed = await processAvifImage(image);
const tx = pool.transaction();
await tx.begin();
try {
const sortResult = await request(pool, tx)
.input('categoryId', categoryId)
.query(GET_NEXT_SORT);
const sort = sortResult.recordset[0].sort;
const sortResult = await runner.query(getSortSql, { categoryId });
const sort = sortResult.rows[0].sort;
const fullHash = newFullHash();
const picResult = await request(pool, tx)
.input('w', processed.full.w)
.input('h', processed.full.h)
.input('size', processed.full.size)
.input('hash', fullHash)
.input('thumbw', processed.thumb.w)
.input('thumbh', processed.thumb.h)
.input('thumbhash', processed.thumb.hash)
.input('source', '')
.input('fullbinary', processed.full.buffer)
.input('thumbbinary', processed.thumb.buffer)
.query(CREATE_PIC);
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 picId = picResult.recordset[0].id;
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,
});
await request(pool, tx)
.input('categoryId', categoryId)
.input('picId', picId)
.input('sort', sort)
.query(ADD_CATEGORYPIC);
const picId = picResult.rows[0]?.id ?? picResult.insertId;
await tx.commit();
await runner.query(addCategoryPicSql, {
categoryId,
picId,
sort,
});
return {
id: categoryId,
@@ -66,13 +82,14 @@ export default function addImage(pool) {
thumbH: processed.thumb.h,
thumbHash: processed.thumb.hash,
};
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runAdd(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runAdd);
}
return runAdd(makeRunner(provider));
};
}

View File

@@ -1,14 +1,28 @@
import {
CREATE,
GET_NEXT_SIBLING_SORT,
CREATE as MSSQL_CREATE,
GET_NEXT_SIBLING_SORT as MSSQL_GET_NEXT_SIBLING_SORT,
} from '../../providers/mssql/queries/category.js';
import { CREATE as CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js';
import {
CREATE as SQLITE_CREATE,
GET_NEXT_SIBLING_SORT as SQLITE_GET_NEXT_SIBLING_SORT,
} from '../../providers/sqlite3/queries/category.js';
import { CREATE as MSSQL_CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js';
import { CREATE as SQLITE_CREATE_CATEGORYTEXT } from '../../providers/sqlite3/queries/categorytext.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import request from '../request.js';
import slugify from '../slugify.js';
import makeRunner from '../runner.js';
export default function createUnderParent(pool) {
const access = resolveCategoryAccess(pool);
export default function createUnderParent(provider) {
const access = resolveCategoryAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSortSql = isSqlite
? SQLITE_GET_NEXT_SIBLING_SORT
: MSSQL_GET_NEXT_SIBLING_SORT;
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
const createTextSql = isSqlite
? SQLITE_CREATE_CATEGORYTEXT
: MSSQL_CREATE_CATEGORYTEXT;
/**
* Creates a new category under a given parent category.
@@ -17,56 +31,50 @@ export default function createUnderParent(pool) {
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category.
* @param {string} input.name - The category name.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>} The created category.
*
* @example
* const category = await store.category.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Drinks' });
* // => { id, parentId, sort, name, slug }
*/
return async ({ googleAuthId, parentId, name }) => {
const { languageId } = await access(googleAuthId, parentId);
return async ({ googleAuthId, parentId, name }, tx) => {
const runCreate = async (runner) => {
const { languageId } = await access(googleAuthId, parentId, runner);
const tx = pool.transaction();
await tx.begin();
try {
const sortResult = await request(pool, tx)
.input('parentId', parentId)
.query(GET_NEXT_SIBLING_SORT);
const sort = sortResult.recordset[0].sort;
const sortResult = await runner.query(getSortSql, { parentId });
const sort = sortResult.rows[0].sort;
const slug = slugify(name);
const category = await request(pool, tx)
.input('parentId', parentId)
.input('sort', sort)
.query(CREATE);
const category = await runner.query(createSql, {
parentId,
sort,
});
const { id, parentId: resolvedParentId } = category.recordset[0];
const id = category.rows[0]?.id ?? category.insertId;
await request(pool, tx)
.input('categoryId', id)
.input('languageId', languageId)
.input('name', name)
.input('slug', slug)
.query(CREATE_CATEGORYTEXT);
await tx.commit();
await runner.query(createTextSql, {
categoryId: id,
languageId,
name,
slug,
});
return {
id,
parentId: resolvedParentId,
parentId,
sort,
name,
slug,
};
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runCreate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
};
}

View File

@@ -1,8 +1,11 @@
import { DELETE } from '../../providers/mssql/queries/category.js';
import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/category.js';
import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/category.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const access = resolveCategoryAccess(pool);
export default function del(provider) {
const access = resolveCategoryAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a category, refusing to delete the company's root category.
@@ -10,19 +13,20 @@ export default function del(pool) {
* @param {object} input - The delete input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the category is the root category.
*
* @example
* await store.category.delete({ googleAuthId: 42, id: 5 });
*/
return async ({ googleAuthId, id }) => {
const { rootCategoryId } = await access(googleAuthId, id);
return async ({ googleAuthId, id }, tx) => {
const { rootCategoryId } = await access(googleAuthId, id, tx);
if (id === rootCategoryId) {
throw new Error('Cannot delete root category');
}
await pool.request().input('id', id).query(DELETE);
await makeRunner(provider, tx).query(sql, { id });
};
}

View File

@@ -1,14 +1,18 @@
import { DELETE_BY_CATEGORY } from '../../providers/mssql/queries/categorypic.js';
import { DELETE_BY_CATEGORY as MSSQL_DELETE } from '../../providers/mssql/queries/categorypic.js';
import { DELETE_BY_CATEGORY as SQLITE_DELETE } from '../../providers/sqlite3/queries/categorypic.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function deleteImage(pool) {
const access = resolveCategoryAccess(pool);
export default function deleteImage(provider) {
const access = resolveCategoryAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes the image of a category.
*
* @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<{id: number, thumbW: null, thumbH: null, thumbHash: null}>} The cleared image info.
* @throws {Error} If the image is not found.
*
@@ -16,15 +20,13 @@ export default function deleteImage(pool) {
* const result = await store.category.deleteImage(42, 5);
* // => { id: 5, thumbW: null, thumbH: null, thumbHash: null }
*/
return async (googleAuthId, categoryId) => {
await access(googleAuthId, categoryId);
return async (googleAuthId, categoryId, tx) => {
const runner = makeRunner(provider, tx);
await access(googleAuthId, categoryId, runner);
const result = await pool
.request()
.input('categoryId', categoryId)
.query(DELETE_BY_CATEGORY);
const result = await runner.query(sql, { categoryId });
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Image not found');
}

View File

@@ -1,12 +1,18 @@
import {
CREATE,
GET_BY_PARENT_AND_NAME,
GET_NEXT_SIBLING_SORT,
CREATE as MSSQL_CREATE,
GET_BY_PARENT_AND_NAME as MSSQL_GET_BY_PARENT_AND_NAME,
GET_NEXT_SIBLING_SORT as MSSQL_GET_NEXT_SIBLING_SORT,
} from '../../providers/mssql/queries/category.js';
import { CREATE as CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js';
import {
CREATE as SQLITE_CREATE,
GET_BY_PARENT_AND_NAME as SQLITE_GET_BY_PARENT_AND_NAME,
GET_NEXT_SIBLING_SORT as SQLITE_GET_NEXT_SIBLING_SORT,
} from '../../providers/sqlite3/queries/category.js';
import { CREATE as MSSQL_CREATE_CATEGORYTEXT } from '../../providers/mssql/queries/categorytext.js';
import { CREATE as SQLITE_CREATE_CATEGORYTEXT } from '../../providers/sqlite3/queries/categorytext.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import request from '../request.js';
import slugify from '../slugify.js';
import makeRunner from '../runner.js';
// Splits a "A | B | C" breadcrumb string into trimmed, non-empty name parts,
// ordered root-first.
@@ -17,8 +23,20 @@ export function splitPath(path) {
.filter(Boolean);
}
export default function ensureByPath(pool) {
const access = resolveCategoryAccess(pool);
export default function ensureByPath(provider) {
const access = resolveCategoryAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSortSql = isSqlite
? SQLITE_GET_NEXT_SIBLING_SORT
: MSSQL_GET_NEXT_SIBLING_SORT;
const createCategorySql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
const createTextSql = isSqlite
? SQLITE_CREATE_CATEGORYTEXT
: MSSQL_CREATE_CATEGORYTEXT;
const getByNameSql = isSqlite
? SQLITE_GET_BY_PARENT_AND_NAME
: MSSQL_GET_BY_PARENT_AND_NAME;
/**
* Resolves a category by breadcrumb path under a given parent, creating any
@@ -43,62 +61,54 @@ export default function ensureByPath(pool) {
let currentId = parentId;
const createCategory = async (tx, parentId, name, languageId) => {
const sortResult = await request(pool, tx)
.input('parentId', parentId)
.query(GET_NEXT_SIBLING_SORT);
const sort = sortResult.recordset[0].sort;
const createCategory = async (runner, parentId, name, languageId) => {
const sortResult = await runner.query(getSortSql, { parentId });
const sort = sortResult.rows[0].sort;
const slug = slugify(name);
const category = await request(pool, tx)
.input('parentId', parentId)
.input('sort', sort)
.query(CREATE);
const id = category.recordset[0].id;
const category = await runner.query(createCategorySql, {
parentId,
sort,
});
const id = category.rows[0]?.id ?? category.insertId;
await request(pool, tx)
.input('categoryId', id)
.input('languageId', languageId)
.input('name', name)
.input('slug', slug)
.query(CREATE_CATEGORYTEXT);
await runner.query(createTextSql, {
categoryId: id,
languageId,
name,
slug,
});
return id;
};
for (const name of names) {
const { languageId } = await access(googleAuthId, currentId);
const { languageId } = await access(googleAuthId, currentId, tx);
const existing = await request(pool, tx)
.input('parentId', currentId)
.input('languageId', languageId)
.input('name', name)
.query(GET_BY_PARENT_AND_NAME);
const existing = await makeRunner(provider, tx).query(getByNameSql, {
parentId: currentId,
languageId,
name,
});
if (existing.recordset[0]) {
currentId = existing.recordset[0].id;
if (existing.rows[0]) {
currentId = existing.rows[0].id;
continue;
}
if (tx) {
// Run within the caller's transaction (e.g. article import row).
currentId = await createCategory(tx, currentId, name, languageId);
currentId = await createCategory(makeRunner(provider, tx), currentId, name, languageId);
continue;
}
// Standalone use: own transaction per created category.
const ownTx = pool.transaction();
await ownTx.begin();
try {
currentId = await createCategory(ownTx, currentId, name, languageId);
await ownTx.commit();
} catch (err) {
try {
await ownTx.rollback();
} catch {
// ignore
}
throw err;
if (typeof provider?.transaction === 'function') {
currentId = await provider.transaction((runner) =>
createCategory(runner, currentId, name, languageId),
);
} else {
currentId = await createCategory(makeRunner(provider), currentId, name, languageId);
}
}

View File

@@ -1,27 +1,29 @@
import resolveCategoryAccess from './resolveCategoryAccess.js';
import queryCategoryArticles from './queryCategoryArticles.js';
export default function getCategoryArticles(pool) {
const access = resolveCategoryAccess(pool);
const articles = queryCategoryArticles(pool);
export default function getCategoryArticles(provider) {
const access = resolveCategoryAccess(provider);
const articles = queryCategoryArticles(provider);
/**
* Returns the articles belonging to a single category.
*
* @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.
*
* @example
* const articles = await store.category.getCategoryArticles(42, 5);
* // => [{ id, sku, name, ... }]
*/
return async (googleAuthId, categoryId) => {
return async (googleAuthId, categoryId, tx) => {
const { languageId, posCustomerGroupId } = await access(
googleAuthId,
categoryId,
tx,
);
return articles({ categoryId, languageId, posCustomerGroupId });
return articles({ categoryId, languageId, posCustomerGroupId }, tx);
};
}

View File

@@ -1,39 +1,44 @@
import { GET_TREE_FOR_COMPANY } from '../../providers/mssql/queries/category.js';
import { GET_TREE_FOR_COMPANY as MSSQL_GET_TREE } from '../../providers/mssql/queries/category.js';
import { GET_TREE_FOR_COMPANY as SQLITE_GET_TREE } from '../../providers/sqlite3/queries/category.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import queryCategoryArticles from './queryCategoryArticles.js';
import getCategoryPosterminalIds from '../posterminal/getCategoryPosterminalIds.js';
import makeRunner from '../runner.js';
export default function getTreeForCompany(pool) {
const access = resolveSelectedCompanyAccess(pool);
const articles = queryCategoryArticles(pool);
const posterminalIds = getCategoryPosterminalIds(pool);
export default function getTreeForCompany(provider) {
const access = resolveSelectedCompanyAccess(provider);
const articles = queryCategoryArticles(provider);
const posterminalIds = getCategoryPosterminalIds(provider);
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_GET_TREE : MSSQL_GET_TREE;
/**
* Returns the full category tree for the user's selected company together
* with its articles and the posterminal ids assigned to each category.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>} The category tree, articles and language.
*
* @example
* const tree = await store.category.getTreeForCompany(42);
* // => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] }
*/
return async (googleAuthId) => {
return async (googleAuthId, tx) => {
const { companyId, rootCategoryId, languageId, posCustomerGroupId } =
await access(googleAuthId);
await access(googleAuthId, tx);
const runner = makeRunner(provider, tx);
const [treeResult, articleRows, ptMap] = await Promise.all([
pool
.request()
.input('rootId', rootCategoryId)
.input('languageId', languageId)
.query(GET_TREE_FOR_COMPANY),
articles({ categoryId: rootCategoryId, languageId, posCustomerGroupId }),
posterminalIds(companyId),
runner.query(sql, {
rootId: rootCategoryId,
languageId,
}),
articles({ categoryId: rootCategoryId, languageId, posCustomerGroupId }, tx),
posterminalIds(companyId, tx),
]);
const tree = treeResult.recordset.map((row) => ({
const tree = treeResult.rows.map((row) => ({
...row,
posterminalIds: ptMap[row.id] ?? [],
}));

View File

@@ -1,13 +1,25 @@
import {
GET_NEXT_SIBLING_SORT,
IS_ANCESTOR,
MOVE,
GET_NEXT_SIBLING_SORT as MSSQL_GET_NEXT_SIBLING_SORT,
IS_ANCESTOR as MSSQL_IS_ANCESTOR,
MOVE as MSSQL_MOVE,
} from '../../providers/mssql/queries/category.js';
import request from '../request.js';
import {
GET_NEXT_SIBLING_SORT as SQLITE_GET_NEXT_SIBLING_SORT,
IS_ANCESTOR as SQLITE_IS_ANCESTOR,
MOVE as SQLITE_MOVE,
} from '../../providers/sqlite3/queries/category.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function move(pool) {
const access = resolveCategoryAccess(pool);
export default function move(provider) {
const access = resolveCategoryAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const isAncestorSql = isSqlite ? SQLITE_IS_ANCESTOR : MSSQL_IS_ANCESTOR;
const getSortSql = isSqlite
? SQLITE_GET_NEXT_SIBLING_SORT
: MSSQL_GET_NEXT_SIBLING_SORT;
const moveSql = isSqlite ? SQLITE_MOVE : MSSQL_MOVE;
/**
* Moves a category to a new parent, preventing cycles and cross-company moves.
@@ -16,6 +28,7 @@ export default function move(pool) {
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to move.
* @param {number} input.parentId - The id of the new parent category.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, parentId: number, sort: number}>} The moved category.
* @throws {Error} If the move would create a cycle or cross companies.
*
@@ -23,59 +36,49 @@ export default function move(pool) {
* const result = await store.category.move({ googleAuthId: 42, id: 5, parentId: 9 });
* // => { id, parentId, sort }
*/
return async ({ googleAuthId, id, parentId }) => {
// Source category auth (throws if missing / not authorized).
const source = await access(googleAuthId, id);
// Target must be a valid category for the same authorized user/company.
const target = await access(googleAuthId, parentId);
return async ({ googleAuthId, id, parentId }, tx) => {
const runMove = async (runner) => {
// Source category auth (throws if missing / not authorized).
const source = await access(googleAuthId, id, runner);
// Target must be a valid category for the same authorized user/company.
const target = await access(googleAuthId, parentId, runner);
if (target.companyId !== source.companyId) {
throw new Error('Parent not found');
}
if (target.companyId !== source.companyId) {
throw new Error('Parent not found');
}
if (id === parentId) {
throw new Error('Invalid parent');
}
if (id === parentId) {
throw new Error('Invalid parent');
}
const cycle = await pool
.request()
.input('id', id)
.input('parentId', parentId)
.query(IS_ANCESTOR);
const cycle = await runner.query(isAncestorSql, { id, parentId });
if (cycle.recordset[0]) {
throw new Error('Invalid parent');
}
if (cycle.rows[0]) {
throw new Error('Invalid parent');
}
const tx = pool.transaction();
await tx.begin();
const sortResult = await runner.query(getSortSql, { parentId });
const sort = sortResult.rows[0].sort;
try {
const sortResult = await request(pool, tx)
.input('parentId', parentId)
.query(GET_NEXT_SIBLING_SORT);
const sort = sortResult.recordset[0].sort;
const result = await runner.query(moveSql, {
id,
parentId,
sort,
});
const result = await request(pool, tx)
.input('id', id)
.input('parentId', parentId)
.input('sort', sort)
.query(MOVE);
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Category not found');
}
await tx.commit();
return { id, parentId, sort };
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runMove(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runMove);
}
return runMove(makeRunner(provider));
};
}

View File

@@ -1,12 +1,21 @@
import {
LIST_CHILD_IDS,
UPDATE_SORT,
LIST_CHILD_IDS as MSSQL_LIST_CHILD_IDS,
UPDATE_SORT as MSSQL_UPDATE_SORT,
} from '../../providers/mssql/queries/category.js';
import request from '../request.js';
import {
LIST_CHILD_IDS as SQLITE_LIST_CHILD_IDS,
UPDATE_SORT as SQLITE_UPDATE_SORT,
} from '../../providers/sqlite3/queries/category.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function order(pool) {
const access = resolveCategoryAccess(pool);
export default function order(provider) {
const access = resolveCategoryAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const listChildIdsSql = isSqlite
? SQLITE_LIST_CHILD_IDS
: MSSQL_LIST_CHILD_IDS;
const updateSortSql = isSqlite ? SQLITE_UPDATE_SORT : MSSQL_UPDATE_SORT;
/**
* Reorders the child categories of a given parent.
@@ -15,6 +24,7 @@ export default function order(pool) {
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category.
* @param {Array<number>} input.categoryIds - The child category ids in their new order.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{categoryIds: Array<number>}>} The applied order.
* @throws {Error} If the order is invalid.
*
@@ -22,55 +32,48 @@ export default function order(pool) {
* const result = await store.category.order({ googleAuthId: 42, parentId: 5, categoryIds: [3, 1, 2] });
* // => { categoryIds: [3, 1, 2] }
*/
return async ({ googleAuthId, parentId, categoryIds }) => {
return async ({ googleAuthId, parentId, categoryIds }, tx) => {
if (!Array.isArray(categoryIds)) {
throw new Error('Invalid category order');
}
// Auth + ensures parentId is a real category of the user's company.
await access(googleAuthId, parentId);
const runOrder = async (runner) => {
// Auth + ensures parentId is a real category of the user's company.
await access(googleAuthId, parentId, runner);
const children = await pool
.request()
.input('parentId', parentId)
.query(LIST_CHILD_IDS);
const children = await runner.query(listChildIdsSql, { parentId });
const existing = new Set(children.recordset.map((r) => r.id));
const existing = new Set(children.rows.map((r) => r.id));
if (categoryIds.length !== existing.size) {
throw new Error('Invalid category order');
}
const seen = new Set();
for (const categoryId of categoryIds) {
if (!Number.isFinite(categoryId) || !existing.has(categoryId) || seen.has(categoryId)) {
if (categoryIds.length !== existing.size) {
throw new Error('Invalid category order');
}
seen.add(categoryId);
}
const tx = pool.transaction();
await tx.begin();
try {
for (let sort = 0; sort < categoryIds.length; sort++) {
await request(pool, tx)
.input('id', categoryIds[sort])
.input('parentId', parentId)
.input('sort', sort)
.query(UPDATE_SORT);
const seen = new Set();
for (const categoryId of categoryIds) {
if (!Number.isFinite(categoryId) || !existing.has(categoryId) || seen.has(categoryId)) {
throw new Error('Invalid category order');
}
seen.add(categoryId);
}
await tx.commit();
for (let sort = 0; sort < categoryIds.length; sort++) {
await runner.query(updateSortSql, {
id: categoryIds[sort],
parentId,
sort,
});
}
return { categoryIds };
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runOrder(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runOrder);
}
return runOrder(makeRunner(provider));
};
}

View File

@@ -1,14 +1,17 @@
import { GET_CATEGORY_ARTICLES } from '../../providers/mssql/queries/category.js';
import { GET_CATEGORY_ARTICLES as MSSQL_GET } from '../../providers/mssql/queries/category.js';
import { GET_CATEGORY_ARTICLES as SQLITE_GET } from '../../providers/sqlite3/queries/category.js';
import makeRunner from '../runner.js';
export default function queryCategoryArticles(pool) {
return async ({ categoryId, languageId, posCustomerGroupId }) => {
const result = await pool
.request()
.input('categoryId', categoryId)
.input('languageId', languageId)
.input('posCustomerGroupId', posCustomerGroupId)
.query(GET_CATEGORY_ARTICLES);
export default function queryCategoryArticles(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
return result.recordset;
return async ({ categoryId, languageId, posCustomerGroupId }, tx) => {
const result = await makeRunner(provider, tx).query(sql, {
categoryId,
languageId,
posCustomerGroupId,
});
return result.rows;
};
}

View File

@@ -1,9 +1,12 @@
import { UPDATE_BY_CATEGORY_AND_LANGUAGE } from '../../providers/mssql/queries/categorytext.js';
import { UPDATE_BY_CATEGORY_AND_LANGUAGE as MSSQL_UPDATE } from '../../providers/mssql/queries/categorytext.js';
import { UPDATE_BY_CATEGORY_AND_LANGUAGE as SQLITE_UPDATE } from '../../providers/sqlite3/queries/categorytext.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import slugify from '../slugify.js';
import makeRunner from '../runner.js';
export default function rename(pool) {
const access = resolveCategoryAccess(pool);
export default function rename(provider) {
const access = resolveCategoryAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Renames a category and updates its slug.
@@ -12,6 +15,7 @@ export default function rename(pool) {
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to rename.
* @param {string} input.name - The new category name.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, name: string, slug: string}>} The renamed category.
* @throws {Error} If the name is empty or the category is not found.
*
@@ -19,25 +23,24 @@ export default function rename(pool) {
* const result = await store.category.rename({ googleAuthId: 42, id: 5, name: 'Soft Drinks' });
* // => { id, name, slug }
*/
return async ({ googleAuthId, id, name }) => {
return async ({ googleAuthId, id, name }, tx) => {
if (!name?.trim()) {
throw new Error('Name required');
}
const cleaned = name.trim();
const { languageId } = await access(googleAuthId, id);
const { languageId } = await access(googleAuthId, id, tx);
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);
const result = await makeRunner(provider, tx).query(sql, {
categoryId: id,
languageId,
name: cleaned,
slug,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Category not found');
}

View File

@@ -1,14 +1,18 @@
import { RESOLVE_CATEGORY_ACCESS } from '../../providers/mssql/queries/category.js';
import { RESOLVE_CATEGORY_ACCESS as MSSQL_RESOLVE } from '../../providers/mssql/queries/category.js';
import { RESOLVE_CATEGORY_ACCESS as SQLITE_RESOLVE } from '../../providers/sqlite3/queries/category.js';
import makeRunner from '../runner.js';
export default function resolveCategoryAccess(pool) {
return async (googleAuthId, categoryId) => {
const result = await pool
.request()
.input('categoryId', categoryId)
.input('googleAuthId', googleAuthId)
.query(RESOLVE_CATEGORY_ACCESS);
export default function resolveCategoryAccess(provider) {
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_RESOLVE : MSSQL_RESOLVE;
const row = result.recordset[0];
return async (googleAuthId, categoryId, tx) => {
const result = await makeRunner(provider, tx).query(sql, {
categoryId,
googleAuthId,
});
const row = result.rows[0];
if (!row) {
throw new Error('Parent not found');
}

View File

@@ -1,20 +1,34 @@
import { POS_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import { CREATE } from '../../providers/mssql/queries/company.js';
import { CREATE as CREATE_CATEGORY } from '../../providers/mssql/queries/category.js';
import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/company.js';
import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/company.js';
import { CREATE as MSSQL_CREATE_CATEGORY } from '../../providers/mssql/queries/category.js';
import { CREATE as SQLITE_CREATE_CATEGORY } from '../../providers/sqlite3/queries/category.js';
import {
CREATE_WITHOUT_COMPANY as CREATE_CUSTOMERGROUP,
SET_COMPANY as SET_CUSTOMERGROUP_COMPANY,
CREATE_WITHOUT_COMPANY as MSSQL_CREATE_CUSTOMERGROUP,
SET_COMPANY as MSSQL_SET_CUSTOMERGROUP_COMPANY,
} from '../../providers/mssql/queries/customergroup.js';
import { ADD as ADD_COMPANYUSER } from '../../providers/mssql/queries/companyuser.js';
import {
CREATE_WITHOUT_COMPANY as SQLITE_CREATE_CUSTOMERGROUP,
SET_COMPANY as SQLITE_SET_CUSTOMERGROUP_COMPANY,
} from '../../providers/sqlite3/queries/customergroup.js';
import { ADD as MSSQL_ADD_COMPANYUSER } from '../../providers/mssql/queries/companyuser.js';
import { ADD as SQLITE_ADD_COMPANYUSER } from '../../providers/sqlite3/queries/companyuser.js';
import getByName from './getByName.js';
import langEnsure from '../lang/ensure.js';
import taxEnsure from '../tax/ensure.js';
import request from '../request.js';
import makeRunner from '../runner.js';
export default function create(pool) {
const nameTaken = getByName(pool);
const ensureLang = langEnsure(pool);
const ensureTax = taxEnsure(pool);
export default function create(provider) {
const nameTaken = getByName(provider);
const ensureLang = langEnsure(provider);
const ensureTax = taxEnsure(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const createCategorySql = isSqlite ? SQLITE_CREATE_CATEGORY : MSSQL_CREATE_CATEGORY;
const createCustGroupSql = isSqlite ? SQLITE_CREATE_CUSTOMERGROUP : MSSQL_CREATE_CUSTOMERGROUP;
const createCompanySql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
const setCustGroupCompanySql = isSqlite ? SQLITE_SET_CUSTOMERGROUP_COMPANY : MSSQL_SET_CUSTOMERGROUP_COMPANY;
const addCompanyUserSql = isSqlite ? SQLITE_ADD_COMPANYUSER : MSSQL_ADD_COMPANYUSER;
/**
* Creates a new company together with its root category, POS customer group,
@@ -26,6 +40,7 @@ export default function create(pool) {
* @param {number} input.taxrate - The standard tax rate in percent.
* @param {number} input.reducedtaxrate - The reduced tax rate in percent.
* @param {number} input.googleAuthId - The id of the Google auth user to link.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>} The created company ids.
* @throws {Error} If the company name is already taken.
*
@@ -39,60 +54,52 @@ export default function create(pool) {
* });
* // => { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId, posCustomerGroupId }
*/
return async ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }) => {
const tx = pool.transaction();
await tx.begin();
try {
const existing = await nameTaken(name, tx);
return async ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }, tx) => {
const runCreate = async (runner) => {
const existing = await nameTaken(name, runner);
if (existing) {
throw new Error('Company name not available');
}
const languageId = await ensureLang(iso639, tx);
const taxrateId = await ensureTax(taxrate, tx);
const reducedtaxrateId = await ensureTax(reducedtaxrate, tx);
const taxfreerateId = await ensureTax(0, tx);
const languageId = await ensureLang(iso639, runner);
const taxrateId = await ensureTax(taxrate, runner);
const reducedtaxrateId = await ensureTax(reducedtaxrate, runner);
const taxfreerateId = await ensureTax(0, runner);
const category = await request(pool, tx)
.input('parentId', null)
.input('sort', 0)
.query(CREATE_CATEGORY);
const rootCategoryId = category.recordset[0].id;
const category = await runner.query(createCategorySql, {
parentId: null,
sort: 0,
});
const rootCategoryId = category.rows[0]?.id ?? category.insertId;
// Circular FK: create POS group first (company_id null), company with
// poscustomergroup_id NOT NULL, then link the group back to the company.
const posGroup = await request(pool, tx)
.input('name', POS_GROUP_NAME)
.input('sort', 0)
.query(CREATE_CUSTOMERGROUP);
const posGroup = await runner.query(createCustGroupSql, {
name: POS_GROUP_NAME,
sort: 0,
});
const posCustomerGroupId = posGroup.rows[0]?.id ?? posGroup.insertId;
const posCustomerGroupId = posGroup.recordset[0].id;
const result = await runner.query(createCompanySql, {
name,
rootCategoryId,
languageId,
taxrate: taxrateId,
reducedtaxrate: reducedtaxrateId,
taxfreerate: taxfreerateId,
posCustomerGroupId,
});
const companyId = result.rows[0]?.id ?? result.insertId;
const result = await request(pool, tx)
.input('name', name)
.input('rootCategoryId', rootCategoryId)
.input('languageId', languageId)
.input('taxrate', taxrateId)
.input('reducedtaxrate', reducedtaxrateId)
.input('taxfreerate', taxfreerateId)
.input('posCustomerGroupId', posCustomerGroupId)
.query(CREATE);
await runner.query(setCustGroupCompanySql, {
id: posCustomerGroupId,
companyId,
});
const companyId = result.recordset[0].id;
await request(pool, tx)
.input('id', posCustomerGroupId)
.input('companyId', companyId)
.query(SET_CUSTOMERGROUP_COMPANY);
await request(pool, tx)
.input('companyId', companyId)
.input('googleAuthId', googleAuthId)
.query(ADD_COMPANYUSER);
await tx.commit();
await runner.query(addCompanyUserSql, {
companyId,
googleAuthId,
});
return {
id: companyId,
@@ -103,13 +110,14 @@ export default function create(pool) {
taxfreerateId,
posCustomerGroupId,
};
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runCreate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
};
}

View File

@@ -1,19 +1,22 @@
import { DELETE } from '../../providers/mssql/queries/company.js';
import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/company.js';
import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/company.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
export default function del(pool) {
/**
* Deletes a company by its id.
*
* @param {number} companyId - The id of the company to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
*
* @example
* await store.company.delete(1);
*/
return async (companyId) => {
await pool
.request()
.input('companyId', companyId)
.query(DELETE);
return async (companyId, tx) => {
await makeRunner(provider, tx).query(sql, { companyId });
};
}

View File

@@ -1,7 +1,11 @@
import { GET_BY_ID } from '../../providers/mssql/queries/company.js';
import request from '../request.js';
import { GET_BY_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/company.js';
import { GET_BY_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/company.js';
import makeRunner from '../runner.js';
export default function getById(provider) {
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID;
export default function getById(pool) {
/**
* Returns a single company by its id.
*
@@ -14,10 +18,8 @@ export default function getById(pool) {
* // => { id, name, ... }
*/
return async (id, tx) => {
const result = await request(pool, tx)
.input('id', id)
.query(GET_BY_ID);
const result = await makeRunner(provider, tx).query(sql, { id });
return result.recordset[0];
return result.rows[0];
};
}

View File

@@ -1,7 +1,11 @@
import { GET_BY_NAME } from '../../providers/mssql/queries/company.js';
import request from '../request.js';
import { GET_BY_NAME as MSSQL_GET_BY_NAME } from '../../providers/mssql/queries/company.js';
import { GET_BY_NAME as SQLITE_GET_BY_NAME } from '../../providers/sqlite3/queries/company.js';
import makeRunner from '../runner.js';
export default function getByName(provider) {
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_NAME : MSSQL_GET_BY_NAME;
export default function getByName(pool) {
/**
* Returns a single company by its name.
*
@@ -14,10 +18,8 @@ export default function getByName(pool) {
* // => { id, name, ... }
*/
return async (name, tx) => {
const result = await request(pool, tx)
.input('name', name)
.query(GET_BY_NAME);
const result = await makeRunner(provider, tx).query(sql, { name });
return result.recordset[0];
return result.rows[0];
};
}

View File

@@ -1,21 +1,23 @@
import { LIST } from '../../providers/mssql/queries/company.js';
import { LIST as MSSQL_LIST } from '../../providers/mssql/queries/company.js';
import { LIST as SQLITE_LIST } from '../../providers/sqlite3/queries/company.js';
import makeRunner from '../runner.js';
export default function list(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
export default function list(pool) {
/**
* Lists the companies accessible to the given Google auth user.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<object>>} The accessible company rows.
*
* @example
* const companies = await store.company.list(42);
* // => [{ id, name, ... }]
*/
return async (googleAuthId) => {
const result = await pool
.request()
.input('googleAuthId', googleAuthId)
.query(LIST);
return result.recordset;
return async (googleAuthId, tx) => {
const result = await makeRunner(provider, tx).query(sql, { googleAuthId });
return result.rows;
};
}

View File

@@ -1,15 +1,18 @@
import { UPDATE } from '../../providers/mssql/queries/company.js';
import { UPDATE as MSSQL_UPDATE } from '../../providers/mssql/queries/company.js';
import { UPDATE as SQLITE_UPDATE } from '../../providers/sqlite3/queries/company.js';
import getById from './getById.js';
import getByName from './getByName.js';
import langEnsure from '../lang/ensure.js';
import taxEnsure from '../tax/ensure.js';
import request from '../request.js';
import makeRunner from '../runner.js';
export default function update(pool) {
const byId = getById(pool);
const byName = getByName(pool);
const ensureLang = langEnsure(pool);
const ensureTax = taxEnsure(pool);
export default function update(provider) {
const byId = getById(provider);
const byName = getByName(provider);
const ensureLang = langEnsure(provider);
const ensureTax = taxEnsure(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Updates an existing company's name, language and tax rates.
@@ -20,6 +23,7 @@ export default function update(pool) {
* @param {string} input.name - The new company name (must be unique).
* @param {number} input.taxrate - The standard tax rate in percent.
* @param {number} input.reducedtaxrate - The reduced tax rate in percent.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number}>} The updated company ids.
* @throws {Error} If the company is not found or the name is taken.
*
@@ -27,36 +31,31 @@ export default function update(pool) {
* const result = await store.company.update(1, { iso639: 'de', name: 'Acme', taxrate: 19, reducedtaxrate: 7 });
* // => { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId }
*/
return async (id, { iso639, name, taxrate, reducedtaxrate }) => {
const tx = pool.transaction();
await tx.begin();
try {
const company = await byId(id, tx);
return async (id, { iso639, name, taxrate, reducedtaxrate }, tx) => {
const runUpdate = async (runner) => {
const company = await byId(id, runner);
if (!company) {
throw new Error('Company not found');
}
if (name !== company.name) {
const taken = await byName(name, tx);
const taken = await byName(name, runner);
if (taken) {
throw new Error('Company name not available');
}
}
const languageId = await ensureLang(iso639, tx);
const taxrateId = await ensureTax(taxrate, tx);
const reducedtaxrateId = await ensureTax(reducedtaxrate, tx);
const languageId = await ensureLang(iso639, runner);
const taxrateId = await ensureTax(taxrate, runner);
const reducedtaxrateId = await ensureTax(reducedtaxrate, runner);
await request(pool, tx)
.input('id', id)
.input('name', name)
.input('languageId', languageId)
.input('taxrate', taxrateId)
.input('reducedtaxrate', reducedtaxrateId)
.query(UPDATE);
await tx.commit();
await runner.query(updateSql, {
id,
name,
languageId,
taxrate: taxrateId,
reducedtaxrate: reducedtaxrateId,
});
return {
id,
@@ -66,13 +65,14 @@ export default function update(pool) {
reducedtaxrateId,
taxfreerateId: company.taxfreerate,
};
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runUpdate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runUpdate);
}
return runUpdate(makeRunner(provider));
};
}

View File

@@ -1,9 +1,15 @@
import { GET_BY_COMPANY_AND_KEY } from '../../providers/mssql/queries/companysetting.js';
import { GET_BY_COMPANY_AND_KEY as MSSQL_GET_BY_COMPANY_AND_KEY } from '../../providers/mssql/queries/companysetting.js';
import { GET_BY_COMPANY_AND_KEY as SQLITE_GET_BY_COMPANY_AND_KEY } from '../../providers/sqlite3/queries/companysetting.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js';
import makeRunner from '../runner.js';
export default function get(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function get(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_GET_BY_COMPANY_AND_KEY
: MSSQL_GET_BY_COMPANY_AND_KEY;
/**
* Returns a single company setting by key for the user's selected company.
@@ -13,6 +19,7 @@ export default function get(pool) {
* @param {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return the setting if it is frontend-visible.
* @param {boolean} [options.maskWriteonly] - Mask writeonly settings as '***'. Defaults to `frontendOnly` (usergui gets '***', backend gets full value).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>} The setting, or `undefined` if not found.
*
* @example
@@ -23,20 +30,20 @@ export default function get(pool) {
googleAuthId,
key,
{ frontendOnly = false, maskWriteonly } = {},
tx,
) => {
if (!key?.trim()) {
return undefined;
}
const { companyId } = await access(googleAuthId);
const { companyId } = await access(googleAuthId, tx);
const result = await pool
.request()
.input('companyId', companyId)
.input('key', key.trim())
.query(GET_BY_COMPANY_AND_KEY);
const result = await makeRunner(provider, tx).query(sql, {
companyId,
key: key.trim(),
});
const row = result.recordset[0];
const row = result.rows[0];
if (!row) {
return undefined;
}

View File

@@ -1,12 +1,22 @@
import {
LIST_BY_COMPANY,
LIST_BY_COMPANY_FRONTEND,
LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
LIST_BY_COMPANY_FRONTEND as MSSQL_LIST_BY_COMPANY_FRONTEND,
} from '../../providers/mssql/queries/companysetting.js';
import {
LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY,
LIST_BY_COMPANY_FRONTEND as SQLITE_LIST_BY_COMPANY_FRONTEND,
} from '../../providers/sqlite3/queries/companysetting.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js';
import makeRunner from '../runner.js';
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const listSql = isSqlite ? SQLITE_LIST_BY_COMPANY : MSSQL_LIST_BY_COMPANY;
const listFrontendSql = isSqlite
? SQLITE_LIST_BY_COMPANY_FRONTEND
: MSSQL_LIST_BY_COMPANY_FRONTEND;
/**
* Lists the settings of the user's selected company, optionally only frontend-visible ones.
@@ -18,6 +28,7 @@ export default function list(pool) {
* @param {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return frontend-visible settings.
* @param {boolean} [options.maskWriteonly] - Whether to mask writeonly settings with '***'. Defaults to `true`.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{key: string, value: string, frontend: boolean, writeonly: boolean}>>} The settings.
*
* @example
@@ -27,15 +38,16 @@ export default function list(pool) {
return async (
googleAuthId,
{ frontendOnly = false, maskWriteonly = true } = {},
tx,
) => {
const { companyId } = await access(googleAuthId);
const { companyId } = await access(googleAuthId, tx);
const result = await pool
.request()
.input('companyId', companyId)
.query(frontendOnly ? LIST_BY_COMPANY_FRONTEND : LIST_BY_COMPANY);
const result = await makeRunner(provider, tx).query(
frontendOnly ? listFrontendSql : listSql,
{ companyId },
);
return result.recordset.map((row) =>
return result.rows.map((row) =>
shapeSetting(row, { exposeValue: !maskWriteonly }),
);
};

View File

@@ -1,6 +1,14 @@
import { GET_BY_COMPANY_AND_KEY, UPSERT } from '../../providers/mssql/queries/companysetting.js';
import {
GET_BY_COMPANY_AND_KEY as MSSQL_GET_BY_COMPANY_AND_KEY,
UPSERT as MSSQL_UPSERT,
} from '../../providers/mssql/queries/companysetting.js';
import {
GET_BY_COMPANY_AND_KEY as SQLITE_GET_BY_COMPANY_AND_KEY,
UPSERT as SQLITE_UPSERT,
} from '../../providers/sqlite3/queries/companysetting.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js';
import makeRunner from '../runner.js';
const ALLOWED_KEYS = new Set([
'ui.theme',
@@ -10,32 +18,29 @@ const ALLOWED_KEYS = new Set([
'labelprinter.type',
]);
// Keys that are write-only by default: the GUI receives a scrambled "***"
// placeholder when reading, but the backend always gets the real value.
const WRITEONLY_KEYS = new Set([
'keys.tavily',
'keys.openrouter',
]);
export default function set(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function set(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite
? SQLITE_GET_BY_COMPANY_AND_KEY
: MSSQL_GET_BY_COMPANY_AND_KEY;
const upsertSql = isSqlite ? SQLITE_UPSERT : MSSQL_UPSERT;
/**
* Sets (upserts) a company setting by key for the user's selected company.
*
* Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,
* `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`.
*
* Keys in the write-only set (`keys.tavily`, `keys.openrouter`) are stored
* with `writeonly = 1`; when retrieved by the GUI the value is returned as
* `"***"`. The backend always receives the real value.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The setting input.
* @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 {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.
*
@@ -43,7 +48,7 @@ export default function set(pool) {
* const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' });
* // => { key, value, frontend, writeonly }
*/
return async (googleAuthId, input = {}) => {
return async (googleAuthId, input = {}, tx) => {
const key = input.key?.trim();
if (!ALLOWED_KEYS.has(key)) {
throw new Error('Invalid setting');
@@ -52,29 +57,25 @@ export default function set(pool) {
throw new Error('Invalid setting');
}
const runner = makeRunner(provider, tx);
const frontend = Boolean(input.frontend);
const writeonly =
input.writeonly !== undefined
? Boolean(input.writeonly)
: WRITEONLY_KEYS.has(key);
const { companyId } = await access(googleAuthId);
const { companyId } = await access(googleAuthId, tx);
await pool
.request()
.input('companyId', companyId)
.input('key', key)
.input('value', input.value)
.input('frontend', frontend)
.input('writeonly', writeonly)
.query(UPSERT);
await runner.query(upsertSql, {
companyId,
key,
value: input.value,
frontend: isSqlite ? (frontend ? 1 : 0) : frontend,
writeonly: isSqlite ? (writeonly ? 1 : 0) : writeonly,
});
const result = await pool
.request()
.input('companyId', companyId)
.input('key', key)
.query(GET_BY_COMPANY_AND_KEY);
const result = await runner.query(getSql, { companyId, key });
// Return the masked value to the GUI; the DB holds the real value.
return shapeSetting(result.recordset[0]);
return shapeSetting(result.rows[0]);
};
}

View File

@@ -1,24 +1,28 @@
import { ADD } from '../../providers/mssql/queries/companyuser.js';
import { ADD as MSSQL_ADD } from '../../providers/mssql/queries/companyuser.js';
import { ADD as SQLITE_ADD } from '../../providers/sqlite3/queries/companyuser.js';
import makeRunner from '../runner.js';
export default function add(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_ADD : MSSQL_ADD;
export default function add(pool) {
/**
* Adds a company user linking a company to a Google auth account.
*
* @param {number} companyId - The id of the company.
* @param {number} googleAuthId - The id of the Google auth account.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<number>} The id of the created company user link.
*
* @example
* const id = await store.companyuser.add(1, 42);
* // => 7
*/
return async (companyId, googleAuthId) => {
const result = await pool
.request()
.input('companyId', companyId)
.input('googleAuthId', googleAuthId)
.query(ADD);
return async (companyId, googleAuthId, tx) => {
const result = await makeRunner(provider, tx).query(sql, {
companyId,
googleAuthId,
});
return result.recordset[0].id;
return result.rows[0]?.id ?? result.insertId;
};
}

View File

@@ -1,19 +1,21 @@
import { DELETE } from '../../providers/mssql/queries/companyuser.js';
import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/companyuser.js';
import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/companyuser.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
export default function del(pool) {
/**
* Deletes a company user link by its id.
*
* @param {number} id - The id of the company user link to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
*
* @example
* await store.companyuser.delete(7);
*/
return async (id) => {
await pool
.request()
.input('id', id)
.query(DELETE);
return async (id, tx) => {
await makeRunner(provider, tx).query(sql, { id });
};
}

View File

@@ -1,22 +1,27 @@
import { GET_BY_EMAIL } from '../../providers/mssql/queries/companyuser.js';
import { GET_BY_EMAIL as MSSQL_GET_BY_EMAIL } from '../../providers/mssql/queries/companyuser.js';
import { GET_BY_EMAIL as SQLITE_GET_BY_EMAIL } from '../../providers/sqlite3/queries/companyuser.js';
import makeRunner from '../runner.js';
export default function getByEmail(provider) {
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_GET_BY_EMAIL
: MSSQL_GET_BY_EMAIL;
export default function getByEmail(pool) {
/**
* Returns the company user ids associated with the given email address.
*
* @param {string} email - The email address.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<number>>} The company user ids.
*
* @example
* const ids = await store.companyuser.getByEmail('user@example.com');
* // => [1, 2, 3]
*/
return async (email) => {
const result = await pool
.request()
.input('email', email)
.query(GET_BY_EMAIL);
return async (email, tx) => {
const result = await makeRunner(provider, tx).query(sql, { email });
return result.recordset.map((row) => row.id);
return result.rows.map((row) => row.id);
};
}

View File

@@ -1,14 +1,20 @@
import { GET_COMPANY_ACCESS } from '../../providers/mssql/queries/companyuser.js';
import { GET_COMPANY_ACCESS as MSSQL_GET_COMPANY_ACCESS } from '../../providers/mssql/queries/companyuser.js';
import { GET_COMPANY_ACCESS as SQLITE_GET_COMPANY_ACCESS } from '../../providers/sqlite3/queries/companyuser.js';
import makeRunner from '../runner.js';
export default function resolveCompanyAccess(pool) {
return async (googleAuthId, companyId) => {
const result = await pool
.request()
.input('googleAuthId', googleAuthId)
.input('companyId', companyId)
.query(GET_COMPANY_ACCESS);
export default function resolveCompanyAccess(provider) {
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_GET_COMPANY_ACCESS
: MSSQL_GET_COMPANY_ACCESS;
const row = result.recordset[0];
return async (googleAuthId, companyId, tx) => {
const result = await makeRunner(provider, tx).query(sql, {
googleAuthId,
companyId,
});
const row = result.rows[0];
if (!row) {
throw new Error('Company not found');
}

View File

@@ -1,27 +1,29 @@
import { GET_BY_ID } from '../../providers/mssql/queries/google_auth.js';
import { GET_BY_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/google_auth.js';
import { GET_BY_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/google_auth.js';
import makeRunner from '../runner.js';
import resolveCompanyAccess from './resolveCompanyAccess.js';
export default function resolveSelectedCompanyAccess(pool) {
const access = resolveCompanyAccess(pool);
export default function resolveSelectedCompanyAccess(provider) {
const access = resolveCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID;
/**
* Resolves the user's selected company and checks company membership.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{companyId: number, rootCategoryId: number, languageId: number, posCustomerGroupId: number|null, companyuserId: number}>} The resolved company access context.
* @throws {Error} If the user is not authorized or has no company selected.
*/
return async (googleAuthId) => {
return async (googleAuthId, tx) => {
if (!Number.isFinite(googleAuthId)) {
throw new Error('Not authorized');
}
const result = await pool
.request()
.input('googleAuthId', googleAuthId)
.query(GET_BY_ID);
const result = await makeRunner(provider, tx).query(sql, { googleAuthId });
const row = result.recordset[0];
const row = result.rows[0];
if (!row) {
throw new Error('Not authorized');
}
@@ -31,7 +33,7 @@ export default function resolveSelectedCompanyAccess(pool) {
throw new Error('No company selected');
}
const ctx = await access(googleAuthId, companyId);
const ctx = await access(googleAuthId, companyId, tx);
return { companyId, ...ctx };
};
}

View File

@@ -1,6 +1,14 @@
import { CREATE, GET_NEXT_SORT } from '../../providers/mssql/queries/customergroup.js';
import {
CREATE as MSSQL_CREATE,
GET_NEXT_SORT as MSSQL_GET_NEXT_SORT,
} from '../../providers/mssql/queries/customergroup.js';
import {
CREATE as SQLITE_CREATE,
GET_NEXT_SORT as SQLITE_GET_NEXT_SORT,
} from '../../providers/sqlite3/queries/customergroup.js';
import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
const RESERVED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]);
@@ -8,8 +16,11 @@ function shape(row) {
return { id: row.id, name: row.name, sort: row.sort };
}
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT;
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Creates a new customer group for the user's selected company.
@@ -17,6 +28,7 @@ export default function create(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The customer group input.
* @param {string} input.name - The customer group name (must not be a reserved name).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, name: string, sort: number}>} The created customer group.
* @throws {Error} If the name is empty or reserved.
*
@@ -24,7 +36,7 @@ export default function create(pool) {
* const group = await store.customergroup.create(42, { name: 'Wholesale' });
* // => { id, name, sort }
*/
return async (googleAuthId, { name } = {}) => {
return async (googleAuthId, { name } = {}, tx) => {
const cleaned = name?.trim();
if (!cleaned) {
throw new Error('Name required');
@@ -33,21 +45,19 @@ export default function create(pool) {
throw new Error('Name not allowed');
}
const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
const next = await pool
.request()
.input('companyId', companyId)
.query(GET_NEXT_SORT);
const sort = next.recordset[0].sort;
const next = await runner.query(getSortSql, { companyId });
const sort = next.rows[0].sort;
const result = await pool
.request()
.input('companyId', companyId)
.input('name', cleaned)
.input('sort', sort)
.query(CREATE);
const result = await runner.query(createSql, {
companyId,
name: cleaned,
sort,
});
return shape(result.recordset[0]);
const row = result.rows[0] ?? { id: result.insertId, name: cleaned, sort };
return shape(row);
};
}

View File

@@ -1,39 +1,46 @@
import { DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js';
import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js';
import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/customergroup.js';
import { isForeignKeyConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_DELETE_BY_COMPANY_AND_ID
: MSSQL_DELETE_BY_COMPANY_AND_ID;
/**
* Deletes a customer group for the user's selected company by its id.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the customer group to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the customer group is not found.
*
* @example
* await store.customergroup.delete(42, 5);
*/
return async (googleAuthId, id) => {
return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Customer group not found');
}
const { companyId } = await access(googleAuthId);
const { companyId } = await access(googleAuthId, tx);
try {
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.query(DELETE_BY_COMPANY_AND_ID);
const result = await makeRunner(provider, tx).query(sql, {
id,
companyId,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Customer group not found');
}
} catch (err) {
if (err.number === 547) {
if (isForeignKeyConstraintError(err)) {
throw new Error('Customer group in use');
}
throw err;

View File

@@ -1,28 +1,32 @@
import { LIST_BY_COMPANY } from '../../providers/mssql/queries/customergroup.js';
import { LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY } from '../../providers/mssql/queries/customergroup.js';
import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlite3/queries/customergroup.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_LIST_BY_COMPANY
: MSSQL_LIST_BY_COMPANY;
/**
* Lists the customer groups of the user's selected company.
*
* @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, sort: number}>>} The customer groups.
*
* @example
* const groups = await store.customergroup.list(42);
* // => [{ id, name, sort }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId, tx);
const result = await pool
.request()
.input('companyId', companyId)
.query(LIST_BY_COMPANY);
const result = await makeRunner(provider, tx).query(sql, { companyId });
return result.recordset.map((row) => ({
return result.rows.map((row) => ({
id: row.id,
name: row.name,
sort: row.sort,

View File

@@ -1,12 +1,21 @@
import {
LIST_IDS_BY_COMPANY,
UPDATE_SORT,
LIST_IDS_BY_COMPANY as MSSQL_LIST_IDS_BY_COMPANY,
UPDATE_SORT as MSSQL_UPDATE_SORT,
} from '../../providers/mssql/queries/customergroup.js';
import request from '../request.js';
import {
LIST_IDS_BY_COMPANY as SQLITE_LIST_IDS_BY_COMPANY,
UPDATE_SORT as SQLITE_UPDATE_SORT,
} from '../../providers/sqlite3/queries/customergroup.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function order(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function order(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const listIdsSql = isSqlite
? SQLITE_LIST_IDS_BY_COMPANY
: MSSQL_LIST_IDS_BY_COMPANY;
const updateSortSql = isSqlite ? SQLITE_UPDATE_SORT : MSSQL_UPDATE_SORT;
/**
* Reorders the customer groups of the user's selected company.
@@ -14,6 +23,7 @@ export default function order(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The reorder input.
* @param {Array<number>} input.customergroupIds - The customer group ids in their new order.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{customergroupIds: Array<number>}>} The applied order.
* @throws {Error} If the order is invalid.
*
@@ -21,58 +31,51 @@ export default function order(pool) {
* const result = await store.customergroup.order(42, { customergroupIds: [3, 1, 2] });
* // => { customergroupIds: [3, 1, 2] }
*/
return async (googleAuthId, { customergroupIds } = {}) => {
return async (googleAuthId, { customergroupIds } = {}, tx) => {
if (!Array.isArray(customergroupIds)) {
throw new Error('Invalid customer group order');
}
const { companyId } = await access(googleAuthId);
const runOrder = async (runner) => {
const { companyId } = await access(googleAuthId, runner);
const groups = await pool
.request()
.input('companyId', companyId)
.query(LIST_IDS_BY_COMPANY);
const groups = await runner.query(listIdsSql, { companyId });
const existing = new Set(groups.recordset.map((r) => r.id));
const existing = new Set(groups.rows.map((r) => r.id));
if (customergroupIds.length !== existing.size) {
throw new Error('Invalid customer group order');
}
const seen = new Set();
for (const customergroupId of customergroupIds) {
if (
!Number.isFinite(customergroupId) ||
!existing.has(customergroupId) ||
seen.has(customergroupId)
) {
if (customergroupIds.length !== existing.size) {
throw new Error('Invalid customer group order');
}
seen.add(customergroupId);
}
const tx = pool.transaction();
await tx.begin();
try {
for (let sort = 0; sort < customergroupIds.length; sort++) {
await request(pool, tx)
.input('id', customergroupIds[sort])
.input('companyId', companyId)
.input('sort', sort)
.query(UPDATE_SORT);
const seen = new Set();
for (const customergroupId of customergroupIds) {
if (
!Number.isFinite(customergroupId) ||
!existing.has(customergroupId) ||
seen.has(customergroupId)
) {
throw new Error('Invalid customer group order');
}
seen.add(customergroupId);
}
await tx.commit();
for (let sort = 0; sort < customergroupIds.length; sort++) {
await runner.query(updateSortSql, {
id: customergroupIds[sort],
companyId,
sort,
});
}
return { customergroupIds };
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
};
if (tx) {
return runOrder(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runOrder);
}
return runOrder(makeRunner(provider));
};
}

View File

@@ -1,19 +1,25 @@
import { GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js';
import request from '../request.js';
import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js';
import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/customergroup.js';
import makeRunner from '../runner.js';
/** Ensure customergroup belongs to company. Throws Customer group not found. */
export default function resolveCustomerGroupAccess(pool) {
export default function resolveCustomerGroupAccess(provider) {
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_GET_BY_COMPANY_AND_ID
: MSSQL_GET_BY_COMPANY_AND_ID;
return async (companyId, customergroupId, tx) => {
if (!Number.isFinite(customergroupId)) {
throw new Error('Customer group not found');
}
const result = await request(pool, tx)
.input('id', customergroupId)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const result = await makeRunner(provider, tx).query(sql, {
id: customergroupId,
companyId,
});
const row = result.recordset[0];
const row = result.rows[0];
if (!row) {
throw new Error('Customer group not found');
}

View File

@@ -1,6 +1,14 @@
import { GET_BY_COMPANY_AND_ID, UPDATE } from '../../providers/mssql/queries/customergroup.js';
import {
GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/customergroup.js';
import {
GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/customergroup.js';
import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
const LOCKED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]);
@@ -8,8 +16,13 @@ function shape(row) {
return { id: row.id, name: row.name, sort: row.sort };
}
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite
? SQLITE_GET_BY_COMPANY_AND_ID
: MSSQL_GET_BY_COMPANY_AND_ID;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Updates the name of a customer group for the user's selected company.
@@ -18,6 +31,7 @@ export default function update(pool) {
* @param {number} id - The id of the customer group to update.
* @param {object} input - The update input.
* @param {string} input.name - The new customer group name.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, name: string, sort: number}>} The updated customer group.
* @throws {Error} If the customer group is not found or the name is empty.
*
@@ -25,7 +39,7 @@ export default function update(pool) {
* const group = await store.customergroup.update(42, 5, { name: 'Retail' });
* // => { id, name, sort }
*/
return async (googleAuthId, id, { name } = {}) => {
return async (googleAuthId, id, { name } = {}, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Customer group not found');
}
@@ -35,15 +49,12 @@ export default function update(pool) {
throw new Error('Name required');
}
const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
const existing = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const existing = await runner.query(getSql, { id, companyId });
const current = existing.recordset[0];
const current = existing.rows[0];
if (!current) {
throw new Error('Customer group not found');
}
@@ -52,17 +63,13 @@ export default function update(pool) {
throw new Error('Name cannot be changed');
}
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.input('name', cleaned)
.query(UPDATE);
const result = await runner.query(updateSql, {
id,
companyId,
name: cleaned,
});
const row = result.recordset[0];
if (!row) {
throw new Error('Customer group not found');
}
const row = result.rows[0] ?? { id, name: cleaned, sort: current.sort };
return shape(row);
};
}

View File

@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import { LIST_BY_MANIFEST } from '../../providers/mssql/queries/manifestattachment.js';
import request from '../request.js';
import * as mssqlManifestAttachmentQueries from '../../providers/mssql/queries/manifestattachment.js';
import * as sqliteManifestAttachmentQueries from '../../providers/sqlite3/queries/manifestattachment.js';
import makeRunner from '../runner.js';
import generateDocumentThumb from '../../util/generateDocumentThumb.js';
import { parseMeta } from '../manifestattachment/listByManifestId.js';
@@ -33,12 +34,25 @@ export function shapeAttachmentMeta(row) {
/**
* Loads attachment metadata (without binary content) for a manifest.
*/
export async function loadAttachments(pool, tx, manifestId) {
const result = await request(pool, tx)
.input('manifestId', manifestId)
.query(LIST_BY_MANIFEST);
export async function loadAttachments(provider, tx, manifestId) {
const runner = makeRunner(provider, tx);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteManifestAttachmentQueries : mssqlManifestAttachmentQueries;
return result.recordset.map(shapeAttachmentMeta);
const result = await runner(queries.LIST_BY_MANIFEST, { manifestId });
const rows = await Promise.all(
result.rows.map(async (row) => {
let thumbavif = row.thumbavif;
if (isSqlite) {
thumbavif = await (runner.readThumbAvif ? runner.readThumbAvif(row.hash) : provider?.readThumbAvif?.(row.hash));
}
return shapeAttachmentMeta({
...row,
thumbavif,
});
}),
);
return rows;
}
function normalizeAttachment(raw, index) {
@@ -57,25 +71,26 @@ function normalizeAttachment(raw, index) {
throw new Error('Invalid file');
}
if (raw.content == null) {
if (rawContent(raw) == null) {
throw new Error('Invalid file');
}
let content;
if (Buffer.isBuffer(raw.content)) {
content = raw.content;
} else if (typeof raw.content === 'string') {
const trimmed = raw.content.trim();
const rawData = rawContent(raw);
if (Buffer.isBuffer(rawData)) {
content = rawData;
} else if (typeof rawData === 'string') {
const trimmed = rawData.trim();
if (mimetype === 'application/pdf' || trimmed.startsWith('JVBER') || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) {
try {
content = Buffer.from(trimmed, 'base64');
} catch {
content = Buffer.from(raw.content, 'utf8');
content = Buffer.from(rawData, 'utf8');
}
} else {
content = Buffer.from(raw.content, 'utf8');
content = Buffer.from(rawData, 'utf8');
}
} else {
content = Buffer.from(raw.content);
content = Buffer.from(rawData);
}
const size = Number.isFinite(raw.size) && raw.size >= 0
@@ -126,54 +141,80 @@ function normalizeAttachment(raw, index) {
};
}
function rawContent(raw) {
return raw.content;
}
/**
* Inserts pdf/txt attachments for a delivery (manifest) inside an existing
* transaction. Sort orders continue after the current maximum.
*
* @param {object} pool
* @param {object} provider
* @param {object} tx - Active transaction.
* @param {object} sql - SQL statements from delivery.js.
* @param {number} manifestId
* @param {Array<{mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string}>} [files]
* @returns {Promise<Array<object>>} Attachment metadata rows.
*/
export async function insertAttachments(pool, tx, sql, manifestId, files) {
export async function insertAttachments(provider, tx, sql, manifestId, files) {
const runner = makeRunner(provider, tx);
const attachments = (files ?? []).map(normalizeAttachment);
const sortRes = await request(pool, tx)
.input('manifestId', manifestId)
.query(sql.GET_NEXT_ATTACHMENT_SORT);
let nextSort = sortRes.recordset[0].sort;
const sortRes = await runner(sql.GET_NEXT_ATTACHMENT_SORT, { manifestId });
let nextSort = sortRes.rows[0].sort;
const results = [];
for (const a of attachments) {
const sort = a.sort ?? nextSort;
nextSort = Math.max(nextSort, sort) + 1;
// Generate thumbnail server-side (e.g. pdftocairo + sharp for PDF)
const { thumbavif, thumbx, thumby } = await generateDocumentThumb(a.content, a.mimetype, { filename: a.name });
// Use client-provided thumbavif if present, otherwise generate server-side
let thumbavif = a.thumbavif
? (Buffer.isBuffer(a.thumbavif)
? a.thumbavif
: Buffer.from(a.thumbavif, typeof a.thumbavif === 'string' ? 'base64' : undefined))
: null;
let thumbx = Number.isFinite(a.thumbx) ? Number(a.thumbx) : null;
let thumby = Number.isFinite(a.thumby) ? Number(a.thumby) : null;
if (!thumbavif) {
const generated = await generateDocumentThumb(a.content, a.mimetype, { filename: a.name });
thumbavif = generated.thumbavif;
thumbx = generated.thumbx;
thumby = generated.thumby;
}
if (provider?.dialect === 'sqlite3') {
if (runner.writeFileContent) {
await runner.writeFileContent(a.hash, a.content, a.mimetype);
if (thumbavif) await runner.writeThumbAvif(a.hash, thumbavif);
} else if (provider?.writeFileContent) {
await provider.writeFileContent(a.hash, a.content, a.mimetype);
if (thumbavif) await provider.writeThumbAvif(a.hash, thumbavif);
}
}
const fileRes = await request(pool, tx)
.input('hash', a.hash)
.input('mimetype', a.mimetype)
.input('name', a.name)
.input('size', a.size)
.input('created', a.created)
.input('modified', a.modified)
.input('content', a.content)
.input('thumbavif', thumbavif)
.input('thumbx', thumbx)
.input('thumby', thumby)
.input('meta', a.meta === null ? null : JSON.stringify(a.meta))
.query(sql.CREATE_FILE);
const fileRes = await runner(sql.CREATE_FILE, {
hash: a.hash,
mimetype: a.mimetype,
name: a.name,
size: a.size,
created: a.created,
modified: a.modified,
content: a.content,
thumbavif,
thumbx,
thumby,
meta: a.meta === null ? null : JSON.stringify(a.meta),
});
const fileId = fileRes.recordset[0].id;
const fileId = fileRes.rows[0]?.id ?? fileRes.insertId;
await request(pool, tx)
.input('manifestId', manifestId)
.input('fileId', fileId)
.input('sort', sort)
.query(sql.CREATE_ATTACHMENT);
await runner(sql.CREATE_ATTACHMENT, {
manifestId,
fileId,
sort,
});
results.push({
manifestId,

View File

@@ -1,11 +1,5 @@
import {
CREATE_MANIFEST,
CREATE_FILE,
CREATE_ATTACHMENT,
GET_NEXT_ATTACHMENT_SORT,
DELIVERY_TYPE,
} from '../../providers/mssql/queries/delivery.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import nextNumber from '../numbers/nextNumber.js';
import taxEnsure from '../tax/ensure.js';
@@ -13,30 +7,15 @@ import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { loadDelivery } from './helpers.js';
import { insertAttachments } from './attachments.js';
import { assertSupplier } from './writeLines.js';
import makeRunner from '../runner.js';
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
const allocateNumber = nextNumber(pool);
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const ensureTax = taxEnsure(provider);
const allocateNumber = nextNumber(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
/**
* Creates a new delivery for the user's selected company with its lines.
* The delivery number is allocated internally via `getnextnumberwithprefixandpadding`
* (`type` `delivery`); supply `externalNumber` for an external (supplier) reference.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} body - The delivery input.
* @param {string} body.date - The delivery date.
* @param {number} body.supplierId - The id of the supplier.
* @param {string} [body.externalNumber] - An external (supplier) reference number.
* @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - The delivery lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The created delivery.
* @throws {Error} If the delivery is invalid.
*
* @example
* const delivery = await store.delivery.create(42, { date: '2026-01-01', supplierId: 5, lines: [...] });
* // => { id, number, type, entityId, externalNumber, date, lines: [...] }
*/
return async (googleAuthId, body = {}) => {
const date = parseDate(body.date);
if (!date) {
@@ -50,58 +29,51 @@ export default function create(pool) {
const { companyId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
try {
const run = async (runner, tx) => {
const supplierId = await assertSupplier(
pool,
provider,
tx,
companyId,
body.supplierId ?? null,
);
const number = await allocateNumber(companyId, DELIVERY_TYPE, tx);
const number = await allocateNumber(companyId, queries.DELIVERY_TYPE, tx);
const created = await request(pool, tx)
.input('companyId', companyId)
.input('number', number)
.input('type', DELIVERY_TYPE)
.input('entityId', supplierId)
.input('externalNumber', externalNumber)
.input('date', date)
.query(CREATE_MANIFEST);
const created = await runner(queries.CREATE_MANIFEST, {
companyId,
number,
type: queries.DELIVERY_TYPE,
entityId: supplierId,
externalNumber,
date,
});
const manifestId = created.recordset[0].id;
const manifestId = created.rows[0]?.id ?? created.insertId;
await insertLines(pool, tx, {
await insertLines(provider, tx, {
manifestId,
lines,
ensureTax,
});
await insertAttachments(
pool,
provider,
tx,
{
GET_NEXT_ATTACHMENT_SORT,
CREATE_FILE,
CREATE_ATTACHMENT,
GET_NEXT_ATTACHMENT_SORT: queries.GET_NEXT_ATTACHMENT_SORT,
CREATE_FILE: queries.CREATE_FILE,
CREATE_ATTACHMENT: queries.CREATE_ATTACHMENT,
},
manifestId,
body.files,
);
const delivery = await loadDelivery(pool, tx, companyId, manifestId);
await tx.commit();
return delivery;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
return loadDelivery(provider, tx, companyId, manifestId);
};
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, tx) => run(runner, tx));
}
return run(makeRunner(provider));
};
}

View File

@@ -1,35 +1,29 @@
import { DELETE_MANIFEST, DELIVERY_TYPE } from '../../providers/mssql/queries/delivery.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
/**
* Deletes a delivery by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the delivery to delete.
* @returns {Promise<void>}
* @throws {Error} If the delivery is not found.
*
* @example
* await store.delivery.delete(42, 5);
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Delivery not found');
}
const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider);
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.input('type', DELIVERY_TYPE)
.query(DELETE_MANIFEST);
const result = await runner(queries.DELETE_MANIFEST, {
id,
companyId,
type: queries.DELIVERY_TYPE,
});
if (!result.rowsAffected[0]) {
const rowsAffected = result.rowsAffected?.[0] ?? (typeof result.rowsAffected === 'number' ? result.rowsAffected : 0);
if (!rowsAffected) {
throw new Error('Delivery not found');
}
};

View File

@@ -1,21 +1,9 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { loadDelivery } from './helpers.js';
export default function getById(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function getById(provider) {
const access = resolveSelectedCompanyAccess(provider);
/**
* Returns a single delivery by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the delivery.
* @returns {Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The delivery.
* @throws {Error} If the delivery is not found.
*
* @example
* const delivery = await store.delivery.getById(42, 5);
* // => { id, number, type, entityId, externalNumber, date, lines: [...] }
*/
return async (googleAuthId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Delivery not found');
@@ -23,7 +11,7 @@ export default function getById(pool) {
const { companyId } = await access(googleAuthId);
const delivery = await loadDelivery(pool, null, companyId, id);
const delivery = await loadDelivery(provider, null, companyId, id);
if (!delivery) {
throw new Error('Delivery not found');
}

View File

@@ -1,12 +1,11 @@
import {
DELIVERY_TYPE,
GET_DELIVERY_HEADER,
GET_DELIVERY_LINES,
} from '../../providers/mssql/queries/delivery.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import makeRunner from '../runner.js';
import { shapeLine } from '../manifestLines.js';
import { loadAttachments } from './attachments.js';
export const DELIVERY_TYPE = mssqlQueries.DELIVERY_TYPE;
export function shapeSummary(row) {
return {
id: row.id,
@@ -18,27 +17,31 @@ export function shapeSummary(row) {
};
}
export async function loadDelivery(pool, tx, companyId, id) {
const header = await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', DELIVERY_TYPE)
.query(GET_DELIVERY_HEADER);
export async function loadDelivery(provider, tx, companyId, id) {
const runner = makeRunner(provider, tx);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const row = header.recordset[0];
const header = await runner(queries.GET_DELIVERY_HEADER, {
id,
companyId,
type: DELIVERY_TYPE,
});
const row = header.rows[0];
if (!row) {
return null;
}
const lines = await request(pool, tx)
.input('manifestId', id)
.query(GET_DELIVERY_LINES);
const lines = await runner(queries.GET_DELIVERY_LINES, {
manifestId: id,
});
const files = await loadAttachments(pool, tx, id);
const files = await loadAttachments(provider, tx, id);
return {
...shapeSummary(row),
lines: lines.recordset.map(shapeLine),
lines: lines.rows.map(shapeLine),
files,
};
}

View File

@@ -1,45 +1,37 @@
import { DELIVERY_TYPE, LIST_DELIVERIES, LIST_DELIVERY_FILES } from '../../providers/mssql/queries/delivery.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import makeRunner from '../runner.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { shapeSummary } from './helpers.js';
import { shapeAttachmentMeta } from './attachments.js';
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
/**
* Lists the deliveries of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @returns {Promise<Array<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string}>>} The deliveries.
*
* @example
* const deliveries = await store.delivery.list(42);
* // => [{ id, number, type, entityId, externalNumber, date }]
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider);
const result = await pool
.request()
.input('companyId', companyId)
.input('type', DELIVERY_TYPE)
.query(LIST_DELIVERIES);
const result = await runner(queries.LIST_DELIVERIES, {
companyId,
type: queries.DELIVERY_TYPE,
});
const filesResult = await pool
.request()
.input('companyId', companyId)
.input('type', DELIVERY_TYPE)
.query(LIST_DELIVERY_FILES);
const filesResult = await runner(queries.LIST_DELIVERY_FILES, {
companyId,
type: queries.DELIVERY_TYPE,
});
const filesByManifest = new Map();
for (const row of filesResult.recordset) {
for (const row of filesResult.rows) {
const list = filesByManifest.get(row.manifestId) ?? [];
list.push(shapeAttachmentMeta(row));
filesByManifest.set(row.manifestId, list);
}
return result.recordset.map((row) => ({
return result.rows.map((row) => ({
...shapeSummary(row),
files: filesByManifest.get(row.id) ?? [],
}));

View File

@@ -1,42 +1,19 @@
import {
DELETE_LINES_BY_MANIFEST,
DELIVERY_TYPE,
GET_DELIVERY_HEADER,
GET_NEXT_ATTACHMENT_SORT,
CREATE_FILE,
CREATE_ATTACHMENT,
UPDATE_MANIFEST,
} from '../../providers/mssql/queries/delivery.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { loadDelivery, shapeSummary } from './helpers.js';
import { insertAttachments } from './attachments.js';
import { assertSupplier } from './writeLines.js';
import makeRunner from '../runner.js';
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const ensureTax = taxEnsure(pool);
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const ensureTax = taxEnsure(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
/**
* Updates a delivery's header and/or lines.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the delivery to update.
* @param {object} patch - The update patch.
* @param {string} [patch.number] - The new delivery number.
* @param {string} [patch.date] - The new delivery date.
* @param {number} [patch.supplierId] - The new supplier id.
* @param {string} [patch.externalNumber] - The new external (supplier) reference number.
* @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [patch.lines] - The new delivery lines.
* @returns {Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The updated delivery.
* @throws {Error} If the delivery is not found.
*
* @example
* const delivery = await store.delivery.update(42, 5, { number: 'D-2', lines: [...] });
* // => { id, number, type, entityId, date, lines: [...] }
*/
return async (googleAuthId, id, patch = {}) => {
if (!Number.isFinite(id)) {
throw new Error('Delivery not found');
@@ -63,17 +40,14 @@ export default function update(pool) {
const { companyId } = await access(googleAuthId);
const tx = pool.transaction();
await tx.begin();
const run = async (runner, tx) => {
const existing = await runner(queries.GET_DELIVERY_HEADER, {
id,
companyId,
type: queries.DELIVERY_TYPE,
});
try {
const existing = await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', DELIVERY_TYPE)
.query(GET_DELIVERY_HEADER);
const current = existing.recordset[0];
const current = existing.rows[0];
if (!current) {
throw new Error('Delivery not found');
}
@@ -99,7 +73,7 @@ export default function update(pool) {
let supplierId = summary.supplierId;
if (hasSupplierId) {
supplierId = await assertSupplier(
pool,
provider,
tx,
companyId,
patch.supplierId ?? null,
@@ -113,22 +87,22 @@ export default function update(pool) {
: null;
}
await request(pool, tx)
.input('id', id)
.input('companyId', companyId)
.input('type', DELIVERY_TYPE)
.input('number', number)
.input('entityId', supplierId)
.input('externalNumber', externalNumber)
.input('date', date)
.query(UPDATE_MANIFEST);
await runner(queries.UPDATE_MANIFEST, {
id,
companyId,
type: queries.DELIVERY_TYPE,
number,
entityId: supplierId,
externalNumber,
date,
});
if (hasLines) {
await request(pool, tx)
.input('manifestId', id)
.query(DELETE_LINES_BY_MANIFEST);
await runner(queries.DELETE_LINES_BY_MANIFEST, {
manifestId: id,
});
await insertLines(pool, tx, {
await insertLines(provider, tx, {
manifestId: id,
lines: normalizedLines,
ensureTax,
@@ -137,28 +111,24 @@ export default function update(pool) {
if (hasFiles) {
await insertAttachments(
pool,
provider,
tx,
{
GET_NEXT_ATTACHMENT_SORT,
CREATE_FILE,
CREATE_ATTACHMENT,
GET_NEXT_ATTACHMENT_SORT: queries.GET_NEXT_ATTACHMENT_SORT,
CREATE_FILE: queries.CREATE_FILE,
CREATE_ATTACHMENT: queries.CREATE_ATTACHMENT,
},
id,
patch.files,
);
}
const delivery = await loadDelivery(pool, tx, companyId, id);
await tx.commit();
return delivery;
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
return loadDelivery(provider, tx, companyId, id);
};
if (typeof provider?.transaction === 'function') {
return provider.transaction((runner, tx) => run(runner, tx));
}
return run(makeRunner(provider));
};
}

View File

@@ -1,7 +1,8 @@
import { GET_SUPPLIER_BY_ID, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import request from '../request.js';
import * as mssqlQueries from '../../providers/mssql/queries/entity.js';
import * as sqliteQueries from '../../providers/sqlite3/queries/entity.js';
import makeRunner from '../runner.js';
export async function assertSupplier(pool, tx, companyId, supplierId) {
export async function assertSupplier(provider, tx, companyId, supplierId) {
if (supplierId == null) {
return null;
}
@@ -9,13 +10,17 @@ export async function assertSupplier(pool, tx, companyId, supplierId) {
throw new Error('Supplier not found');
}
const result = await request(pool, tx)
.input('id', supplierId)
.input('companyId', companyId)
.input('groupName', SUPPLIER_GROUP_NAME)
.query(GET_SUPPLIER_BY_ID);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteQueries : mssqlQueries;
const runner = makeRunner(provider, tx);
if (!result.recordset[0]) {
const result = await runner(queries.GET_SUPPLIER_BY_ID, {
id: supplierId,
companyId,
groupName: queries.SUPPLIER_GROUP_NAME,
});
if (!result.rows[0]) {
throw new Error('Supplier not found');
}
return supplierId;

View File

@@ -1,19 +1,26 @@
import { CREATE } from '../../providers/mssql/queries/entity.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js';
import request from '../request.js';
import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/entity.js';
import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/entity.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/entityhistory.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js';
import nextNumber from '../numbers/nextNumber.js';
import shape from './shape.js';
import makeRunner from '../runner.js';
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const groupAccess = resolveCustomerGroupAccess(pool);
const allocateNumber = nextNumber(pool);
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const groupAccess = resolveCustomerGroupAccess(provider);
const allocateNumber = nextNumber(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Creates a new entity (customer) in a customer group, recording history.
* Optional `number`; if omitted, allocates via `getnextnumberwithprefixandpadding`
* Optional `number`; if omitted, allocates via `numbers.nextNumber`
* (`type` `entity`).
*
* @param {number} googleAuthId - The id of the Google auth user.
@@ -22,6 +29,7 @@ export default function create(pool) {
* @param {string} input.name - The entity name.
* @param {string} [input.firstName] - The entity first name.
* @param {string} [input.number] - Optional explicit customer number.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The created entity.
* @throws {Error} If the name is empty.
*
@@ -29,7 +37,7 @@ export default function create(pool) {
* const entity = await store.entity.create(42, { customergroupId: 5, name: 'Doe', firstName: 'John' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
return async (googleAuthId, { customergroupId, name, firstName = null, number } = {}) => {
return async (googleAuthId, { customergroupId, name, firstName = null, number } = {}, tx) => {
const cleaned = name?.trim();
if (!cleaned) {
throw new Error('Name required');
@@ -45,43 +53,50 @@ export default function create(pool) {
? null
: String(number).trim() || null;
const { companyId, companyuserId } = await access(googleAuthId);
await groupAccess(companyId, customergroupId);
const runCreate = async (runner) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
await groupAccess(companyId, customergroupId, runner);
const tx = pool.transaction();
await tx.begin();
try {
const entityNumber =
explicitNumber ?? (await allocateNumber(companyId, 'entity', tx));
explicitNumber ?? (await allocateNumber(companyId, 'entity', runner));
const created = await request(pool, tx)
.input('customergroupId', customergroupId)
.input('firstName', first)
.input('name', cleaned)
.input('number', entityNumber)
.query(CREATE);
const row = created.recordset[0];
await request(pool, tx)
.input('entityId', row.id)
.input('companyuserId', companyuserId)
.input('text', 'Erstellt')
.query(INSERT_HISTORY);
await tx.commit();
return shape(row);
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
const created = await runner.query(createSql, {
customergroupId,
firstName: first,
name: cleaned,
number: entityNumber,
});
const row = created.rows[0] ?? {
id: created.insertId,
customergroupId,
firstName: first,
name: cleaned,
number: entityNumber,
};
await runner.query(insertHistorySql, {
entityId: row.id,
companyuserId,
text: 'Erstellt',
});
return shape(row);
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Number already in use');
}
throw err;
}
if (err.number === 2601 || err.number === 2627) {
throw new Error('Number already in use');
}
throw err;
};
if (tx) {
return runCreate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
};
}

View File

@@ -1,34 +1,37 @@
import { DELETE_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js';
import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entity.js';
import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes an entity by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the entity to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the entity is not found.
*
* @example
* await store.entity.delete(42, 5);
*/
return async (googleAuthId, id) => {
return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Entity not found');
}
const { companyId } = await access(googleAuthId);
const { companyId } = await access(googleAuthId, tx);
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.query(DELETE_BY_COMPANY_AND_ID);
const result = await makeRunner(provider, tx).query(sql, {
id,
companyId,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Entity not found');
}
};

View File

@@ -1,15 +1,22 @@
import { GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js';
import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js';
import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shape from './shape.js';
import makeRunner from '../runner.js';
export default function getById(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function getById(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_GET_BY_COMPANY_AND_ID
: MSSQL_GET_BY_COMPANY_AND_ID;
/**
* Returns a single entity by its id within the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the entity.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The entity.
* @throws {Error} If the entity is not found.
*
@@ -17,20 +24,19 @@ export default function getById(pool) {
* const entity = await store.entity.getById(42, 5);
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
return async (googleAuthId, id) => {
return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Entity not found');
}
const { companyId } = await access(googleAuthId);
const { companyId } = await access(googleAuthId, tx);
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const result = await makeRunner(provider, tx).query(sql, {
id,
companyId,
});
const row = result.recordset[0];
const row = result.rows[0];
if (!row) {
throw new Error('Entity not found');
}

View File

@@ -1,8 +1,14 @@
import { NUMBER_TAKEN_IN_COMPANY } from '../../providers/mssql/queries/entity.js';
import { NUMBER_TAKEN_IN_COMPANY as MSSQL_NUMBER_TAKEN } from '../../providers/mssql/queries/entity.js';
import { NUMBER_TAKEN_IN_COMPANY as SQLITE_NUMBER_TAKEN } from '../../providers/sqlite3/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function isNumberFree(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function isNumberFree(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_NUMBER_TAKEN
: MSSQL_NUMBER_TAKEN;
/**
* Returns whether a customer number is free within the user's selected company.
@@ -12,6 +18,7 @@ export default function isNumberFree(pool) {
* @param {string} number - The customer number to check.
* @param {object} [options] - Optional options.
* @param {number} [options.excludeEntityId] - An entity id to exclude from the check.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<boolean>} Whether the number is free.
* @throws {Error} If the number is empty.
*
@@ -21,7 +28,7 @@ export default function isNumberFree(pool) {
* const freeOnEdit = await store.entity.isNumberFree(42, 'C-100', { excludeEntityId: 5 });
* // => true
*/
return async (googleAuthId, number, { excludeEntityId } = {}) => {
return async (googleAuthId, number, { excludeEntityId } = {}, tx) => {
const cleaned =
number == null || number === '' ? null : String(number).trim() || null;
if (!cleaned) {
@@ -35,15 +42,14 @@ export default function isNumberFree(pool) {
throw new Error('Entity not found');
}
const { companyId } = await access(googleAuthId);
const { companyId } = await access(googleAuthId, tx);
const result = await pool
.request()
.input('companyId', companyId)
.input('number', cleaned)
.input('excludeEntityId', excludeEntityId ?? null)
.query(NUMBER_TAKEN_IN_COMPANY);
const result = await makeRunner(provider, tx).query(sql, {
companyId,
number: cleaned,
excludeEntityId: excludeEntityId ?? null,
});
return !result.recordset[0];
return !result.rows[0];
};
}

View File

@@ -1,17 +1,34 @@
import {
LIST_BY_COMPANY,
LIST_BY_COMPANY_AND_GROUP,
LIST_CUSTOMERS_BY_COMPANY,
LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
LIST_BY_COMPANY_AND_GROUP as MSSQL_LIST_BY_COMPANY_AND_GROUP,
LIST_CUSTOMERS_BY_COMPANY as MSSQL_LIST_CUSTOMERS_BY_COMPANY,
POS_GROUP_NAME,
SUPPLIER_GROUP_NAME,
} from '../../providers/mssql/queries/entity.js';
import {
LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY,
LIST_BY_COMPANY_AND_GROUP as SQLITE_LIST_BY_COMPANY_AND_GROUP,
LIST_CUSTOMERS_BY_COMPANY as SQLITE_LIST_CUSTOMERS_BY_COMPANY,
} from '../../providers/sqlite3/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js';
import shape from './shape.js';
import makeRunner from '../runner.js';
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
const groupAccess = resolveCustomerGroupAccess(pool);
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const groupAccess = resolveCustomerGroupAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const listAllSql = isSqlite
? SQLITE_LIST_BY_COMPANY
: MSSQL_LIST_BY_COMPANY;
const listGroupSql = isSqlite
? SQLITE_LIST_BY_COMPANY_AND_GROUP
: MSSQL_LIST_BY_COMPANY_AND_GROUP;
const listCustomersSql = isSqlite
? SQLITE_LIST_CUSTOMERS_BY_COMPANY
: MSSQL_LIST_CUSTOMERS_BY_COMPANY;
/**
* Lists the entities (customers) of the user's selected company, optionally
@@ -21,6 +38,7 @@ export default function list(pool) {
* @param {object} [options] - Optional options.
* @param {number} [options.customergroupId] - Filter by customer group id.
* @param {string} [options.role] - Restrict to `'customer'` role if set.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>>} The entities.
* @throws {Error} If the role is invalid.
*
@@ -28,38 +46,33 @@ export default function list(pool) {
* const entities = await store.entity.list(42, { customergroupId: 5 });
* // => [{ id, customergroupId, firstName, name, number, createdAt, updatedAt }]
*/
return async (googleAuthId, { customergroupId, role } = {}) => {
const { companyId } = await access(googleAuthId);
return async (googleAuthId, { customergroupId, role } = {}, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
if (role != null && role !== 'customer') {
throw new Error('Invalid role');
}
if (role === 'customer') {
const result = await pool
.request()
.input('companyId', companyId)
.input('supplierGroupName', SUPPLIER_GROUP_NAME)
.input('posGroupName', POS_GROUP_NAME)
.query(LIST_CUSTOMERS_BY_COMPANY);
return result.recordset.map(shape);
const result = await runner.query(listCustomersSql, {
companyId,
supplierGroupName: SUPPLIER_GROUP_NAME,
posGroupName: POS_GROUP_NAME,
});
return result.rows.map(shape);
}
if (customergroupId != null) {
await groupAccess(companyId, customergroupId);
const result = await pool
.request()
.input('companyId', companyId)
.input('customergroupId', customergroupId)
.query(LIST_BY_COMPANY_AND_GROUP);
return result.recordset.map(shape);
await groupAccess(companyId, customergroupId, tx);
const result = await runner.query(listGroupSql, {
companyId,
customergroupId,
});
return result.rows.map(shape);
}
const result = await pool
.request()
.input('companyId', companyId)
.query(LIST_BY_COMPANY);
return result.recordset.map(shape);
const result = await runner.query(listAllSql, { companyId });
return result.rows.map(shape);
};
}

View File

@@ -1,23 +1,24 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import peekNumber from '../numbers/peekNumber.js';
export default function peekNextNumber(pool) {
const access = resolveSelectedCompanyAccess(pool);
const peek = peekNumber(pool);
export default function peekNextNumber(provider) {
const access = resolveSelectedCompanyAccess(provider);
const peek = peekNumber(provider);
/**
* Returns the next customer number that would be allocated for the user's
* selected company (`numbers` type `entity`) without consuming it.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<string>} The next entity number.
*
* @example
* const number = await store.entity.peekNextNumber(42);
* // => '1'
*/
return async (googleAuthId) => {
const { companyId } = await access(googleAuthId);
return peek(companyId, 'entity');
return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId, tx);
return peek(companyId, 'entity', tx);
};
}

View File

@@ -1,19 +1,25 @@
import { GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js';
import request from '../request.js';
import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/entity.js';
import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID } from '../../providers/sqlite3/queries/entity.js';
import makeRunner from '../runner.js';
/** Ensure entity belongs to company. Throws Entity not found. */
export default function resolveEntityAccess(pool) {
export default function resolveEntityAccess(provider) {
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_GET_BY_COMPANY_AND_ID
: MSSQL_GET_BY_COMPANY_AND_ID;
return async (companyId, entityId, tx) => {
if (!Number.isFinite(entityId)) {
throw new Error('Entity not found');
}
const result = await request(pool, tx)
.input('id', entityId)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const result = await makeRunner(provider, tx).query(sql, {
id: entityId,
companyId,
});
const row = result.recordset[0];
const row = result.rows[0];
if (!row) {
throw new Error('Entity not found');
}

View File

@@ -1,11 +1,28 @@
import { GET_BY_COMPANY_AND_ID, UPDATE } from '../../providers/mssql/queries/entity.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js';
import request from '../request.js';
import {
GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/entity.js';
import {
GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_COMPANY_AND_ID,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/entity.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/entityhistory.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shape from './shape.js';
import makeRunner from '../runner.js';
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite
? SQLITE_GET_BY_COMPANY_AND_ID
: MSSQL_GET_BY_COMPANY_AND_ID;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
const insertHistorySql = isSqlite
? SQLITE_INSERT_HISTORY
: MSSQL_INSERT_HISTORY;
/**
* Updates name, first name, and/or customer number of an entity, recording history.
@@ -17,6 +34,7 @@ export default function update(pool) {
* @param {string} [patch.name] - The new entity name.
* @param {string} [patch.firstName] - The new first name.
* @param {string} [patch.number] - The new customer number.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>} The updated entity.
* @throws {Error} If no valid field is provided or the entity is not found.
*
@@ -24,7 +42,7 @@ export default function update(pool) {
* const entity = await store.entity.update(42, 5, { name: 'Doe', firstName: 'Jane', number: 'C-42' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/
return async (googleAuthId, id, patch = {}) => {
return async (googleAuthId, id, patch = {}, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Entity not found');
}
@@ -36,102 +54,101 @@ export default function update(pool) {
throw new Error('Name required');
}
const { companyId, companyuserId } = await access(googleAuthId);
const runUpdate = async (runner) => {
const { companyId, companyuserId } = await access(googleAuthId, runner);
const existing = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const existing = await runner.query(getSql, { id, companyId });
const current = existing.recordset[0];
if (!current) {
throw new Error('Entity not found');
}
let name = current.name;
if (hasName) {
name = patch.name?.trim();
if (!name) {
throw new Error('Name required');
}
}
let firstName = current.firstName ?? null;
if (hasFirstName) {
firstName =
patch.firstName == null || patch.firstName === ''
? null
: String(patch.firstName).trim() || null;
}
let number = current.number ?? null;
if (hasNumber) {
number =
patch.number == null || patch.number === ''
? null
: String(patch.number).trim() || null;
}
const historyLines = [];
const oldFirst = current.firstName ?? '';
const newFirst = firstName ?? '';
if (oldFirst !== newFirst) {
historyLines.push(
`Vorname geändert von "${oldFirst}" auf "${newFirst}"`,
);
}
if (current.name !== name) {
historyLines.push(
`Name geändert von "${current.name}" auf "${name}"`,
);
}
const oldNumber = current.number ?? '';
const newNumber = number ?? '';
if (oldNumber !== newNumber) {
historyLines.push(
`Nummer geändert von "${oldNumber}" auf "${newNumber}"`,
);
}
if (!historyLines.length) {
return shape(current);
}
const tx = pool.transaction();
await tx.begin();
try {
const updated = await request(pool, tx)
.input('id', id)
.input('firstName', firstName)
.input('name', name)
.input('number', number)
.query(UPDATE);
const row = updated.recordset[0];
if (!row) {
const current = existing.rows[0];
if (!current) {
throw new Error('Entity not found');
}
await request(pool, tx)
.input('entityId', id)
.input('companyuserId', companyuserId)
.input('text', historyLines.join('\n'))
.query(INSERT_HISTORY);
let name = current.name;
if (hasName) {
name = patch.name?.trim();
if (!name) {
throw new Error('Name required');
}
}
let firstName = current.firstName ?? null;
if (hasFirstName) {
firstName =
patch.firstName == null || patch.firstName === ''
? null
: String(patch.firstName).trim() || null;
}
let number = current.number ?? null;
if (hasNumber) {
number =
patch.number == null || patch.number === ''
? null
: String(patch.number).trim() || null;
}
const historyLines = [];
const oldFirst = current.firstName ?? '';
const newFirst = firstName ?? '';
if (oldFirst !== newFirst) {
historyLines.push(
`Vorname geändert von "${oldFirst}" auf "${newFirst}"`,
);
}
if (current.name !== name) {
historyLines.push(
`Name geändert von "${current.name}" auf "${name}"`,
);
}
const oldNumber = current.number ?? '';
const newNumber = number ?? '';
if (oldNumber !== newNumber) {
historyLines.push(
`Nummer geändert von "${oldNumber}" auf "${newNumber}"`,
);
}
if (!historyLines.length) {
return shape(current);
}
await tx.commit();
return shape(row);
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
const updated = await runner.query(updateSql, {
id,
firstName,
name,
number,
});
const row = updated.rows[0] ?? {
...current,
firstName,
name,
number,
};
await runner.query(insertHistorySql, {
entityId: id,
companyuserId,
text: historyLines.join('\n'),
});
return shape(row);
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Number already in use');
}
throw err;
}
if (err.number === 2601 || err.number === 2627) {
throw new Error('Number already in use');
}
throw err;
};
if (tx) {
return runUpdate(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runUpdate);
}
return runUpdate(makeRunner(provider));
};
}

View File

@@ -1,6 +1,14 @@
import { CREATE, GET_NEXT_SORT } from '../../providers/mssql/queries/entityaddress.js';
import {
CREATE as MSSQL_CREATE,
GET_NEXT_SORT as MSSQL_GET_NEXT_SORT,
} from '../../providers/mssql/queries/entityaddress.js';
import {
CREATE as SQLITE_CREATE,
GET_NEXT_SORT as SQLITE_GET_NEXT_SORT,
} from '../../providers/sqlite3/queries/entityaddress.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
@@ -24,9 +32,12 @@ function optionalString(value) {
return s || null;
}
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT;
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Creates a new address for an entity.
@@ -43,6 +54,7 @@ export default function create(pool) {
* @param {string} [input.city] - The city.
* @param {string} [input.county] - The county.
* @param {number} [input.sort] - Optional sort order.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>} The created address.
* @throws {Error} If the address type is missing.
*
@@ -50,7 +62,7 @@ export default function create(pool) {
* const address = await store.entityaddress.create(42, 5, { addressType: 'billing', street: 'Main St', city: 'Berlin' });
* // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }
*/
return async (googleAuthId, entityId, input = {}) => {
return async (googleAuthId, entityId, input = {}, tx) => {
const addressType = input.addressType?.trim?.()
? input.addressType.trim()
: null;
@@ -58,34 +70,45 @@ export default function create(pool) {
throw new Error('Address type required');
}
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
let sort = input.sort;
if (sort === undefined || sort === null) {
const next = await pool
.request()
.input('entityId', entityId)
.query(GET_NEXT_SORT);
sort = next.recordset[0].sort;
const next = await runner.query(getSortSql, { entityId });
sort = next.rows[0].sort;
} else if (!Number.isFinite(sort)) {
throw new Error('Invalid sort');
}
const result = await pool
.request()
.input('entityId', entityId)
.input('firstName', optionalString(input.firstName))
.input('name', optionalString(input.name))
.input('street', optionalString(input.street))
.input('streetNumber', optionalString(input.streetNumber))
.input('zip', optionalString(input.zip))
.input('city', optionalString(input.city))
.input('county', optionalString(input.county))
.input('addressType', addressType)
.input('sort', sort)
.query(CREATE);
const result = await runner.query(createSql, {
entityId,
firstName: optionalString(input.firstName),
name: optionalString(input.name),
street: optionalString(input.street),
streetNumber: optionalString(input.streetNumber),
zip: optionalString(input.zip),
city: optionalString(input.city),
county: optionalString(input.county),
addressType,
sort,
});
return shape(result.recordset[0]);
const row = result.rows[0] ?? {
id: result.insertId,
entityId,
firstName: optionalString(input.firstName),
name: optionalString(input.name),
street: optionalString(input.street),
streetNumber: optionalString(input.streetNumber),
zip: optionalString(input.zip),
city: optionalString(input.city),
county: optionalString(input.county),
addressType,
sort,
};
return shape(row);
};
}

View File

@@ -1,10 +1,13 @@
import { DELETE_BY_ENTITY_AND_ID } from '../../providers/mssql/queries/entityaddress.js';
import { DELETE_BY_ENTITY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entityaddress.js';
import { DELETE_BY_ENTITY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entityaddress.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes an address of an entity.
@@ -12,27 +15,28 @@ export default function del(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {number} id - The id of the address to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the address is not found.
*
* @example
* await store.entityaddress.delete(42, 5, 9);
*/
return async (googleAuthId, entityId, id) => {
return async (googleAuthId, entityId, id, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Address not found');
}
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool
.request()
.input('id', id)
.input('entityId', entityId)
.query(DELETE_BY_ENTITY_AND_ID);
const result = await runner.query(sql, {
id,
entityId,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Address not found');
}
};

View File

@@ -1,6 +1,8 @@
import { LIST_BY_ENTITY } from '../../providers/mssql/queries/entityaddress.js';
import { LIST_BY_ENTITY as MSSQL_LIST_BY_ENTITY } from '../../providers/mssql/queries/entityaddress.js';
import { LIST_BY_ENTITY as SQLITE_LIST_BY_ENTITY } from '../../providers/sqlite3/queries/entityaddress.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
@@ -18,30 +20,33 @@ function shape(row) {
};
}
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_LIST_BY_ENTITY
: MSSQL_LIST_BY_ENTITY;
/**
* Lists the addresses of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>>} The addresses.
*
* @example
* const addresses = await store.entityaddress.list(42, 5);
* // => [{ id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }]
*/
return async (googleAuthId, entityId) => {
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
return async (googleAuthId, entityId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool
.request()
.input('entityId', entityId)
.query(LIST_BY_ENTITY);
const result = await runner.query(sql, { entityId });
return result.recordset.map(shape);
return result.rows.map(shape);
};
}

View File

@@ -1,6 +1,14 @@
import { GET_BY_ENTITY_AND_ID, UPDATE } from '../../providers/mssql/queries/entityaddress.js';
import {
GET_BY_ENTITY_AND_ID as MSSQL_GET_BY_ENTITY_AND_ID,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/entityaddress.js';
import {
GET_BY_ENTITY_AND_ID as SQLITE_GET_BY_ENTITY_AND_ID,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/entityaddress.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
@@ -24,9 +32,14 @@ function optionalString(value) {
return s || null;
}
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite
? SQLITE_GET_BY_ENTITY_AND_ID
: MSSQL_GET_BY_ENTITY_AND_ID;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Updates an address of an entity.
@@ -44,6 +57,7 @@ export default function update(pool) {
* @param {string} [patch.city] - The city.
* @param {string} [patch.county] - The county.
* @param {number} [patch.sort] - The sort order.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>} The updated address.
* @throws {Error} If the address is not found.
*
@@ -51,21 +65,18 @@ export default function update(pool) {
* const address = await store.entityaddress.update(42, 5, 9, { city: 'Hamburg' });
* // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }
*/
return async (googleAuthId, entityId, id, patch = {}) => {
return async (googleAuthId, entityId, id, patch = {}, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Address not found');
}
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const existing = await pool
.request()
.input('id', id)
.input('entityId', entityId)
.query(GET_BY_ENTITY_AND_ID);
const existing = await runner.query(getSql, { id, entityId });
const current = existing.recordset[0];
const current = existing.rows[0];
if (!current) {
throw new Error('Address not found');
}
@@ -113,25 +124,34 @@ export default function update(pool) {
sort = patch.sort;
}
const result = await pool
.request()
.input('id', id)
.input('entityId', entityId)
.input('firstName', firstName)
.input('name', name)
.input('street', street)
.input('streetNumber', streetNumber)
.input('zip', zip)
.input('city', city)
.input('county', county)
.input('addressType', addressType)
.input('sort', sort)
.query(UPDATE);
const result = await runner.query(updateSql, {
id,
entityId,
firstName,
name,
street,
streetNumber,
zip,
city,
county,
addressType,
sort,
});
const row = result.rows[0] ?? {
id,
entityId,
firstName,
name,
street,
streetNumber,
zip,
city,
county,
addressType,
sort,
};
const row = result.recordset[0];
if (!row) {
throw new Error('Address not found');
}
return shape(row);
};
}

View File

@@ -1,6 +1,14 @@
import { CREATE, GET_NEXT_SORT } from '../../providers/mssql/queries/entitycontact.js';
import {
CREATE as MSSQL_CREATE,
GET_NEXT_SORT as MSSQL_GET_NEXT_SORT,
} from '../../providers/mssql/queries/entitycontact.js';
import {
CREATE as SQLITE_CREATE,
GET_NEXT_SORT as SQLITE_GET_NEXT_SORT,
} from '../../providers/sqlite3/queries/entitycontact.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
@@ -12,9 +20,12 @@ function shape(row) {
};
}
export default function create(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT;
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Creates a new contact for an entity.
@@ -25,6 +36,7 @@ export default function create(pool) {
* @param {string} input.contactType - The contact type (e.g. `'email'`).
* @param {string} input.contact - The contact value.
* @param {number} [input.sort] - Optional sort order.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>} The created contact.
* @throws {Error} If the contact type or value is missing.
*
@@ -32,7 +44,7 @@ export default function create(pool) {
* const contact = await store.entitycontact.create(42, 5, { contactType: 'email', contact: 'a@b.c' });
* // => { id, entityId, contactType, contact, sort }
*/
return async (googleAuthId, entityId, input = {}) => {
return async (googleAuthId, entityId, input = {}, tx) => {
const contactType = input.contactType?.trim?.()
? input.contactType.trim()
: null;
@@ -41,28 +53,33 @@ export default function create(pool) {
throw new Error('Contact required');
}
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
let sort = input.sort;
if (sort === undefined || sort === null) {
const next = await pool
.request()
.input('entityId', entityId)
.query(GET_NEXT_SORT);
sort = next.recordset[0].sort;
const next = await runner.query(getSortSql, { entityId });
sort = next.rows[0].sort;
} else if (!Number.isFinite(sort)) {
throw new Error('Invalid sort');
}
const result = await pool
.request()
.input('entityId', entityId)
.input('contactType', contactType)
.input('contact', contact)
.input('sort', sort)
.query(CREATE);
const result = await runner.query(createSql, {
entityId,
contactType,
contact,
sort,
});
return shape(result.recordset[0]);
const row = result.rows[0] ?? {
id: result.insertId,
entityId,
contactType,
contact,
sort,
};
return shape(row);
};
}

View File

@@ -1,10 +1,13 @@
import { DELETE_BY_ENTITY_AND_ID } from '../../providers/mssql/queries/entitycontact.js';
import { DELETE_BY_ENTITY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entitycontact.js';
import { DELETE_BY_ENTITY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entitycontact.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a contact of an entity.
@@ -12,27 +15,28 @@ export default function del(pool) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {number} id - The id of the contact to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the contact is not found.
*
* @example
* await store.entitycontact.delete(42, 5, 9);
*/
return async (googleAuthId, entityId, id) => {
return async (googleAuthId, entityId, id, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Contact not found');
}
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool
.request()
.input('id', id)
.input('entityId', entityId)
.query(DELETE_BY_ENTITY_AND_ID);
const result = await runner.query(sql, {
id,
entityId,
});
if (!result.rowsAffected[0]) {
if (!result.rowsAffected) {
throw new Error('Contact not found');
}
};

View File

@@ -1,6 +1,8 @@
import { LIST_BY_ENTITY } from '../../providers/mssql/queries/entitycontact.js';
import { LIST_BY_ENTITY as MSSQL_LIST_BY_ENTITY } from '../../providers/mssql/queries/entitycontact.js';
import { LIST_BY_ENTITY as SQLITE_LIST_BY_ENTITY } from '../../providers/sqlite3/queries/entitycontact.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
@@ -12,30 +14,33 @@ function shape(row) {
};
}
export default function list(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_LIST_BY_ENTITY
: MSSQL_LIST_BY_ENTITY;
/**
* Lists the contacts of an entity.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, entityId: number, contactType: string, contact: string, sort: number}>>} The contacts.
*
* @example
* const contacts = await store.entitycontact.list(42, 5);
* // => [{ id, entityId, contactType, contact, sort }]
*/
return async (googleAuthId, entityId) => {
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
return async (googleAuthId, entityId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool
.request()
.input('entityId', entityId)
.query(LIST_BY_ENTITY);
const result = await runner.query(sql, { entityId });
return result.recordset.map(shape);
return result.rows.map(shape);
};
}

View File

@@ -1,6 +1,14 @@
import { GET_BY_ENTITY_AND_ID, UPDATE } from '../../providers/mssql/queries/entitycontact.js';
import {
GET_BY_ENTITY_AND_ID as MSSQL_GET_BY_ENTITY_AND_ID,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/entitycontact.js';
import {
GET_BY_ENTITY_AND_ID as SQLITE_GET_BY_ENTITY_AND_ID,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/entitycontact.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
@@ -12,9 +20,14 @@ function shape(row) {
};
}
export default function update(pool) {
const access = resolveSelectedCompanyAccess(pool);
const entityAccess = resolveEntityAccess(pool);
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite
? SQLITE_GET_BY_ENTITY_AND_ID
: MSSQL_GET_BY_ENTITY_AND_ID;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Updates a contact of an entity.
@@ -23,6 +36,7 @@ export default function update(pool) {
* @param {number} entityId - The id of the entity.
* @param {number} id - The id of the contact to update.
* @param {object} patch - The update patch (any subset of contact fields).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>} The updated contact.
* @throws {Error} If the contact is not found.
*
@@ -30,21 +44,18 @@ export default function update(pool) {
* const contact = await store.entitycontact.update(42, 5, 9, { contact: 'b@c.d' });
* // => { id, entityId, contactType, contact, sort }
*/
return async (googleAuthId, entityId, id, patch = {}) => {
return async (googleAuthId, entityId, id, patch = {}, tx) => {
if (!Number.isFinite(id)) {
throw new Error('Contact not found');
}
const { companyId } = await access(googleAuthId);
await entityAccess(companyId, entityId);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const existing = await pool
.request()
.input('id', id)
.input('entityId', entityId)
.query(GET_BY_ENTITY_AND_ID);
const existing = await runner.query(getSql, { id, entityId });
const current = existing.recordset[0];
const current = existing.rows[0];
if (!current) {
throw new Error('Contact not found');
}
@@ -73,19 +84,22 @@ export default function update(pool) {
sort = patch.sort;
}
const result = await pool
.request()
.input('id', id)
.input('entityId', entityId)
.input('contactType', contactType)
.input('contact', contact)
.input('sort', sort)
.query(UPDATE);
const result = await runner.query(updateSql, {
id,
entityId,
contactType,
contact,
sort,
});
const row = result.rows[0] ?? {
id,
entityId,
contactType,
contact,
sort,
};
const row = result.recordset[0];
if (!row) {
throw new Error('Contact not found');
}
return shape(row);
};
}

View File

@@ -2,7 +2,7 @@ import { SCHEMA_SCRIPTS } from '../providers/mssql/schema.js';
export default function initialize(provider) {
/**
* Initializes the database schema by running all schema scripts.
* Initializes the database schema by delegating to the provider.
*
* @returns {Promise<void>}
*
@@ -10,8 +10,16 @@ export default function initialize(provider) {
* await store.initialize();
*/
return async () => {
if (typeof provider.initialize === 'function') {
return provider.initialize();
}
// Transitional fallback for raw mssql pool
for (const script of SCHEMA_SCRIPTS) {
await provider.query(script);
if (typeof provider.query === 'function') {
await provider.query(script);
} else if (typeof provider.request === 'function') {
await provider.request().query(script);
}
}
};
}

Some files were not shown because too many files have changed in this diff Show More