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 `provider.transaction()` / `provider.exec()` and the normalized result shape
(`{ rows, rowsAffected, insertId }` instead of `recordset` / `rowsAffected[]`). (`{ rows, rowsAffected, insertId }` instead of `recordset` / `rowsAffected[]`).
For every module: For every module:
- [ ] `pool.request().input(...).query(SQL)` → `provider.query(SQL, params)` - [x] `pool.request().input(...).query(SQL)` → `provider.query(SQL, params)`
- [ ] `pool.transaction(); begin; commit; catch rollback` → `provider.transaction(async (tx) => { ... })` - [x] `pool.transaction(); begin; commit; catch rollback` → `provider.transaction(async (tx) => { ... })`
- [ ] `result.recordset` → `result.rows`; `result.rowsAffected[0]` → `result.rowsAffected` - [x] `result.recordset` → `result.rows`; `result.rowsAffected[0]` → `result.rowsAffected`
- [ ] `request.js` helper (`(tx ?? pool).request()`) becomes obsolete — delete when unused - [x] `request.js` helper (`(tx ?? pool).request()`) becomes obsolete — deleted
- [x] `src/store/runner.js` bridges mixed call sites: converted modules accept a - [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) provider runner *or* a legacy mssql Transaction/`pool` (wrapped on the fly)
so partially converted callers keep working 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): Conversion order (bottom-up by dependency):
- [x] `lang`, `tax` (leaf helpers, used by company create) - [x] `lang`, `tax` (leaf helpers, used by company create)
- [x] `google_auth` - [x] `google_auth`
- [ ] `companyuser` (resolveCompanyAccess / resolveSelectedCompanyAccess first) - [x] `companyuser` (resolveCompanyAccess / resolveSelectedCompanyAccess first)
- [ ] `company`, `companysetting` - [x] `company`, `companysetting`
- [ ] `customergroup`, `entity`, `entityaddress`, `entitycontact`, `entityhistory` - [x] `customergroup`, `entity`, `entityaddress`, `entitycontact`, `entityhistory`
- [ ] `unit` (incl. `ensureDefault`), `brand`, `supplier` - [x] `unit` (incl. `ensureDefault`), `brand`, `supplier`
- [ ] `numbers` (see §2 — stored procs!) - [x] `numbers` (see §2 — stored procs!)
- [ ] `category` (recursive CTEs!) - [x] `category` (recursive CTEs!)
- [ ] `article` + articletext/articlepic/articleean/price/articlehistory - [x] `article` + articletext/articlepic/articleean/price/articlehistory
- [ ] `articlesupplier` - [x] `articlesupplier`
- [ ] `quote`, `order`, `delivery` (shared manifest helpers: `manifestLines`, `manifestDoc`, `manifestattachment`) - [x] `quote`, `order`, `delivery` (shared manifest helpers: `manifestLines`, `manifestDoc`, `manifestattachment`)
- [ ] `posterminal`, `pic` - [x] `posterminal`, `pic`
- [ ] `initialize.js`, `purge.js` final wiring (drop `provider.pool` fallback from `index.js`) - [x] `initialize.js`, `purge.js` final wiring (drop `provider.pool` fallback from `index.js`)
- [ ] Delete `src/store/request.js` once no module uses it - [x] Delete `src/store/request.js` once no module uses it
## 2. Replace MSSQL stored procedures (blocker for SQLite) ## 2. Replace MSSQL stored procedures (blocker for SQLite)
- [ ] `numbers.nextNumber` uses proc `getnextnumberwithprefixandpadding` (output param) - [x] `numbers.nextNumber` uses proc `getnextnumberwithprefixandpadding` (output param)
- [ ] `numbers.peekNumber` uses proc `peeknextnumberwithprefixandpadding` (output param) - [x] `numbers.peekNumber` uses proc `peeknextnumberwithprefixandpadding` (output param)
- [ ] Implement both in plain JS inside the store layer: `SELECT next/prefix/padding`, - [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 ...`. compute `prefix + padded next`, `UPDATE numbers SET next = next + 1 WHERE ...`.
Must stay atomic: use `provider.transaction()` with row locking semantics Must stay atomic: use `provider.transaction()` with row locking semantics
(MSSQL: `WITH (UPDLOCK, ROWLOCK)` in the SELECT; SQLite: within its write tx). (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" "thumbhash": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"better-sqlite3": "^13.0.3",
"mssql": "^12.7.2" "mssql": "^12.7.2"
}, },
"peerDependencies": { "peerDependencies": {
@@ -984,6 +985,19 @@
], ],
"license": "MIT" "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": { "node_modules/bl": {
"version": "6.1.6", "version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
@@ -1466,6 +1480,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/open": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",

View File

@@ -41,6 +41,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"better-sqlite3": "^13.0.3",
"mssql": "^12.7.2" "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; this.provider = provider;
// Transitional: store modules still use the raw mssql pool. this.pool = provider.pool;
// They are converted to provider.query() area by area (see TODO.md).
const pool = provider.pool;
this.pool = pool;
this.companyuser = { this.companyuser = {
getByEmail: companyuserGetByEmail(pool), getByEmail: companyuserGetByEmail(provider),
add: companyuserAdd(pool), add: companyuserAdd(provider),
delete: companyuserDelete(pool), delete: companyuserDelete(provider),
}; };
this.company = { this.company = {
create: companyCreate(pool), create: companyCreate(provider),
update: companyUpdate(pool), update: companyUpdate(provider),
delete: companyDelete(pool), delete: companyDelete(provider),
list: companyList(pool), list: companyList(provider),
}; };
this.category = { this.category = {
getTreeForCompany: categoryGetTreeForCompany(pool), getTreeForCompany: categoryGetTreeForCompany(provider),
getCategoryArticles: categoryGetCategoryArticles(pool), getCategoryArticles: categoryGetCategoryArticles(provider),
createUnderParent: categoryCreateUnderParent(pool), createUnderParent: categoryCreateUnderParent(provider),
ensureByPath: categoryEnsureByPath(pool), ensureByPath: categoryEnsureByPath(provider),
rename: categoryRename(pool), rename: categoryRename(provider),
move: categoryMove(pool), move: categoryMove(provider),
order: categoryOrder(pool), order: categoryOrder(provider),
delete: categoryDelete(pool), delete: categoryDelete(provider),
addImage: categoryAddImage(pool), addImage: categoryAddImage(provider),
deleteImage: categoryDeleteImage(pool), deleteImage: categoryDeleteImage(provider),
}; };
this.article = { this.article = {
createUnderParent: articleCreateUnderParent(pool), createUnderParent: articleCreateUnderParent(provider),
getBySku: articleGetBySku(pool), getBySku: articleGetBySku(provider),
getHistoryBySku: articleGetHistoryBySku(pool), getHistoryBySku: articleGetHistoryBySku(provider),
listSearchIndex: articleListSearchIndex(pool), listSearchIndex: articleListSearchIndex(provider),
listExport: articleListExport(pool), listExport: articleListExport(provider),
updateBySku: articleUpdateBySku(pool), updateBySku: articleUpdateBySku(provider),
setPricesBySku: articleSetPricesBySku(pool), setPricesBySku: articleSetPricesBySku(provider),
setEansBySku: articleSetEansBySku(pool), setEansBySku: articleSetEansBySku(provider),
setBrandIdBySku: articleSetBrandIdBySku(pool), setBrandIdBySku: articleSetBrandIdBySku(provider),
setWeightBySku: articleSetWeightBySku(pool), setWeightBySku: articleSetWeightBySku(provider),
setCategoryIdsBySku: articleSetCategoryIdsBySku(pool), setCategoryIdsBySku: articleSetCategoryIdsBySku(provider),
setFatherBySku: articleSetFatherBySku(pool), setFatherBySku: articleSetFatherBySku(provider),
importRows: articleImportRows(pool), importRows: articleImportRows(provider),
addImage: articleAddImage(pool), addImage: articleAddImage(provider),
deleteImage: articleDeleteImage(pool), deleteImage: articleDeleteImage(provider),
orderImages: articleOrderImages(pool), orderImages: articleOrderImages(provider),
delete: articleDelete(pool), delete: articleDelete(provider),
}; };
this.lang = { this.lang = {
@@ -205,105 +202,105 @@ export default class LibDashStore {
}; };
this.unit = { this.unit = {
list: unitList(pool), list: unitList(provider),
upsert: unitUpsert(pool), upsert: unitUpsert(provider),
delete: unitDelete(pool), delete: unitDelete(provider),
}; };
this.companysetting = { this.companysetting = {
list: companysettingList(pool), list: companysettingList(provider),
get: companysettingGet(pool), get: companysettingGet(provider),
set: companysettingSet(pool), set: companysettingSet(provider),
}; };
this.brand = { this.brand = {
list: brandList(pool), list: brandList(provider),
upsert: brandUpsert(pool), upsert: brandUpsert(provider),
delete: brandDelete(pool), delete: brandDelete(provider),
}; };
this.customergroup = { this.customergroup = {
list: customergroupList(pool), list: customergroupList(provider),
create: customergroupCreate(pool), create: customergroupCreate(provider),
update: customergroupUpdate(pool), update: customergroupUpdate(provider),
order: customergroupOrder(pool), order: customergroupOrder(provider),
delete: customergroupDelete(pool), delete: customergroupDelete(provider),
}; };
this.entity = { this.entity = {
list: entityList(pool), list: entityList(provider),
getById: entityGetById(pool), getById: entityGetById(provider),
create: entityCreate(pool), create: entityCreate(provider),
update: entityUpdate(pool), update: entityUpdate(provider),
delete: entityDelete(pool), delete: entityDelete(provider),
peekNextNumber: entityPeekNextNumber(pool), peekNextNumber: entityPeekNextNumber(provider),
isNumberFree: entityIsNumberFree(pool), isNumberFree: entityIsNumberFree(provider),
}; };
this.entityaddress = { this.entityaddress = {
list: entityaddressList(pool), list: entityaddressList(provider),
create: entityaddressCreate(pool), create: entityaddressCreate(provider),
update: entityaddressUpdate(pool), update: entityaddressUpdate(provider),
delete: entityaddressDelete(pool), delete: entityaddressDelete(provider),
}; };
this.entitycontact = { this.entitycontact = {
list: entitycontactList(pool), list: entitycontactList(provider),
create: entitycontactCreate(pool), create: entitycontactCreate(provider),
update: entitycontactUpdate(pool), update: entitycontactUpdate(provider),
delete: entitycontactDelete(pool), delete: entitycontactDelete(provider),
}; };
this.supplier = { this.supplier = {
list: supplierList(pool), list: supplierList(provider),
create: supplierCreate(pool), create: supplierCreate(provider),
update: supplierUpdate(pool), update: supplierUpdate(provider),
delete: supplierDelete(pool), delete: supplierDelete(provider),
}; };
this.articlesupplier = { this.articlesupplier = {
listByArticleSku: articlesupplierListByArticleSku(pool), listByArticleSku: articlesupplierListByArticleSku(provider),
listByEntityAndSkus: articlesupplierListByEntityAndSkus(pool), listByEntityAndSkus: articlesupplierListByEntityAndSkus(provider),
listBySupplierAndSkus: articlesupplierListByEntityAndSkus(pool), listBySupplierAndSkus: articlesupplierListByEntityAndSkus(provider),
create: articlesupplierCreate(pool), create: articlesupplierCreate(provider),
update: articlesupplierUpdate(pool), update: articlesupplierUpdate(provider),
order: articlesupplierOrder(pool), order: articlesupplierOrder(provider),
delete: articlesupplierDelete(pool), delete: articlesupplierDelete(provider),
}; };
this.delivery = { this.delivery = {
list: deliveryList(pool), list: deliveryList(provider),
getById: deliveryGetById(pool), getById: deliveryGetById(provider),
create: deliveryCreate(pool), create: deliveryCreate(provider),
update: deliveryUpdate(pool), update: deliveryUpdate(provider),
delete: deliveryDelete(pool), delete: deliveryDelete(provider),
}; };
this.quote = { this.quote = {
list: quoteList(pool), list: quoteList(provider),
getById: quoteGetById(pool), getById: quoteGetById(provider),
create: quoteCreate(pool), create: quoteCreate(provider),
update: quoteUpdate(pool), update: quoteUpdate(provider),
delete: quoteDelete(pool), delete: quoteDelete(provider),
}; };
this.order = { this.order = {
list: orderList(pool), list: orderList(provider),
getById: orderGetById(pool), getById: orderGetById(provider),
create: orderCreate(pool), create: orderCreate(provider),
update: orderUpdate(pool), update: orderUpdate(provider),
delete: orderDelete(pool), delete: orderDelete(provider),
}; };
this.posterminal = { this.posterminal = {
list: posterminalList(pool), list: posterminalList(provider),
upsert: posterminalUpsert(pool), upsert: posterminalUpsert(provider),
delete: posterminalDelete(pool), delete: posterminalDelete(provider),
setOn: posterminalSetOn(pool), setOn: posterminalSetOn(provider),
setOff: posterminalSetOff(pool), setOff: posterminalSetOff(provider),
setOnAll: posterminalSetOnAll(pool), setOnAll: posterminalSetOnAll(provider),
setOffAll: posterminalSetOffAll(pool), setOffAll: posterminalSetOffAll(provider),
getCategoryPosterminalIds: posterminalGetCategoryPosterminalIds(pool), getCategoryPosterminalIds: posterminalGetCategoryPosterminalIds(provider),
}; };
this.google_auth = { this.google_auth = {
@@ -317,21 +314,21 @@ export default class LibDashStore {
}; };
this.pic = { this.pic = {
getImageByHash: picGetImageByHash(pool), getImageByHash: picGetImageByHash(provider),
getThumbByHash: picGetThumbByHash(pool), getThumbByHash: picGetThumbByHash(provider),
}; };
this.numbers = { this.numbers = {
list: numbersList(pool), list: numbersList(provider),
update: numbersUpdate(pool), update: numbersUpdate(provider),
}; };
this.manifestattachment = { this.manifestattachment = {
add: manifestattachmentAdd(pool), add: manifestattachmentAdd(provider),
remove: manifestattachmentRemove(pool), remove: manifestattachmentRemove(provider),
listByManifestId: manifestattachmentListByManifestId(pool), listByManifestId: manifestattachmentListByManifestId(provider),
getById: manifestattachmentGetById(pool), getById: manifestattachmentGetById(provider),
order: manifestattachmentOrder(pool), order: manifestattachmentOrder(provider),
}; };
this.purge = storePurge(provider); this.purge = storePurge(provider);
@@ -347,3 +344,6 @@ export default class LibDashStore {
} }
export { SCHEMA_SCRIPTS } from './providers/mssql/schema.js'; 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(); const tx = pool.transaction();
await tx.begin(); await tx.begin();
try { try {
const runner = this.makeExecutor(() => tx.request()); const executor = this.makeExecutor(() => tx.request());
const result = await fn(runner); 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(); await tx.commit();
return result; return result;
} catch (err) { } catch (err) {
@@ -148,17 +152,32 @@ export class MssqlProvider {
} }
async initialize() { async initialize() {
// Implemented by store/initialize.js — no-op here (keeps parity with const pool = await this.ensurePool();
// other providers, which may run migrations internally instead). const { SCHEMA_SCRIPTS } = await import('./schema.js');
for (const script of SCHEMA_SCRIPTS) {
if (script && script.trim()) {
await pool.request().query(script);
}
}
} }
async purge() { 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() { close() {
return this.pool ? this.pool.close() : Promise.resolve(); 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 */ /** 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 WHERE id = @id
AND company_id = @companyId 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. * Checks whether an error is a unique-constraint violation.
* @param {unknown} err * @param {any} err
* @returns {boolean} * @returns {boolean}
*/ */
export function isUniqueConstraintError(err) { 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. * Checks whether an error is a foreign-key-constraint violation.
* @param {unknown} err * @param {any} err
* @returns {boolean} * @returns {boolean}
*/ */
export function isForeignKeyConstraintError(err) { 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 * The driver is synchronous, but this adapter exposes the asynchronous
* provider contract used by LibDashStore. * 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) { function bindParams(params) {
return Object.fromEntries( return Object.fromEntries(
Object.entries(params).map(([key, value]) => [ Object.entries(params).map(([key, value]) => [
key.replace(/^[@:$]/, ''), 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; 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 { 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.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>} */ /** @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 { 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 ( if (
!db || !db ||
typeof db.prepare !== 'function' || typeof db.prepare !== 'function' ||
@@ -61,22 +270,29 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
typeof db.pragma !== 'function' typeof db.pragma !== 'function'
) { ) {
throw new TypeError( throw new TypeError(
'BetterSqlite3Provider requires an open better-sqlite3 Database', 'BetterSqlite3Provider requires an open better-sqlite3 Database or a filename string',
); );
} }
super(db); super(db, storageDir);
this.dialect = 'sqlite3'; 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('foreign_keys = ON');
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000'); db.pragma('busy_timeout = 5000');
} }
#transactionTail = Promise.resolve(); #transactionTail = Promise.resolve();
#transactionDepth = 0;
/** /**
* Runs `fn(runner)` in an immediate write transaction. Transactions are * Runs `fn(runner)` in an immediate write transaction. Transactions are
* serialized because better-sqlite3 has one synchronous connection. * serialized because better-sqlite3 has one synchronous connection.
* Nested calls use SAVEPOINTs.
* *
* @template T * @template T
* @param {(runner: BetterSqlite3Executor) => Promise<T> | T} fn * @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')); 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 () => { const run = async () => {
this.#transactionDepth = 1;
this.db.exec('BEGIN IMMEDIATE'); this.db.exec('BEGIN IMMEDIATE');
try { 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'); this.db.exec('COMMIT');
return result; return result;
} catch (err) { } catch (err) {
if (this.db.inTransaction) this.db.exec('ROLLBACK'); if (this.db.inTransaction) this.db.exec('ROLLBACK');
throw err; 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'); throw new Error('Stored procedures are not supported by better-sqlite3');
} }
/**
* Initializes the native SQLite schema.
* @returns {Promise<void>}
*/
async initialize() { 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() { 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() { close() {
this.db.close(); this.db.close();
} }
isUniqueConstraintError(err) {
return isUniqueConstraintError(err);
}
isForeignKeyConstraintError(err) {
return isForeignKeyConstraintError(err);
}
} }
export function isUniqueConstraintError(err) { export function isUniqueConstraintError(err) {
@@ -131,4 +447,7 @@ export function isUniqueConstraintError(err) {
export function isForeignKeyConstraintError(err) { export function isForeignKeyConstraintError(err) {
return err?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY'; 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'); const queries = translateQueries(source, 'category');
export const { export const {
CREATE, DELETE, GET_BY_PARENT_AND_NAME, CREATE, DELETE, GET_BY_PARENT_AND_NAME,
GET_NEXT_SIBLING_SORT, IS_ANCESTOR, LIST_CHILD_IDS, GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS,
MOVE, RESOLVE_CATEGORY_ACCESS, UPDATE_SORT, MOVE, UPDATE_SORT,
} = queries; } = 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 = ` export const GET_CATEGORY_ARTICLES = `
SELECT ${ARTICLE_LIST_COLUMNS} SELECT ${ARTICLE_LIST_COLUMNS}
FROM categoryarticle ca FROM categoryarticle ca

View File

@@ -5,18 +5,41 @@ const queries = translateQueries(source, 'delivery');
export const { export const {
CREATE_ATTACHMENT, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST, CREATE_ATTACHMENT, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST,
DELETE_MANIFEST, DELIVERY_TYPE, GET_DELIVERY_HEADER, GET_DELIVERY_LINES, 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, UPDATE_MANIFEST,
} = queries; } = queries;
export const CREATE_FILE = ` export const CREATE_FILE = `
INSERT INTO "file" ( INSERT INTO "file" (
hash, mimetype, name, size, created, modified, content, hash, mimetype, name, size, created, modified,
thumbavif, thumbx, thumby, meta thumbx, thumby, meta
) )
VALUES ( VALUES (
@hash, @mimetype, @name, @size, @created, @modified, @content, @hash, @mimetype, @name, @size, @created, @modified,
@thumbavif, @thumbx, @thumby, @meta @thumbx, @thumby, @meta
) )
RETURNING id 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'); const queries = translateQueries(source, 'manifestattachment');
export const { export const {
CHECK_MANIFEST_ACCESS, CREATE_ATTACHMENT, DELETE_ATTACHMENT, 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, LIST_IDS_BY_MANIFEST, SET_TEMP_SORTS, UPDATE_SORT,
} = queries; } = 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 = ` export const CREATE_FILE = `
INSERT INTO "file" ( INSERT INTO "file" (
hash, mimetype, name, size, created, modified, content, hash, mimetype, name, size, created, modified,
thumbavif, thumbx, thumby, meta thumbx, thumby, meta
) )
VALUES ( VALUES (
@hash, @mimetype, @name, @size, @created, @modified, @content, @hash, @mimetype, @name, @size, @created, @modified,
@thumbavif, @thumbx, @thumby, @meta @thumbx, @thumby, @meta
) )
RETURNING id 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'); const queries = translateQueries(source, 'pic');
export const { export const {
CREATE, GET_CATEGORY_ID_BY_HASH, GET_FULL_BY_HASH, GET_THUMB_BY_HASH, GET_CATEGORY_ID_BY_HASH,
} = queries; } = 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 = ` export const DELETE_BY_ARTICLE_AND_HASH = `
DELETE FROM pic DELETE FROM pic
WHERE hash = @fullHash WHERE hash = @fullHash

View File

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

View File

@@ -1,12 +1,25 @@
import { ADD as ADD_ARTICLEPIC, GET_NEXT_SORT } from '../../providers/mssql/queries/articlepic.js'; import {
import { CREATE as CREATE_PIC } from '../../providers/mssql/queries/pic.js'; 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 processAvifImage from '../../util/processAvifImage.js';
import newFullHash from '../../util/newFullHash.js'; import newFullHash from '../../util/newFullHash.js';
import request from '../request.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function addImage(pool) { export default function addImage(provider) {
const resolve = resolveArticleBySku(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {Buffer} image - The source image buffer. * @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. * @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 * @example
* const result = await store.article.addImage(42, 'SKU-1', imageBuffer); * const result = await store.article.addImage(42, 'SKU-1', imageBuffer);
* // => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash } * // => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash }
*/ */
return async (googleAuthId, sku, image) => { return async (googleAuthId, sku, image, tx) => {
const { articleId } = await resolve(googleAuthId, sku); 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(); const sortResult = await runner.query(getSortSql, { articleId });
await tx.begin(); const sort = sortResult.rows[0].sort;
try {
const sortResult = await request(pool, tx)
.input('articleId', articleId)
.query(GET_NEXT_SORT);
const sort = sortResult.recordset[0].sort;
const fullHash = newFullHash(); const fullHash = newFullHash();
const picResult = await request(pool, tx) if (isSqlite) {
.input('w', processed.full.w) if (runner.writePicFull) {
.input('h', processed.full.h) await runner.writePicFull(fullHash, processed.full.buffer);
.input('size', processed.full.size) await runner.writePicThumb(fullHash, processed.thumb.buffer);
.input('hash', fullHash) } else if (provider?.writePicFull) {
.input('thumbw', processed.thumb.w) await provider.writePicFull(fullHash, processed.full.buffer);
.input('thumbh', processed.thumb.h) await provider.writePicThumb(fullHash, processed.thumb.buffer);
.input('thumbhash', processed.thumb.hash) }
.input('source', '') }
.input('fullbinary', processed.full.buffer)
.input('thumbbinary', processed.thumb.buffer)
.query(CREATE_PIC);
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) const picId = picResult.rows[0]?.id ?? picResult.insertId;
.input('articleId', articleId)
.input('picId', picId)
.input('sort', sort)
.query(ADD_ARTICLEPIC);
await tx.commit(); await runner.query(addArticlePicSql, {
articleId,
picId,
sort,
});
return { return {
picId, picId,
@@ -74,13 +90,15 @@ export default function addImage(pool) {
fullHash, fullHash,
thumbHash: processed.thumb.hash, thumbHash: processed.thumb.hash,
}; };
} catch (err) { };
try {
await tx.rollback(); if (tx) {
} catch { return runAdd(makeRunner(provider, tx));
// ignore
}
throw err;
} }
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 { CREATE as MSSQL_CREATE_ARTICLE } from '../../providers/mssql/queries/article.js';
import { ADD as ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; import { CREATE as SQLITE_CREATE_ARTICLE } from '../../providers/sqlite3/queries/article.js';
import { CREATE as CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js'; import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.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 resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import ensureDefaultUnit from '../unit/ensureDefault.js'; import ensureDefaultUnit from '../unit/ensureDefault.js';
import nextSku from '../numbers/nextSku.js'; import nextSku from '../numbers/nextSku.js';
import request from '../request.js'; import makeRunner from '../runner.js';
import slugify from '../slugify.js'; import slugify from '../slugify.js';
export default function createUnderParent(pool) { export default function createUnderParent(provider) {
const access = resolveCategoryAccess(pool); const access = resolveCategoryAccess(provider);
const ensureUnit = ensureDefaultUnit(pool); const ensureUnit = ensureDefaultUnit(provider);
const allocateSku = nextSku(pool); 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 * 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.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category. * @param {number} input.parentId - The id of the parent category.
* @param {string} input.name - The article name. * @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. * @returns {Promise<{id: number, parentId: number, name: string, sku: string}>} The created article.
* *
* @example * @example
* const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' }); * const article = await store.article.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Cola' });
* // => { id, parentId, name, sku } * // => { id, parentId, name, sku }
*/ */
return async ({ googleAuthId, parentId, name }) => { return async ({ googleAuthId, parentId, name }, tx) => {
const { companyId, languageId, companyuserId } = await access( const runCreate = async (runner) => {
googleAuthId, const { companyId, languageId, companyuserId } = await access(
parentId googleAuthId,
); parentId,
runner,
);
const tx = pool.transaction(); const unitId = await ensureUnit(runner, languageId, companyId);
await tx.begin(); const sku = await allocateSku(companyId, runner);
try {
const unitId = await ensureUnit(tx, languageId, companyId);
const sku = await allocateSku(companyId, tx);
const slug = slugify(name); const slug = slugify(name);
const article = await request(pool, tx) const article = await runner.query(createArticleSql, {
.input('sku', sku) sku,
.input('unitId', unitId) unitId,
.input('unitamount', 1) unitamount: 1,
.input('weight', 0) weight: 0,
.query(CREATE); });
const id = article.recordset[0].id; const id = article.rows[0]?.id ?? article.insertId;
await request(pool, tx) await runner.query(addCategoryArticleSql, {
.input('categoryId', parentId) categoryId: parentId,
.input('articleId', id) articleId: id,
.query(ADD_CATEGORYARTICLE); });
await request(pool, tx) await runner.query(createArticleTextSql, {
.input('articleId', id) articleId: id,
.input('languageId', languageId) languageId,
.input('name', name) name,
.input('slug', slug) slug,
.query(CREATE_ARTICLETEXT); });
await request(pool, tx) await runner.query(insertHistorySql, {
.input('articleId', id) articleId: id,
.input('companyuserId', companyuserId) companyuserId,
.input('text', `Artikel "${name}" angelegt`) text: `Artikel "${name}" angelegt`,
.query(INSERT_HISTORY); });
await tx.commit();
return { return {
id, id,
@@ -76,13 +83,15 @@ export default function createUnderParent(pool) {
name, name,
sku, sku,
}; };
} catch (err) { };
try {
await tx.rollback(); if (tx) {
} catch { return runCreate(makeRunner(provider, tx));
// ignore
}
throw err;
} }
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
}; };
} }

View File

@@ -1,83 +1,60 @@
import { import * as mssqlQueries from '../../providers/mssql/queries/articleDelete.js';
DELETE_ARTICLELIST, import * as sqliteQueries from '../../providers/sqlite3/queries/articleDelete.js';
DELETE_BY_ID,
DELETE_OPTIONSLIST,
DELETE_PICS_BY_ARTICLE,
GET_CHILD_COUNT,
GET_LIST_IDS,
} from '../../providers/mssql/queries/articleDelete.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import request from '../request.js'; import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const resolve = resolveArticleBySku(pool); 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. * Deletes an article by its SKU, refusing when it still has children.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the article has children or is not found. * @throws {Error} If the article has children or is not found.
* *
* @example * @example
* await store.article.delete(42, 'SKU-1'); * await store.article.delete(42, 'SKU-1');
*/ */
return async (googleAuthId, sku) => { return async (googleAuthId, sku, tx) => {
const { articleId } = await resolve(googleAuthId, sku); const runDelete = async (runner) => {
const { articleId } = await resolve(googleAuthId, sku, runner);
const childCount = await pool const childCount = await runner.query(queries.GET_CHILD_COUNT, { articleId });
.request() if (childCount.rows[0].count > 0) {
.input('articleId', articleId) throw new Error('Article has children');
.query(GET_CHILD_COUNT); }
if (childCount.recordset[0].count > 0) { const listIds = await runner.query(queries.GET_LIST_IDS, { articleId });
throw new Error('Article has children'); const { articlelistId, optionslistId } = listIds.rows[0] ?? {};
}
const listIds = await pool await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId });
.request()
.input('articleId', articleId)
.query(GET_LIST_IDS);
const { articlelistId, optionslistId } = listIds.recordset[0] ?? {}; const deleted = await runner.query(queries.DELETE_BY_ID, { articleId });
if (!deleted.rowsAffected) {
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]) {
throw new Error('Article not found'); throw new Error('Article not found');
} }
if (articlelistId != null) { if (articlelistId != null) {
await request(pool, tx) await runner.query(queries.DELETE_ARTICLELIST, { id: articlelistId });
.input('id', articlelistId)
.query(DELETE_ARTICLELIST);
} }
if (optionslistId != null) { if (optionslistId != null) {
await request(pool, tx) await runner.query(queries.DELETE_OPTIONSLIST, { id: optionslistId });
.input('id', optionslistId)
.query(DELETE_OPTIONSLIST);
} }
};
await tx.commit(); if (tx) {
} catch (err) { return runDelete(makeRunner(provider, tx));
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
} }
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 resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function deleteImage(pool) { export default function deleteImage(provider) {
const resolve = resolveArticleBySku(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {string} fullHash - The full image hash to delete. * @param {string} fullHash - The full image hash to delete.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the image is not found. * @throws {Error} If the image is not found.
* *
* @example * @example
* await store.article.deleteImage(42, 'SKU-1', 'abc123'); * await store.article.deleteImage(42, 'SKU-1', 'abc123');
*/ */
return async (googleAuthId, sku, fullHash) => { return async (googleAuthId, sku, fullHash, tx) => {
const { articleId } = await resolve(googleAuthId, sku); const { articleId } = await resolve(googleAuthId, sku, tx);
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { articleId, fullHash });
const result = await pool if (!result.rowsAffected) {
.request()
.input('articleId', articleId)
.input('fullHash', fullHash)
.query(DELETE_BY_ARTICLE_AND_HASH);
if (!result.rowsAffected[0]) {
throw new Error('Image not found'); 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) { function parseJsonArray(raw) {
if (!raw) return []; if (!raw) return [];
@@ -6,13 +8,16 @@ function parseJsonArray(raw) {
return Array.isArray(arr) ? arr : []; 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, * Returns the full detail of an article by its SKU, including unit, brand,
* categories, photos, EANs and prices. * categories, photos, EANs and prices.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>} The full article detail. * @returns {Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {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. * @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'); * const article = await store.article.getBySku(42, 'SKU-1');
* // => { article: { id, sku, name, unit, brand, categoryIds, photos, eans, prices, ... } } * // => { article: { id, sku, name, unit, brand, categoryIds, photos, eans, prices, ... } }
*/ */
return async (googleAuthId, sku) => { return async (googleAuthId, sku, tx) => {
const t0 = performance.now(); const t0 = performance.now();
const result = await pool const runner = makeRunner(provider, tx);
.request() const result = await runner.query(sql, { sku, googleAuthId });
.input('sku', sku)
.input('googleAuthId', googleAuthId)
.query(GET_ARTICLE_BY_SKU_FULL);
console.log(`[getBySku] single query ${(performance.now() - t0).toFixed(1)}ms`); console.log(`[getBySku] single query ${(performance.now() - t0).toFixed(1)}ms`);
const row = result.recordset[0]; const row = result.rows[0];
if (!row) { if (!row) {
throw new Error('Article not found'); throw new Error('Article not found');
} }
@@ -43,6 +45,7 @@ export default function getBySku(pool) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
const a = typeof row.articleJson === 'string' ? JSON.parse(row.articleJson) : row.articleJson; const a = typeof row.articleJson === 'string' ? JSON.parse(row.articleJson) : row.articleJson;
return { 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 resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function getHistoryBySku(pool) { export default function getHistoryBySku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY;
/** /**
* Returns the change history of an article by its SKU. * Returns the change history of an article by its SKU.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>} The article history entries. * @returns {Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>} The article history entries.
* *
* @example * @example
* const history = await store.article.getHistoryBySku(42, 'SKU-1'); * const history = await store.article.getHistoryBySku(42, 'SKU-1');
* // => [{ id, companyuserId, date, text }] * // => [{ id, companyuserId, date, text }]
*/ */
return async (googleAuthId, sku) => { return async (googleAuthId, sku, tx) => {
const { articleId } = await resolve(googleAuthId, sku); const { articleId } = await resolve(googleAuthId, sku, tx);
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { articleId });
const result = await pool return result.rows.map((row) => ({
.request()
.input('articleId', articleId)
.query(LIST_BY_ARTICLE_ID);
return result.recordset.map((row) => ({
id: row.id, id: row.id,
companyuserId: row.companyuserId ?? null, companyuserId: row.companyuserId ?? null,
date: row.date, 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 { import {
ADD_EAN, CREATE as MSSQL_CREATE_BRAND,
CREATE as CREATE_ARTICLE, GET_BY_COMPANY_AND_NAME as MSSQL_GET_BRAND_BY_NAME,
EAN_EXISTS, } from '../../providers/mssql/queries/brand.js';
GET_ID_BY_SKU, import {
SET_FATHER_SORT, CREATE as SQLITE_CREATE_BRAND,
SET_IS_FATHER, GET_BY_COMPANY_AND_NAME as SQLITE_GET_BRAND_BY_NAME,
UPDATE_BRAND, } from '../../providers/sqlite3/queries/brand.js';
UPDATE_FATHER_BY_SKU, import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js';
} from '../../providers/mssql/queries/article.js'; import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js';
import { ADD as ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; import {
import { CREATE as CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js'; CREATE as MSSQL_CREATE_TAX,
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; GET_BY_RATE as MSSQL_GET_TAX_BY_RATE,
import { GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js'; } from '../../providers/mssql/queries/tax.js';
import { CREATE as CREATE_BRAND, GET_BY_COMPANY_AND_NAME } from '../../providers/mssql/queries/brand.js'; import {
import { CREATE as CREATE_PRICE } from '../../providers/mssql/queries/price.js'; CREATE as SQLITE_CREATE_TAX,
import { CREATE as CREATE_TAX, GET_BY_RATE } from '../../providers/mssql/queries/tax.js'; GET_BY_RATE as SQLITE_GET_TAX_BY_RATE,
import request from '../request.js'; } from '../../providers/sqlite3/queries/tax.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import ensureByPath from '../category/ensureByPath.js'; import ensureByPath from '../category/ensureByPath.js';
import ensureDefaultUnit from '../unit/ensureDefault.js'; import ensureDefaultUnit from '../unit/ensureDefault.js';
import makeRunner from '../runner.js';
import slugify from '../slugify.js'; import slugify from '../slugify.js';
// Normalizes a numeric value that may arrive as a string using German decimal // 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; return Number.isFinite(n) ? n : null;
} }
export default function importRows(pool) { export default function importRows(provider) {
const companyAccess = resolveSelectedCompanyAccess(pool); const companyAccess = resolveSelectedCompanyAccess(provider);
const ensureUnit = ensureDefaultUnit(pool); const ensureUnit = ensureDefaultUnit(provider);
const ensureCategoryByPath = ensureByPath(pool); 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). // Find or create a brand for a company by name (within the current tx).
async function ensureBrand(tx, companyId, name) { async function ensureBrand(runner, companyId, name) {
const existing = await request(pool, tx) const existing = await runner.query(getBrandByNameSql, {
.input('companyId', companyId) companyId,
.input('name', name) name,
.query(GET_BY_COMPANY_AND_NAME); });
if (existing.recordset[0]) { if (existing.rows[0]) {
return existing.recordset[0].id; return existing.rows[0].id;
} }
const created = await request(pool, tx) const created = await runner.query(createBrandSql, {
.input('companyId', companyId) companyId,
.input('name', name) name,
.input('gsprpinfo', null) gsprpinfo: null,
.query(CREATE_BRAND); });
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 {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 {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 {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. * @returns {Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>} The import outcome.
* *
* @example * @example
@@ -94,14 +118,15 @@ export default function importRows(pool) {
* }); * });
* // => { imported: [...], skipped: [...], errors: [...] } * // => { imported: [...], skipped: [...], errors: [...] }
*/ */
return async ({ googleAuthId, rows }) => { return async ({ googleAuthId, rows }, tx) => {
const baseRunner = makeRunner(provider, tx);
const { const {
companyId, companyId,
languageId, languageId,
rootCategoryId, rootCategoryId,
posCustomerGroupId, posCustomerGroupId,
companyuserId, companyuserId,
} = await companyAccess(googleAuthId); } = await companyAccess(googleAuthId, baseRunner);
if (!Array.isArray(rows)) { if (!Array.isArray(rows)) {
throw new Error('Invalid rows'); throw new Error('Invalid rows');
@@ -112,6 +137,16 @@ export default function importRows(pool) {
const errors = []; const errors = [];
const usedEans = new Set(); 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). // Pass 1: create articles (skip existing SKUs).
for (const row of rows) { for (const row of rows) {
const sku = String(row?.sku ?? '').trim(); const sku = String(row?.sku ?? '').trim();
@@ -122,123 +157,113 @@ export default function importRows(pool) {
continue; continue;
} }
const existing = await pool.request().input('sku', sku).query(GET_ID_BY_SKU); const existing = await baseRunner.query(articleQueries.GET_ID_BY_SKU, { sku });
if (existing.recordset[0]) { if (existing.rows[0]) {
skipped.push({ sku, name, reason: 'duplicate sku' }); skipped.push({ sku, name, reason: 'duplicate sku' });
continue; continue;
} }
let tx;
try { try {
tx = pool.transaction(); await runInTx(async (runner) => {
await tx.begin(); const unitId = await ensureUnit(runner, companyId, languageId);
const slug = slugify(name);
const unitId = await ensureUnit(tx, companyId, languageId); const article = await runner.query(articleQueries.CREATE, {
const slug = slugify(name); sku,
unitId,
unitamount: 1,
weight: 0,
});
const article = await request(pool, tx) const articleId = article.rows[0]?.id ?? article.insertId;
.input('sku', sku)
.input('unitId', unitId)
.input('unitamount', 1)
.input('weight', 0)
.query(CREATE_ARTICLE);
const articleId = article.recordset[0].id; // Resolve the parent category id (explicit id or breadcrumb path).
let parentId = row.parentId;
// Resolve the parent category id (explicit id or breadcrumb path). if (parentId == null && row.parentCategory) {
let parentId = row.parentId; parentId = await ensureCategoryByPath(
if (parentId == null && row.parentCategory) { { googleAuthId, parentId: rootCategoryId, path: row.parentCategory },
parentId = await ensureCategoryByPath( runner,
{ googleAuthId, parentId: rootCategoryId, path: row.parentCategory }, );
tx, }
); if (parentId == null) {
} throw new Error('Parent category required');
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;
} }
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). await runner.query(addCategoryArticleSql, {
const rawEans = row.eans; categoryId: parentId,
const eans = Array.isArray(rawEans) articleId,
? 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 tx.commit(); await runner.query(createArticleTextSql, {
imported.push({ id: articleId, sku, name }); 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) { } catch (err) {
if (tx) {
try {
await tx.rollback();
} catch {
// ignore
}
}
errors.push({ sku, name, error: err.message ?? String(err) }); 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() : ''; row?.fatherSku != null ? String(row.fatherSku).trim() : '';
if (!sku || !fatherSku) continue; if (!sku || !fatherSku) continue;
let tx;
try { try {
tx = pool.transaction(); await runInTx(async (runner) => {
await tx.begin(); const child = await runner.query(articleQueries.GET_ID_BY_SKU, { sku });
const childId = child.rows[0]?.articleId;
const child = await request(pool, tx) if (childId) {
.input('sku', sku) const father = await runner.query(articleQueries.GET_ID_BY_SKU, { sku: fatherSku });
.query(GET_ID_BY_SKU); const fatherId = father.rows[0]?.articleId;
const childId = child.recordset[0]?.articleId; if (fatherId && fatherId !== childId) {
if (childId) { await runner.query(articleQueries.UPDATE_FATHER_BY_SKU, {
const father = await request(pool, tx) sku,
.input('sku', fatherSku) fatherArticleId: fatherId,
.query(GET_ID_BY_SKU); });
const fatherId = father.recordset[0]?.articleId; await runner.query(articleQueries.SET_IS_FATHER, {
if (fatherId && fatherId !== childId) { articleId: fatherId,
await request(pool, tx) });
.input('sku', sku) const childCount = await runner.query(getChildCountSql, {
.input('fatherArticleId', fatherId) articleId: fatherId,
.query(UPDATE_FATHER_BY_SKU); });
await request(pool, tx) const count = childCount.rows[0].count;
.input('articleId', fatherId) // The child's sort is its 0-based position among the father's children.
.query(SET_IS_FATHER); await runner.query(articleQueries.SET_FATHER_SORT, {
const childCount = await request(pool, tx) articleId: childId,
.input('articleId', fatherId) fatherArticleId: fatherId,
.query(GET_CHILD_COUNT); fathersort: count - 1,
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 tx.commit();
} catch { } catch {
if (tx) {
try {
await tx.rollback();
} catch {
// ignore
}
}
// Non-fatal: article was already imported in pass 1. // 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import makeRunner from '../runner.js';
const ROOT_CATEGORY_NAME = 'Home'; const ROOT_CATEGORY_NAME = 'Home';
@@ -10,9 +12,11 @@ function parseJsonArray(raw) {
return Array.isArray(arr) ? arr : []; return Array.isArray(arr) ? arr : [];
} }
export default function listExport(pool) { export default function listExport(provider) {
const companyAccess = resolveSelectedCompanyAccess(pool); const companyAccess = resolveSelectedCompanyAccess(provider);
const categoryAccess = resolveCategoryAccess(pool); const categoryAccess = resolveCategoryAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteArticle : mssqlArticle;
/** /**
* Returns articles for export, optionally scoped to a category, including * 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} 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 {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. * @returns {Promise<Array<object>>} The articles for export.
* @throws {Error} If the category is not reachable from the selected company. * @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); * const articles = await store.article.listExport(42, null);
* // => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }] * // => [{ 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 } = const { languageId, posCustomerGroupId, rootCategoryId } =
await companyAccess(googleAuthId); await companyAccess(googleAuthId, runner);
// Default to the company's root category when no specific category is given. // Default to the company's root category when no specific category is given.
let exportCategoryId = rootCategoryId; let exportCategoryId = rootCategoryId;
if (categoryId != null) { if (categoryId != null) {
// The requested category must be reachable via the selected company's root. // 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) { if (access.rootCategoryId !== rootCategoryId) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
@@ -44,22 +50,20 @@ export default function listExport(pool) {
} }
const [result, treeResult] = await Promise.all([ const [result, treeResult] = await Promise.all([
pool runner.query(queries.GET_ARTICLE_EXPORT, {
.request() categoryId: exportCategoryId,
.input('categoryId', exportCategoryId) languageId,
.input('languageId', languageId) posCustomerGroupId,
.input('posCustomerGroupId', posCustomerGroupId) }),
.query(GET_ARTICLE_EXPORT), runner.query(queries.GET_CATEGORY_TREE_FLAT, {
pool rootId: rootCategoryId,
.request() languageId,
.input('rootId', rootCategoryId) }),
.input('languageId', languageId)
.query(GET_CATEGORY_TREE_FLAT),
]); ]);
// id -> { id, parentId, name } for breadcrumb resolution. // id -> { id, parentId, name } for breadcrumb resolution.
const catById = new Map(); const catById = new Map();
for (const node of treeResult.recordset) { for (const node of treeResult.rows) {
catById.set(String(node.id), node); catById.set(String(node.id), node);
} }
@@ -67,10 +71,10 @@ export default function listExport(pool) {
const displayName = (node) => const displayName = (node) =>
String(node.id) === String(rootCategoryId) ? ROOT_CATEGORY_NAME : (node.name ?? '').trim(); String(node.id) === String(rootCategoryId) ? ROOT_CATEGORY_NAME : (node.name ?? '').trim();
const breadcrumb = (categoryId) => { const breadcrumb = (cId) => {
const crumbs = []; const crumbs = [];
const seen = new Set(); const seen = new Set();
let id = categoryId; let id = cId;
while (id != null && !seen.has(String(id))) { while (id != null && !seen.has(String(id))) {
seen.add(String(id)); seen.add(String(id));
const node = catById.get(String(id)); const node = catById.get(String(id));
@@ -82,7 +86,7 @@ export default function listExport(pool) {
return crumbs.join('·'); return crumbs.join('·');
}; };
return result.recordset.map((row) => ({ return result.rows.map((row) => ({
id: row.id, id: row.id,
sku: row.sku, sku: row.sku,
name: row.name, 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function listSearchIndex(pool) { export default function listSearchIndex(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {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. * @returns {Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>} The article search index.
* *
* @example * @example
* const articles = await store.article.listSearchIndex(42); * const articles = await store.article.listSearchIndex(42);
* // => [{ id, sku, name, eans, categoryIds }] * // => [{ id, sku, name, eans, categoryIds }]
*/ */
return async (googleAuthId) => { return async (googleAuthId, tx) => {
const { companyId, rootCategoryId, languageId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
const { rootCategoryId, languageId } = await access(googleAuthId, runner);
const result = await pool const result = await runner.query(sql, {
.request() rootCategoryId,
.input('rootCategoryId', rootCategoryId) languageId,
.input('languageId', languageId) });
.query(SEARCH_ARTICLES);
return result.recordset.map((row) => ({ return result.rows.map((row) => ({
id: row.id, id: row.id,
sku: row.sku, sku: row.sku,
name: row.name, name: row.name,
@@ -34,3 +38,4 @@ export default function listSearchIndex(pool) {
})); }));
}; };
} }

View File

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

View File

@@ -1,10 +1,6 @@
import { import * as mssqlDetail from '../../providers/mssql/queries/articleDetail.js';
GET_CATEGORY_IDS_BY_ARTICLE_ID, import * as sqliteDetail from '../../providers/sqlite3/queries/articleDetail.js';
GET_DETAIL_BY_ARTICLE_ID, import makeRunner from '../runner.js';
GET_EANS_BY_ARTICLE_ID,
GET_PHOTOS_BY_ARTICLE_ID,
GET_PRICES_BY_ARTICLE_ID,
} from '../../providers/mssql/queries/articleDetail.js';
function shapeArticleDetail(row, categoryIds, photos, eans, prices) { function shapeArticleDetail(row, categoryIds, photos, eans, prices) {
return { return {
@@ -41,31 +37,32 @@ function shapeArticleDetail(row, categoryIds, photos, eans, prices) {
}; };
} }
export default function queryBySku(pool) { export default function queryBySku(provider) {
return async (articleId, languageId) => { 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([ const [detail, categories, photos, eans, prices] = await Promise.all([
pool runner.query(queries.GET_DETAIL_BY_ARTICLE_ID, { articleId, languageId }),
.request() runner.query(queries.GET_CATEGORY_IDS_BY_ARTICLE_ID, { articleId }),
.input('articleId', articleId) runner.query(queries.GET_PHOTOS_BY_ARTICLE_ID, { articleId }),
.input('languageId', languageId) runner.query(queries.GET_EANS_BY_ARTICLE_ID, { articleId }),
.query(GET_DETAIL_BY_ARTICLE_ID), runner.query(queries.GET_PRICES_BY_ARTICLE_ID, { articleId }),
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),
]); ]);
const row = detail.recordset[0]; const row = detail.rows[0];
if (!row) { if (!row) {
return null; return null;
} }
return shapeArticleDetail( return shapeArticleDetail(
row, row,
categories.recordset, categories.rows,
photos.recordset, photos.rows,
eans.recordset, eans.rows,
prices.recordset, 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) { export default function resolveArticleBySku(provider) {
return async (googleAuthId, sku) => { const sql =
const result = await pool provider?.dialect === 'sqlite3' ? SQLITE_RESOLVE : MSSQL_RESOLVE;
.request()
.input('sku', sku)
.input('googleAuthId', googleAuthId)
.query(RESOLVE_ARTICLE_ACCESS_BY_SKU);
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) { if (!row?.articleId || !row?.categoryId) {
throw new Error('Article not found'); throw new Error('Article not found');
} }
@@ -30,3 +32,4 @@ export default function resolveArticleBySku(pool) {
}; };
}; };
} }

View File

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

View File

@@ -1,16 +1,24 @@
import { import {
ADD_CATEGORY, ADD_CATEGORY as MSSQL_ADD_CATEGORY,
DELETE_CATEGORIES_BY_ARTICLE, DELETE_CATEGORIES_BY_ARTICLE as MSSQL_DELETE_CATEGORIES,
} from '../../providers/mssql/queries/article.js'; } 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 resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import queryBySku from './queryBySku.js'; import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setCategoryIdsBySku(pool) { export default function setCategoryIdsBySku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const categoryAccess = resolveCategoryAccess(pool); const categoryAccess = resolveCategoryAccess(provider);
const load = queryBySku(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {Array<number>} categoryIds - The category ids the article should belong to (at least one). * @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. * @returns {Promise<object>} The updated article.
* @throws {Error} If the category ids are invalid or the article is not found. * @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]); * const article = await store.article.setCategoryIdsBySku(42, 'SKU-1', [5, 9]);
* // => { id, sku, name, ... } * // => { id, sku, name, ... }
*/ */
return async (googleAuthId, sku, categoryIds) => { return async (googleAuthId, sku, categoryIds, tx) => {
if (!Array.isArray(categoryIds)) { if (!Array.isArray(categoryIds)) {
throw new Error('Invalid 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'); throw new Error('Article must have at least one category');
} }
const { articleId, languageId, companyId } = await resolve( const runSet = async (runner) => {
googleAuthId, const { articleId, languageId, companyId } = await resolve(
sku, googleAuthId,
); sku,
runner,
);
const tx = pool.transaction();
await tx.begin();
try {
for (const categoryId of normalized) { for (const categoryId of normalized) {
// Ensures the category belongs to the same company as the article. // 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) { if (access.companyId !== companyId) {
throw new Error('Invalid categoryIds'); throw new Error('Invalid categoryIds');
} }
} }
await request(pool, tx) await runner.query(deleteCategoriesSql, { articleId });
.input('articleId', articleId)
.query(DELETE_CATEGORIES_BY_ARTICLE);
for (const categoryId of normalized) { for (const categoryId of normalized) {
await request(pool, tx) await runner.query(addCategorySql, {
.input('categoryId', categoryId) categoryId,
.input('articleId', articleId) articleId,
.query(ADD_CATEGORY); });
} }
await tx.commit(); const article = await load(articleId, languageId, runner);
} catch (err) { if (!article) {
try { throw new Error('Article not found');
await tx.rollback();
} catch {
// ignore
} }
throw err;
}
const article = await load(articleId, languageId); return article;
if (!article) { };
throw new Error('Article not found');
}
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 { import {
ADD_EAN, ADD_EAN as MSSQL_ADD_EAN,
DELETE_EANS_BY_ARTICLE, DELETE_EANS_BY_ARTICLE as MSSQL_DELETE_EANS,
} from '../../providers/mssql/queries/article.js'; } 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 queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setEansBySku(pool) { export default function setEansBySku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const load = queryBySku(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {Array<string>} eans - The EANs to set (must be unique, non-empty strings). * @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. * @returns {Promise<object>} The updated article.
* @throws {Error} If the EANs are invalid or the article is not found. * @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']); * const article = await store.article.setEansBySku(42, 'SKU-1', ['4001234567890']);
* // => { id, sku, name, ... } * // => { id, sku, name, ... }
*/ */
return async (googleAuthId, sku, eans) => { return async (googleAuthId, sku, eans, tx) => {
if (!Array.isArray(eans)) { if (!Array.isArray(eans)) {
throw new Error('Invalid eans'); throw new Error('Invalid eans');
} }
@@ -41,38 +50,30 @@ export default function setEansBySku(pool) {
normalized.push(clean); 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 runner.query(deleteEansSql, { articleId });
await tx.begin();
try {
await request(pool, tx)
.input('articleId', articleId)
.query(DELETE_EANS_BY_ARTICLE);
for (const ean of normalized) { for (const ean of normalized) {
await request(pool, tx) await runner.query(addEanSql, { articleId, ean });
.input('articleId', articleId)
.input('ean', ean)
.query(ADD_EAN);
} }
await tx.commit(); const article = await load(articleId, languageId, runner);
} catch (err) { if (!article) {
try { throw new Error('Article not found');
await tx.rollback();
} catch {
// ignore
} }
throw err;
}
const article = await load(articleId, languageId); return article;
if (!article) { };
throw new Error('Article not found');
}
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 { import * as mssqlArticle from '../../providers/mssql/queries/article.js';
GET_ID_BY_SKU, import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
SET_FATHER_SORT, import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js';
SET_IS_FATHER, import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js';
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 queryBySku from './queryBySku.js'; import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setFatherBySku(pool) { export default function setFatherBySku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const load = queryBySku(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The child article SKU. * @param {string} sku - The child article SKU.
* @param {string|null} fatherSku - The father article SKU, or `null` to unlink. * @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. * @returns {Promise<object>} The updated child article.
* @throws {Error} If the article or father is not found, or the link is invalid. * @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'); * const article = await store.article.setFatherBySku(42, '42001190-1', '42001190');
* // => { id, sku, name, fatherSku: '42001190', ... } * // => { id, sku, name, fatherSku: '42001190', ... }
*/ */
return async (googleAuthId, sku, fatherSku) => { return async (googleAuthId, sku, fatherSku, tx) => {
const { articleId, languageId } = await resolve(googleAuthId, sku); const runSet = async (runner) => {
const { articleId, languageId } = await resolve(googleAuthId, sku, runner);
if (fatherSku == null || fatherSku === '') { if (fatherSku == null || fatherSku === '') {
await request(pool) await runner.query(queries.UPDATE_FATHER_BY_SKU, {
.input('sku', sku) sku,
.input('fatherArticleId', null) fatherArticleId: null,
.query(UPDATE_FATHER_BY_SKU); });
const unlinked = await load(articleId, languageId); const unlinked = await load(articleId, languageId, runner);
if (!unlinked) { if (!unlinked) {
throw new Error('Article not found'); throw new Error('Article not found');
}
return unlinked;
} }
return unlinked;
}
const father = await pool const father = await runner.query(queries.GET_ID_BY_SKU, { sku: fatherSku });
.request() const fatherId = father.rows[0]?.articleId;
.input('sku', fatherSku) if (!fatherId) {
.query(GET_ID_BY_SKU); throw new Error('Father not found');
const fatherId = father.recordset[0]?.articleId; }
if (!fatherId) { if (fatherId === articleId) {
throw new Error('Father not found'); throw new Error('Invalid father');
} }
if (fatherId === articleId) {
throw new Error('Invalid father');
}
const tx = pool.transaction(); await runner.query(queries.UPDATE_FATHER_BY_SKU, {
await tx.begin(); sku,
fatherArticleId: fatherId,
});
try { await runner.query(queries.SET_IS_FATHER, {
await request(pool, tx) articleId: fatherId,
.input('sku', sku) });
.input('fatherArticleId', fatherId)
.query(UPDATE_FATHER_BY_SKU);
await request(pool, tx) const childCount = await runner.query(getChildCountSql, {
.input('articleId', fatherId) articleId: fatherId,
.query(SET_IS_FATHER); });
const count = childCount.rows[0].count;
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. // The child's sort is its 0-based position among the father's children.
await request(pool, tx) await runner.query(queries.SET_FATHER_SORT, {
.input('articleId', articleId) articleId,
.input('fatherArticleId', fatherId) fatherArticleId: fatherId,
.input('fathersort', count - 1) fathersort: count - 1,
.query(SET_FATHER_SORT); });
await tx.commit(); const article = await load(articleId, languageId, runner);
} catch (err) { if (!article) {
try { throw new Error('Article not found');
await tx.rollback();
} catch {
// ignore
} }
throw err;
}
const article = await load(articleId, languageId); return article;
if (!article) { };
throw new Error('Article not found');
}
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 { import {
CREATE as CREATE_PRICE, CREATE as MSSQL_CREATE_PRICE,
DELETE_BY_ARTICLE_AND_CUSTOMERGROUP, DELETE_BY_ARTICLE_AND_CUSTOMERGROUP as MSSQL_DELETE_PRICE,
} from '../../providers/mssql/queries/price.js'; } 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 taxEnsure from '../tax/ensure.js';
import queryBySku from './queryBySku.js'; import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
function normalizePrices(prices) { function normalizePrices(prices) {
if (!Array.isArray(prices)) { if (!Array.isArray(prices)) {
@@ -42,10 +46,14 @@ function normalizePrices(prices) {
return normalized; return normalized;
} }
export default function setPricesBySku(pool) { export default function setPricesBySku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const ensureTax = taxEnsure(pool); const ensureTax = taxEnsure(provider);
const load = queryBySku(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {Array<{price: number, tax: number, minmultiplier?: number}>} prices - The price entries (minmultiplier defaults to 1). * @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. * @returns {Promise<object>} The updated article.
* @throws {Error} If the prices are invalid or the article is not found. * @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 }]); * const article = await store.article.setPricesBySku(42, 'SKU-1', [{ price: 1.5, tax: 19, minmultiplier: 1 }]);
* // => { id, sku, name, ... } * // => { id, sku, name, ... }
*/ */
return async (googleAuthId, sku, prices) => { return async (googleAuthId, sku, prices, tx) => {
const normalized = normalizePrices(prices); const normalized = normalizePrices(prices);
const { articleId, languageId, posCustomerGroupId } = await resolve( const runSet = async (runner) => {
googleAuthId, const { articleId, languageId, posCustomerGroupId } = await resolve(
sku, googleAuthId,
); sku,
if (!posCustomerGroupId) { runner,
throw new Error('POS customer group required'); );
} if (!posCustomerGroupId) {
throw new Error('POS customer group required');
}
const tx = pool.transaction();
await tx.begin();
try {
const taxIds = new Map(); const taxIds = new Map();
for (const entry of normalized) { for (const entry of normalized) {
if (!taxIds.has(entry.tax)) { 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) await runner.query(deletePriceSql, {
.input('articleId', articleId) articleId,
.input('customerGroupId', posCustomerGroupId) customerGroupId: posCustomerGroupId,
.query(DELETE_BY_ARTICLE_AND_CUSTOMERGROUP); });
for (const entry of normalized) { for (const entry of normalized) {
await request(pool, tx) await runner.query(createPriceSql, {
.input('taxId', taxIds.get(entry.tax)) taxId: taxIds.get(entry.tax),
.input('articleId', articleId) articleId,
.input('minmultiplier', entry.minmultiplier) minmultiplier: entry.minmultiplier,
.input('customerGroupId', posCustomerGroupId) customerGroupId: posCustomerGroupId,
.input('net', entry.price) net: entry.price,
.query(CREATE_PRICE); });
} }
await tx.commit(); const article = await load(articleId, languageId, runner);
} catch (err) { if (!article) {
try { throw new Error('Article not found');
await tx.rollback();
} catch {
// ignore
} }
throw err;
}
const article = await load(articleId, languageId); return article;
if (!article) { };
throw new Error('Article not found');
}
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 queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setWeightBySku(pool) { export default function setWeightBySku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const load = queryBySku(pool); const load = queryBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE_WEIGHT : MSSQL_UPDATE_WEIGHT;
/** /**
* Sets the weight of an article by its SKU. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {number} weight - The weight (must be >= 0). * @param {number} weight - The weight (must be >= 0).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated article. * @returns {Promise<object>} The updated article.
* @throws {Error} If the weight is invalid or the article is not found. * @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); * const article = await store.article.setWeightBySku(42, 'SKU-1', 1.5);
* // => { id, sku, name, ... } * // => { id, sku, name, ... }
*/ */
return async (googleAuthId, sku, weight) => { return async (googleAuthId, sku, weight, tx) => {
if (!Number.isFinite(weight) || weight < 0) { if (!Number.isFinite(weight) || weight < 0) {
throw new Error('Invalid weight'); 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 const result = await runner.query(sql, {
.request() articleId,
.input('articleId', articleId) weight,
.input('weight', weight) });
.query(UPDATE_WEIGHT);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Article not found'); throw new Error('Article not found');
} }
const article = await load(articleId, languageId); const article = await load(articleId, languageId, runner);
if (!article) { if (!article) {
throw new Error('Article not found'); throw new Error('Article not found');
} }
@@ -44,3 +48,4 @@ export default function setWeightBySku(pool) {
return article; return article;
}; };
} }

View File

@@ -1,15 +1,16 @@
import { import * as mssqlArticle from '../../providers/mssql/queries/article.js';
UPDATE_UNIT, import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
UPDATE_UNITAMOUNT, import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
UPDATE_UNIT_AND_AMOUNT, import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
} from '../../providers/mssql/queries/article.js'; import { UPDATE_BY_ARTICLE_AND_LANGUAGE as MSSQL_UPDATE_TEXT } from '../../providers/mssql/queries/articletext.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; import { UPDATE_BY_ARTICLE_AND_LANGUAGE as SQLITE_UPDATE_TEXT } from '../../providers/sqlite3/queries/articletext.js';
import { UPDATE_BY_ARTICLE_AND_LANGUAGE } from '../../providers/mssql/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 } 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 } from '../../providers/mssql/queries/unit.js'; import { GET_FULL_BY_SI as MSSQL_GET_UNIT } from '../../providers/mssql/queries/unit.js';
import request from '../request.js'; import { GET_FULL_BY_SI as SQLITE_GET_UNIT } from '../../providers/sqlite3/queries/unit.js';
import queryBySku from './queryBySku.js'; import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
import slugify from '../slugify.js'; import slugify from '../slugify.js';
function formatUnitLabel(amount, symbol) { function formatUnitLabel(amount, symbol) {
@@ -20,9 +21,16 @@ function formatUnitLabel(amount, symbol) {
return `${Number(amount)}${sym}`; return `${Number(amount)}${sym}`;
} }
export default function updateBySku(pool) { export default function updateBySku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const load = queryBySku(pool); 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. * 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.name] - The new article name.
* @param {string} [patch.unitSi] - The new unit SI code. * @param {string} [patch.unitSi] - The new unit SI code.
* @param {number} [patch.unitamount] - The new unit amount (must be > 0). * @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. * @returns {Promise<object>} The updated article.
* @throws {Error} If no valid field is provided or the article is not found. * @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 }); * const article = await store.article.updateBySku(42, 'SKU-1', { name: 'Cola 1L', unitSi: 'l', unitamount: 1 });
* // => { id, sku, name, ... } * // => { id, sku, name, ... }
*/ */
return async (googleAuthId, sku, patch = {}) => { return async (googleAuthId, sku, patch = {}, tx) => {
const hasName = Object.prototype.hasOwnProperty.call(patch, 'name'); const hasName = Object.prototype.hasOwnProperty.call(patch, 'name');
const hasUnitamount = Object.prototype.hasOwnProperty.call(patch, 'unitamount'); const hasUnitamount = Object.prototype.hasOwnProperty.call(patch, 'unitamount');
const hasUnitSi = Object.prototype.hasOwnProperty.call(patch, 'unitSi'); const hasUnitSi = Object.prototype.hasOwnProperty.call(patch, 'unitSi');
@@ -57,122 +66,118 @@ export default function updateBySku(pool) {
throw new Error('Invalid unitamount'); throw new Error('Invalid unitamount');
} }
const { articleId, languageId, companyId, companyuserId } = await resolve( const runUpdate = async (runner) => {
googleAuthId, const { articleId, languageId, companyId, companyuserId } = await resolve(
sku, googleAuthId,
); sku,
runner,
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 newLabel = formatUnitLabel(
hasUnitamount ? patch.unitamount : current.unitamount, const currentResult = await runner.query(getDetailSql, {
newUnitSymbol, articleId,
); languageId,
if (oldLabel !== newLabel) { });
historyLines.push(`Einheit geändert von "${oldLabel}" auf "${newLabel}"`); const current = currentResult.rows[0];
if (!current) {
throw new Error('Article not found');
} }
}
const tx = pool.transaction(); let unitId;
await tx.begin(); 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) { if (hasName) {
const slug = slugify(patch.name); const slug = slugify(patch.name);
const update = await request(pool, tx) const update = await runner.query(updateTextSql, {
.input('articleId', articleId) articleId,
.input('languageId', languageId) languageId,
.input('name', patch.name.trim()) name: patch.name.trim(),
.input('slug', slug) slug,
.query(UPDATE_BY_ARTICLE_AND_LANGUAGE); });
if (!update.rowsAffected[0]) { if (!update.rowsAffected) {
throw new Error('Article not found'); throw new Error('Article not found');
} }
} }
if (hasUnitSi && hasUnitamount) { if (hasUnitSi && hasUnitamount) {
await request(pool, tx) await runner.query(articleQueries.UPDATE_UNIT_AND_AMOUNT, {
.input('articleId', articleId) articleId,
.input('unitId', unitId) unitId,
.input('unitamount', patch.unitamount) unitamount: patch.unitamount,
.query(UPDATE_UNIT_AND_AMOUNT); });
} else if (hasUnitSi) { } else if (hasUnitSi) {
await request(pool, tx) await runner.query(articleQueries.UPDATE_UNIT, {
.input('articleId', articleId) articleId,
.input('unitId', unitId) unitId,
.query(UPDATE_UNIT); });
} else if (hasUnitamount) { } else if (hasUnitamount) {
await request(pool, tx) await runner.query(articleQueries.UPDATE_UNITAMOUNT, {
.input('articleId', articleId) articleId,
.input('unitamount', patch.unitamount) unitamount: patch.unitamount,
.query(UPDATE_UNITAMOUNT); });
} }
if (historyLines.length) { if (historyLines.length) {
await request(pool, tx) await runner.query(insertHistorySql, {
.input('articleId', articleId) articleId,
.input('companyuserId', companyuserId) companyuserId,
.input('text', historyLines.join('\n')) text: historyLines.join('\n'),
.query(INSERT_HISTORY); });
} }
await tx.commit(); const article = await load(articleId, languageId, runner);
} catch (err) { if (!article) {
try { throw new Error('Article not found');
await tx.rollback();
} catch {
// ignore
} }
throw err;
}
const article = await load(articleId, languageId); return article;
if (!article) { };
throw new Error('Article not found');
}
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 { import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
CHECK_EXISTS, import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
CREATE as CREATE_ARTICLE_SUPPLIER, import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js';
GET_BY_ID_AND_ARTICLE_ID, import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js';
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 resolveArticleBySku from '../article/resolveArticleBySku.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js';
import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js'; import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js';
import taxEnsure from '../tax/ensure.js'; import taxEnsure from '../tax/ensure.js';
@@ -15,10 +11,15 @@ import {
resolveCompanyDefaultTax, resolveCompanyDefaultTax,
shapeArticleSupplier, shapeArticleSupplier,
} from './helpers.js'; } from './helpers.js';
import makeRunner from '../runner.js';
export default function create(pool) { export default function create(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const ensureTax = taxEnsure(pool); 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. * 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|null} [input.netPrice] - Net purchase price.
* @param {number} [input.taxId] - The tax id for the 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 {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. * @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. * @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 } * // => { 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; const entityId = input.entityId;
if (entityId == null || !Number.isFinite(entityId)) { if (entityId == null || !Number.isFinite(entityId)) {
throw new Error('Supplier required'); throw new Error('Supplier required');
@@ -79,30 +81,26 @@ export default function create(pool) {
throw new Error('Invalid price'); 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 const existingCheck = await runner.query(queries.CHECK_EXISTS, {
.request() articleId,
.input('articleId', articleId) entityId,
.input('entityId', entityId) });
.query(CHECK_EXISTS);
if (existingCheck.recordset[0]) { if (existingCheck.rows[0]) {
throw new Error('Supplier already assigned'); throw new Error('Supplier already assigned');
} }
const tx = pool.transaction();
await tx.begin();
try {
let sort = input.sort; let sort = input.sort;
if (sort === undefined || sort === null) { if (sort === undefined || sort === null) {
const sortRes = await request(pool, tx) const sortRes = await runner.query(queries.GET_NEXT_SORT, {
.input('articleId', articleId) articleId,
.query(GET_NEXT_SORT); });
sort = sortRes.recordset[0].sort; sort = sortRes.rows[0].sort;
} }
let priceId = null; let priceId = null;
@@ -111,63 +109,60 @@ export default function create(pool) {
let taxId = input.taxId; let taxId = input.taxId;
if (taxId == null || !Number.isFinite(taxId)) { if (taxId == null || !Number.isFinite(taxId)) {
if (input.taxRate != null && Number.isFinite(input.taxRate)) { if (input.taxRate != null && Number.isFinite(input.taxRate)) {
taxId = await ensureTax(input.taxRate, tx); taxId = await ensureTax(input.taxRate, runner);
} else { } else {
const defaultTaxId = await resolveCompanyDefaultTax( const defaultTaxId = await resolveCompanyDefaultTax(
pool, runner,
tx,
companyId, companyId,
); );
taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, tx); taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, runner);
} }
} }
const customerGroupId = await ensureLieferantenGroup( const customerGroupId = await ensureLieferantenGroup(
pool, runner,
companyId, companyId,
tx,
); );
const priceRes = await request(pool, tx) const priceRes = await runner.query(createPriceSql, {
.input('taxId', taxId) taxId,
.input('articleId', articleId) articleId,
.input('minmultiplier', 1) minmultiplier: 1,
.input('customerGroupId', customerGroupId) customerGroupId,
.input('net', netPrice) net: netPrice,
.query(CREATE_PRICE); });
priceId = priceRes.recordset[0].id; priceId = priceRes.rows[0]?.id ?? priceRes.insertId;
} }
const createdRes = await request(pool, tx) const createdRes = await runner.query(queries.CREATE, {
.input('articleId', articleId) articleId,
.input('entityId', entityId) entityId,
.input('priceId', priceId) priceId,
.input('sku', supplierSku) sku: supplierSku,
.input('stock', stock) stock,
.input('pu', pu) pu,
.input('sort', sort) sort,
.query(CREATE_ARTICLE_SUPPLIER); });
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 return shapeArticleSupplier(loadRes.rows[0]);
.request() };
.input('id', createdId)
.input('articleId', articleId)
.input('companyId', companyId)
.query(GET_BY_ID_AND_ARTICLE_ID);
return shapeArticleSupplier(loadRes.recordset[0]); if (tx) {
} catch (err) { return runCreate(makeRunner(provider, tx));
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
} }
if (typeof provider?.transaction === 'function') {
return provider.transaction(runCreate);
}
return runCreate(makeRunner(provider));
}; };
} }

View File

@@ -1,13 +1,12 @@
import { import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
DELETE_BY_ID_AND_ARTICLE_ID, import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
DELETE_PRICE,
GET_BY_ID_AND_ARTICLE_ID,
} from '../../providers/mssql/queries/articlesupplier.js';
import request from '../request.js';
import resolveArticleBySku from '../article/resolveArticleBySku.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const resolve = resolveArticleBySku(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {number} id - The id of the articlesupplier record. * @param {number} id - The id of the articlesupplier record.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the supplier assignment is not found. * @throws {Error} If the supplier assignment is not found.
* *
* @example * @example
* await store.articlesupplier.delete(42, 'SKU-1', 12); * 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)) { if (id == null || !Number.isFinite(id)) {
throw new Error('Supplier assignment not found'); 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 const existingRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
.request() id,
.input('id', id) articleId,
.input('articleId', articleId) companyId,
.input('companyId', companyId) });
.query(GET_BY_ID_AND_ARTICLE_ID);
const existing = existingRes.recordset[0]; const existing = existingRes.rows[0];
if (!existing) { if (!existing) {
throw new Error('Supplier assignment not found'); throw new Error('Supplier assignment not found');
} }
const tx = pool.transaction(); await runner.query(queries.DELETE_BY_ID_AND_ARTICLE_ID, {
await tx.begin(); id,
articleId,
try { });
await request(pool, tx)
.input('id', id)
.input('articleId', articleId)
.query(DELETE_BY_ID_AND_ARTICLE_ID);
if (existing.priceId != null) { if (existing.priceId != null) {
await request(pool, tx) await runner.query(queries.DELETE_PRICE, {
.input('id', existing.priceId) id: existing.priceId,
.input('articleId', articleId) articleId,
.query(DELETE_PRICE); });
} }
};
await tx.commit(); if (tx) {
} catch (err) { return runDelete(makeRunner(provider, tx));
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
} }
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 {
import { GET_COMPANY_DEFAULT_TAX } from '../../providers/mssql/queries/articlesupplier.js'; GET_SUPPLIER_BY_ID as MSSQL_GET_SUPPLIER_BY_ID,
import request from '../request.js'; 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) { export function optionalString(value) {
if (value == null || value === '') return null; 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)) { if (supplierId == null || !Number.isFinite(supplierId)) {
throw new Error('Supplier not found'); throw new Error('Supplier not found');
} }
const result = await request(pool, tx) const isSqlite = runner?.dialect === 'sqlite3';
.input('id', supplierId) const getSupplierSql = isSqlite ? SQLITE_GET_SUPPLIER_BY_ID : MSSQL_GET_SUPPLIER_BY_ID;
.input('companyId', companyId) const groupName = isSqlite ? SQLITE_SUPPLIER_GROUP_NAME : MSSQL_SUPPLIER_GROUP_NAME;
.input('groupName', SUPPLIER_GROUP_NAME)
.query(GET_SUPPLIER_BY_ID);
if (!result.recordset[0]) { const result = await runner.query(getSupplierSql, {
id: supplierId,
companyId,
groupName,
});
if (!result.rows[0]) {
throw new Error('Supplier not found'); throw new Error('Supplier not found');
} }
return supplierId; return supplierId;
} }
export async function resolveCompanyDefaultTax(pool, tx, companyId) { export async function resolveCompanyDefaultTax(runnerOrPool, txOrCompanyId, maybeCompanyId) {
const result = await request(pool, tx) let runner;
.input('companyId', companyId) let companyId;
.query(GET_COMPANY_DEFAULT_TAX);
return result.recordset[0]?.taxrate ?? null; 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 resolveArticleBySku from '../article/resolveArticleBySku.js';
import { shapeArticleSupplier } from './helpers.js'; import { shapeArticleSupplier } from './helpers.js';
import makeRunner from '../runner.js';
export default function listByArticleSku(pool) { export default function listByArticleSku(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY;
/** /**
* Lists the supplier assignments for an article by its SKU. * Lists the supplier assignments for an article by its SKU.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU. * @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction.
* @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. * @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. * @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'); * const suppliers = await store.articlesupplier.listByArticleSku(42, 'SKU-1');
* // => [{ id, articleId, entityId, supplierName, sku, pu, stock, priceId, price }] * // => [{ id, articleId, entityId, supplierName, sku, pu, stock, priceId, price }]
*/ */
return async (googleAuthId, sku) => { return async (googleAuthId, sku, tx) => {
const { articleId, companyId } = await resolve(googleAuthId, sku); const runner = makeRunner(provider, tx);
const { articleId, companyId } = await resolve(googleAuthId, sku, runner);
const result = await pool const result = await runner.query(sql, {
.request() articleId,
.input('articleId', articleId) companyId,
.input('companyId', companyId) });
.query(LIST_BY_ARTICLE_ID);
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { assertSupplier } from './helpers.js'; import { assertSupplier } from './helpers.js';
import makeRunner from '../runner.js';
function parseJsonArray(raw) { function parseJsonArray(raw) {
if (!raw) return []; if (!raw) return [];
@@ -12,8 +14,9 @@ function parseJsonArray(raw) {
} }
} }
export default function listByEntityAndSkus(pool) { export default function listByEntityAndSkus(provider) {
const resolveAccess = resolveSelectedCompanyAccess(pool); 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, * 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} 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 {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 {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. * @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. * @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']); * 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 } }] * // => [{ 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 entityId;
let skus; let skus;
@@ -68,19 +72,19 @@ export default function listByEntityAndSkus(pool) {
skusJson = JSON.stringify(Array.from(new Set(cleanList))); 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 const result = await runner.query(sql, {
.request() entityId: parsedEntityId,
.input('entityId', parsedEntityId) companyId,
.input('companyId', companyId) languageId,
.input('languageId', languageId) skusJson,
.input('skusJson', skusJson) });
.query(LIST_BY_ENTITY_AND_SKUS);
return result.recordset.map((row) => { return result.rows.map((row) => {
const rawPhotos = parseJsonArray(row.photosJson); const rawPhotos = parseJsonArray(row.photosJson);
const photos = rawPhotos.map((p) => ({ const photos = rawPhotos.map((p) => ({
sort: p.sort, sort: p.sort,
@@ -128,3 +132,4 @@ export default function listByEntityAndSkus(pool) {
}); });
}; };
} }

View File

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

View File

@@ -1,11 +1,7 @@
import { import * as mssqlQueries from '../../providers/mssql/queries/articlesupplier.js';
DELETE_PRICE, import * as sqliteQueries from '../../providers/sqlite3/queries/articlesupplier.js';
GET_BY_ID_AND_ARTICLE_ID, import { CREATE as MSSQL_CREATE_PRICE } from '../../providers/mssql/queries/price.js';
UPDATE as UPDATE_ARTICLE_SUPPLIER, import { CREATE as SQLITE_CREATE_PRICE } from '../../providers/sqlite3/queries/price.js';
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 resolveArticleBySku from '../article/resolveArticleBySku.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js';
import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js'; import ensureLieferantenGroup from '../supplier/ensureLieferantenGroup.js';
import taxEnsure from '../tax/ensure.js'; import taxEnsure from '../tax/ensure.js';
@@ -14,10 +10,15 @@ import {
resolveCompanyDefaultTax, resolveCompanyDefaultTax,
shapeArticleSupplier, shapeArticleSupplier,
} from './helpers.js'; } from './helpers.js';
import makeRunner from '../runner.js';
export default function update(pool) { export default function update(provider) {
const resolve = resolveArticleBySku(pool); const resolve = resolveArticleBySku(provider);
const ensureTax = taxEnsure(pool); 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. * 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|null} [patch.netPrice] - Net purchase price.
* @param {number} [patch.taxId] - The tax id for the purchase price. * @param {number} [patch.taxId] - The tax id for the purchase price.
* @param {number} [patch.taxRate] - The tax rate in percent. * @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. * @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. * @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 } * // => { 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)) { if (id == null || !Number.isFinite(id)) {
throw new Error('Supplier assignment not found'); 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 const existingRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
.request() id,
.input('id', id) articleId,
.input('articleId', articleId) companyId,
.input('companyId', companyId) });
.query(GET_BY_ID_AND_ARTICLE_ID);
const existing = existingRes.recordset[0]; const existing = existingRes.rows[0];
if (!existing) { if (!existing) {
throw new Error('Supplier assignment not found'); throw new Error('Supplier assignment not found');
} }
const tx = pool.transaction();
await tx.begin();
try {
let priceId = existing.priceId; let priceId = existing.priceId;
if (patch.netPrice !== undefined) { if (patch.netPrice !== undefined) {
@@ -99,20 +97,20 @@ export default function update(pool) {
const oldPriceId = priceId; const oldPriceId = priceId;
priceId = null; priceId = null;
await request(pool, tx) await runner.query(queries.UPDATE, {
.input('id', id) id,
.input('articleId', articleId) articleId,
.input('sku', existing.sku) sku: existing.sku,
.input('stock', existing.stock) stock: existing.stock,
.input('pu', existing.pu) pu: existing.pu,
.input('sort', existing.sort) sort: existing.sort,
.input('priceId', null) priceId: null,
.query(UPDATE_ARTICLE_SUPPLIER); });
await request(pool, tx) await runner.query(queries.DELETE_PRICE, {
.input('id', oldPriceId) id: oldPriceId,
.input('articleId', articleId) articleId,
.query(DELETE_PRICE); });
} }
} else { } else {
// netPrice is a valid number // netPrice is a valid number
@@ -120,49 +118,47 @@ export default function update(pool) {
let taxId = patch.taxId; let taxId = patch.taxId;
if (taxId == null || !Number.isFinite(taxId)) { if (taxId == null || !Number.isFinite(taxId)) {
if (patch.taxRate != null && Number.isFinite(patch.taxRate)) { if (patch.taxRate != null && Number.isFinite(patch.taxRate)) {
taxId = await ensureTax(patch.taxRate, tx); taxId = await ensureTax(patch.taxRate, runner);
} else { } else {
taxId = existing.priceTaxId; taxId = existing.priceTaxId;
} }
} }
await request(pool, tx) await runner.query(queries.UPDATE_PRICE, {
.input('id', priceId) id: priceId,
.input('articleId', articleId) articleId,
.input('net', patch.netPrice) net: patch.netPrice,
.input('taxId', taxId) taxId,
.query(UPDATE_PRICE); });
} else { } else {
// create new price row // create new price row
let taxId = patch.taxId; let taxId = patch.taxId;
if (taxId == null || !Number.isFinite(taxId)) { if (taxId == null || !Number.isFinite(taxId)) {
if (patch.taxRate != null && Number.isFinite(patch.taxRate)) { if (patch.taxRate != null && Number.isFinite(patch.taxRate)) {
taxId = await ensureTax(patch.taxRate, tx); taxId = await ensureTax(patch.taxRate, runner);
} else { } else {
const defaultTaxId = await resolveCompanyDefaultTax( const defaultTaxId = await resolveCompanyDefaultTax(
pool, runner,
tx,
companyId, companyId,
); );
taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, tx); taxId = defaultTaxId != null ? defaultTaxId : await ensureTax(0, runner);
} }
} }
const customerGroupId = await ensureLieferantenGroup( const customerGroupId = await ensureLieferantenGroup(
pool, runner,
companyId, companyId,
tx,
); );
const priceRes = await request(pool, tx) const priceRes = await runner.query(createPriceSql, {
.input('taxId', taxId) taxId,
.input('articleId', articleId) articleId,
.input('minmultiplier', 1) minmultiplier: 1,
.input('customerGroupId', customerGroupId) customerGroupId,
.input('net', patch.netPrice) net: patch.netPrice,
.query(CREATE_PRICE); });
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 nextPu = patch.pu !== undefined ? patch.pu : existing.pu;
const nextSort = patch.sort !== undefined ? patch.sort : existing.sort; const nextSort = patch.sort !== undefined ? patch.sort : existing.sort;
await request(pool, tx) await runner.query(queries.UPDATE, {
.input('id', id) id,
.input('articleId', articleId) articleId,
.input('sku', nextSku) sku: nextSku,
.input('stock', nextStock) stock: nextStock,
.input('pu', nextPu) pu: nextPu,
.input('sort', nextSort) sort: nextSort,
.input('priceId', priceId) priceId,
.query(UPDATE_ARTICLE_SUPPLIER); });
await tx.commit(); const loadRes = await runner.query(queries.GET_BY_ID_AND_ARTICLE_ID, {
id,
articleId,
companyId,
});
const loadRes = await pool return shapeArticleSupplier(loadRes.rows[0]);
.request() };
.input('id', id)
.input('articleId', articleId)
.input('companyId', companyId)
.query(GET_BY_ID_AND_ARTICLE_ID);
return shapeArticleSupplier(loadRes.recordset[0]); if (tx) {
} catch (err) { return runUpdate(makeRunner(provider, tx));
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
} }
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * Deletes a brand for the user's selected company by its id.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the brand to delete. * @param {number} id - The id of the brand to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the brand is not found. * @throws {Error} If the brand is not found.
* *
* @example * @example
* await store.brand.delete(42, 3); * await store.brand.delete(42, 3);
*/ */
return async (googleAuthId, id) => { return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Brand not found'); throw new Error('Brand not found');
} }
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
try { try {
const result = await pool const result = await runner.query(sql, {
.request() id,
.input('id', id) companyId,
.input('companyId', companyId) });
.query(DELETE_BY_COMPANY_AND_ID);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Brand not found'); throw new Error('Brand not found');
} }
} catch (err) { } catch (err) {
if (err.number === 547) { if (isForeignKeyConstraintError(err)) {
throw new Error('Brand in use'); throw new Error('Brand in use');
} }
throw err; 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function list(pool) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * Lists the brands of the user's selected company.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>} The brands. * @returns {Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>} The brands.
* *
* @example * @example
* const brands = await store.brand.list(42); * const brands = await store.brand.list(42);
* // => [{ id, name, gsprpinfo }] * // => [{ id, name, gsprpinfo }]
*/ */
return async (googleAuthId) => { return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
const result = await pool const result = await makeRunner(provider, tx).query(sql, { companyId });
.request()
.input('companyId', companyId)
.query(LIST_BY_COMPANY);
return result.recordset; return result.rows;
}; };
} }

View File

@@ -1,12 +1,24 @@
import { import {
CREATE, CREATE as MSSQL_CREATE,
GET_BY_COMPANY_AND_NAME, GET_BY_COMPANY_AND_NAME as MSSQL_GET_BY_COMPANY_AND_NAME,
UPDATE_BY_ID, UPDATE_BY_ID as MSSQL_UPDATE_BY_ID,
} from '../../providers/mssql/queries/brand.js'; } 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function upsert(pool) { export default function upsert(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {object} input - The brand input.
* @param {string} input.name - The brand name. * @param {string} input.name - The brand name.
* @param {string} [input.gsprpinfo] - Optional GSPRP info. * @param {string} [input.gsprpinfo] - Optional GSPRP info.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, name: string, gsprpinfo: string|null}>} The upserted brand. * @returns {Promise<{id: number, name: string, gsprpinfo: string|null}>} The upserted brand.
* @throws {Error} If the brand is invalid. * @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' }); * const brand = await store.brand.upsert(42, { name: 'Coca-Cola', gsprpinfo: 'x' });
* // => { id, name, gsprpinfo } * // => { id, name, gsprpinfo }
*/ */
return async (googleAuthId, input = {}) => { return async (googleAuthId, input = {}, tx) => {
const name = input.name?.trim(); const name = input.name?.trim();
if (!name) { if (!name) {
throw new Error('Invalid brand'); throw new Error('Invalid brand');
} }
const gsprpinfo = input.gsprpinfo ?? null; 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 const existing = await runner.query(getSql, {
.request() companyId,
.input('companyId', companyId) name,
.input('name', name) });
.query(GET_BY_COMPANY_AND_NAME);
if (existing.recordset[0]) { if (existing.rows[0]) {
const { id } = existing.recordset[0]; const { id } = existing.rows[0];
await pool await runner.query(updateSql, {
.request() id,
.input('id', id) companyId,
.input('companyId', companyId) name,
.input('name', name) gsprpinfo,
.input('gsprpinfo', gsprpinfo) });
.query(UPDATE_BY_ID);
return { id, name, gsprpinfo }; return { id, name, gsprpinfo };
} }
const created = await pool const created = await runner.query(createSql, {
.request() companyId,
.input('companyId', companyId) name,
.input('name', name) gsprpinfo,
.input('gsprpinfo', gsprpinfo) });
.query(CREATE);
const id = created.rows[0]?.id ?? created.insertId;
return { return {
id: created.recordset[0].id, id,
name, name,
gsprpinfo, gsprpinfo,
}; };

View File

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

View File

@@ -1,14 +1,28 @@
import { import {
CREATE, CREATE as MSSQL_CREATE,
GET_NEXT_SIBLING_SORT, GET_NEXT_SIBLING_SORT as MSSQL_GET_NEXT_SIBLING_SORT,
} from '../../providers/mssql/queries/category.js'; } 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 resolveCategoryAccess from './resolveCategoryAccess.js';
import request from '../request.js';
import slugify from '../slugify.js'; import slugify from '../slugify.js';
import makeRunner from '../runner.js';
export default function createUnderParent(pool) { export default function createUnderParent(provider) {
const access = resolveCategoryAccess(pool); 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. * 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.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category. * @param {number} input.parentId - The id of the parent category.
* @param {string} input.name - The category name. * @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. * @returns {Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>} The created category.
* *
* @example * @example
* const category = await store.category.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Drinks' }); * const category = await store.category.createUnderParent({ googleAuthId: 42, parentId: 5, name: 'Drinks' });
* // => { id, parentId, sort, name, slug } * // => { id, parentId, sort, name, slug }
*/ */
return async ({ googleAuthId, parentId, name }) => { return async ({ googleAuthId, parentId, name }, tx) => {
const { languageId } = await access(googleAuthId, parentId); const runCreate = async (runner) => {
const { languageId } = await access(googleAuthId, parentId, runner);
const tx = pool.transaction(); const sortResult = await runner.query(getSortSql, { parentId });
await tx.begin(); 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 slug = slugify(name); const slug = slugify(name);
const category = await request(pool, tx) const category = await runner.query(createSql, {
.input('parentId', parentId) parentId,
.input('sort', sort) sort,
.query(CREATE); });
const { id, parentId: resolvedParentId } = category.recordset[0]; const id = category.rows[0]?.id ?? category.insertId;
await request(pool, tx) await runner.query(createTextSql, {
.input('categoryId', id) categoryId: id,
.input('languageId', languageId) languageId,
.input('name', name) name,
.input('slug', slug) slug,
.query(CREATE_CATEGORYTEXT); });
await tx.commit();
return { return {
id, id,
parentId: resolvedParentId, parentId,
sort, sort,
name, name,
slug, slug,
}; };
} catch (err) { };
try {
await tx.rollback(); if (tx) {
} catch { return runCreate(makeRunner(provider, tx));
// ignore
}
throw err;
} }
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 resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const access = resolveCategoryAccess(pool); const access = resolveCategoryAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/** /**
* Deletes a category, refusing to delete the company's root category. * 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 {object} input - The delete input.
* @param {number} input.googleAuthId - The id of the Google auth user. * @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to delete. * @param {number} input.id - The id of the category to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the category is the root category. * @throws {Error} If the category is the root category.
* *
* @example * @example
* await store.category.delete({ googleAuthId: 42, id: 5 }); * await store.category.delete({ googleAuthId: 42, id: 5 });
*/ */
return async ({ googleAuthId, id }) => { return async ({ googleAuthId, id }, tx) => {
const { rootCategoryId } = await access(googleAuthId, id); const { rootCategoryId } = await access(googleAuthId, id, tx);
if (id === rootCategoryId) { if (id === rootCategoryId) {
throw new Error('Cannot delete root category'); 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 resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function deleteImage(pool) { export default function deleteImage(provider) {
const access = resolveCategoryAccess(pool); const access = resolveCategoryAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/** /**
* Deletes the image of a category. * Deletes the image of a category.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category. * @param {number} categoryId - The id of the category.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>} The cleared image info. * @returns {Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>} The cleared image info.
* @throws {Error} If the image is not found. * @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); * const result = await store.category.deleteImage(42, 5);
* // => { id: 5, thumbW: null, thumbH: null, thumbHash: null } * // => { id: 5, thumbW: null, thumbH: null, thumbHash: null }
*/ */
return async (googleAuthId, categoryId) => { return async (googleAuthId, categoryId, tx) => {
await access(googleAuthId, categoryId); const runner = makeRunner(provider, tx);
await access(googleAuthId, categoryId, runner);
const result = await pool const result = await runner.query(sql, { categoryId });
.request()
.input('categoryId', categoryId)
.query(DELETE_BY_CATEGORY);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Image not found'); throw new Error('Image not found');
} }

View File

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

View File

@@ -1,27 +1,29 @@
import resolveCategoryAccess from './resolveCategoryAccess.js'; import resolveCategoryAccess from './resolveCategoryAccess.js';
import queryCategoryArticles from './queryCategoryArticles.js'; import queryCategoryArticles from './queryCategoryArticles.js';
export default function getCategoryArticles(pool) { export default function getCategoryArticles(provider) {
const access = resolveCategoryAccess(pool); const access = resolveCategoryAccess(provider);
const articles = queryCategoryArticles(pool); const articles = queryCategoryArticles(provider);
/** /**
* Returns the articles belonging to a single category. * Returns the articles belonging to a single category.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category. * @param {number} categoryId - The id of the category.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<object>>} The articles in the category. * @returns {Promise<Array<object>>} The articles in the category.
* *
* @example * @example
* const articles = await store.category.getCategoryArticles(42, 5); * const articles = await store.category.getCategoryArticles(42, 5);
* // => [{ id, sku, name, ... }] * // => [{ id, sku, name, ... }]
*/ */
return async (googleAuthId, categoryId) => { return async (googleAuthId, categoryId, tx) => {
const { languageId, posCustomerGroupId } = await access( const { languageId, posCustomerGroupId } = await access(
googleAuthId, googleAuthId,
categoryId, 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import queryCategoryArticles from './queryCategoryArticles.js'; import queryCategoryArticles from './queryCategoryArticles.js';
import getCategoryPosterminalIds from '../posterminal/getCategoryPosterminalIds.js'; import getCategoryPosterminalIds from '../posterminal/getCategoryPosterminalIds.js';
import makeRunner from '../runner.js';
export default function getTreeForCompany(pool) { export default function getTreeForCompany(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const articles = queryCategoryArticles(pool); const articles = queryCategoryArticles(provider);
const posterminalIds = getCategoryPosterminalIds(pool); 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 * Returns the full category tree for the user's selected company together
* with its articles and the posterminal ids assigned to each category. * with its articles and the posterminal ids assigned to each category.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>} The category tree, articles and language. * @returns {Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>} The category tree, articles and language.
* *
* @example * @example
* const tree = await store.category.getTreeForCompany(42); * const tree = await store.category.getTreeForCompany(42);
* // => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] } * // => { languageId, tree: [{ id, posterminalIds, ... }], articles: [...] }
*/ */
return async (googleAuthId) => { return async (googleAuthId, tx) => {
const { companyId, rootCategoryId, languageId, posCustomerGroupId } = const { companyId, rootCategoryId, languageId, posCustomerGroupId } =
await access(googleAuthId); await access(googleAuthId, tx);
const runner = makeRunner(provider, tx);
const [treeResult, articleRows, ptMap] = await Promise.all([ const [treeResult, articleRows, ptMap] = await Promise.all([
pool runner.query(sql, {
.request() rootId: rootCategoryId,
.input('rootId', rootCategoryId) languageId,
.input('languageId', languageId) }),
.query(GET_TREE_FOR_COMPANY), articles({ categoryId: rootCategoryId, languageId, posCustomerGroupId }, tx),
articles({ categoryId: rootCategoryId, languageId, posCustomerGroupId }), posterminalIds(companyId, tx),
posterminalIds(companyId),
]); ]);
const tree = treeResult.recordset.map((row) => ({ const tree = treeResult.rows.map((row) => ({
...row, ...row,
posterminalIds: ptMap[row.id] ?? [], posterminalIds: ptMap[row.id] ?? [],
})); }));

View File

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

View File

@@ -1,12 +1,21 @@
import { import {
LIST_CHILD_IDS, LIST_CHILD_IDS as MSSQL_LIST_CHILD_IDS,
UPDATE_SORT, UPDATE_SORT as MSSQL_UPDATE_SORT,
} from '../../providers/mssql/queries/category.js'; } 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 resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function order(pool) { export default function order(provider) {
const access = resolveCategoryAccess(pool); 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. * 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.googleAuthId - The id of the Google auth user.
* @param {number} input.parentId - The id of the parent category. * @param {number} input.parentId - The id of the parent category.
* @param {Array<number>} input.categoryIds - The child category ids in their new order. * @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. * @returns {Promise<{categoryIds: Array<number>}>} The applied order.
* @throws {Error} If the order is invalid. * @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] }); * const result = await store.category.order({ googleAuthId: 42, parentId: 5, categoryIds: [3, 1, 2] });
* // => { categoryIds: [3, 1, 2] } * // => { categoryIds: [3, 1, 2] }
*/ */
return async ({ googleAuthId, parentId, categoryIds }) => { return async ({ googleAuthId, parentId, categoryIds }, tx) => {
if (!Array.isArray(categoryIds)) { if (!Array.isArray(categoryIds)) {
throw new Error('Invalid category order'); throw new Error('Invalid category order');
} }
// Auth + ensures parentId is a real category of the user's company. const runOrder = async (runner) => {
await access(googleAuthId, parentId); // Auth + ensures parentId is a real category of the user's company.
await access(googleAuthId, parentId, runner);
const children = await pool const children = await runner.query(listChildIdsSql, { parentId });
.request()
.input('parentId', parentId)
.query(LIST_CHILD_IDS);
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) { 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)) {
throw new Error('Invalid category order'); throw new Error('Invalid category order');
} }
seen.add(categoryId);
}
const tx = pool.transaction(); const seen = new Set();
await tx.begin(); for (const categoryId of categoryIds) {
if (!Number.isFinite(categoryId) || !existing.has(categoryId) || seen.has(categoryId)) {
try { throw new Error('Invalid category order');
for (let sort = 0; sort < categoryIds.length; sort++) { }
await request(pool, tx) seen.add(categoryId);
.input('id', categoryIds[sort])
.input('parentId', parentId)
.input('sort', sort)
.query(UPDATE_SORT);
} }
await tx.commit(); for (let sort = 0; sort < categoryIds.length; sort++) {
await runner.query(updateSortSql, {
id: categoryIds[sort],
parentId,
sort,
});
}
return { categoryIds }; return { categoryIds };
} catch (err) { };
try {
await tx.rollback(); if (tx) {
} catch { return runOrder(makeRunner(provider, tx));
// ignore
}
throw err;
} }
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) { export default function queryCategoryArticles(provider) {
return async ({ categoryId, languageId, posCustomerGroupId }) => { const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
const result = await pool
.request()
.input('categoryId', categoryId)
.input('languageId', languageId)
.input('posCustomerGroupId', posCustomerGroupId)
.query(GET_CATEGORY_ARTICLES);
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 resolveCategoryAccess from './resolveCategoryAccess.js';
import slugify from '../slugify.js'; import slugify from '../slugify.js';
import makeRunner from '../runner.js';
export default function rename(pool) { export default function rename(provider) {
const access = resolveCategoryAccess(pool); const access = resolveCategoryAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE : MSSQL_UPDATE;
/** /**
* Renames a category and updates its slug. * 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.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to rename. * @param {number} input.id - The id of the category to rename.
* @param {string} input.name - The new category name. * @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. * @returns {Promise<{id: number, name: string, slug: string}>} The renamed category.
* @throws {Error} If the name is empty or the category is not found. * @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' }); * const result = await store.category.rename({ googleAuthId: 42, id: 5, name: 'Soft Drinks' });
* // => { id, name, slug } * // => { id, name, slug }
*/ */
return async ({ googleAuthId, id, name }) => { return async ({ googleAuthId, id, name }, tx) => {
if (!name?.trim()) { if (!name?.trim()) {
throw new Error('Name required'); throw new Error('Name required');
} }
const cleaned = name.trim(); const cleaned = name.trim();
const { languageId } = await access(googleAuthId, id); const { languageId } = await access(googleAuthId, id, tx);
const slug = slugify(cleaned); const slug = slugify(cleaned);
const result = await pool const result = await makeRunner(provider, tx).query(sql, {
.request() categoryId: id,
.input('categoryId', id) languageId,
.input('languageId', languageId) name: cleaned,
.input('name', cleaned) slug,
.input('slug', slug) });
.query(UPDATE_BY_CATEGORY_AND_LANGUAGE);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Category not found'); 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) { export default function resolveCategoryAccess(provider) {
return async (googleAuthId, categoryId) => { const sql =
const result = await pool provider?.dialect === 'sqlite3' ? SQLITE_RESOLVE : MSSQL_RESOLVE;
.request()
.input('categoryId', categoryId)
.input('googleAuthId', googleAuthId)
.query(RESOLVE_CATEGORY_ACCESS);
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) { if (!row) {
throw new Error('Parent not found'); throw new Error('Parent not found');
} }

View File

@@ -1,20 +1,34 @@
import { POS_GROUP_NAME } from '../../providers/mssql/queries/entity.js'; import { POS_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import { CREATE } from '../../providers/mssql/queries/company.js'; import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/company.js';
import { CREATE as CREATE_CATEGORY } from '../../providers/mssql/queries/category.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 { import {
CREATE_WITHOUT_COMPANY as CREATE_CUSTOMERGROUP, CREATE_WITHOUT_COMPANY as MSSQL_CREATE_CUSTOMERGROUP,
SET_COMPANY as SET_CUSTOMERGROUP_COMPANY, SET_COMPANY as MSSQL_SET_CUSTOMERGROUP_COMPANY,
} from '../../providers/mssql/queries/customergroup.js'; } 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 getByName from './getByName.js';
import langEnsure from '../lang/ensure.js'; import langEnsure from '../lang/ensure.js';
import taxEnsure from '../tax/ensure.js'; import taxEnsure from '../tax/ensure.js';
import request from '../request.js'; import makeRunner from '../runner.js';
export default function create(pool) { export default function create(provider) {
const nameTaken = getByName(pool); const nameTaken = getByName(provider);
const ensureLang = langEnsure(pool); const ensureLang = langEnsure(provider);
const ensureTax = taxEnsure(pool); 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, * 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.taxrate - The standard tax rate in percent.
* @param {number} input.reducedtaxrate - The reduced 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 {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. * @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. * @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 } * // => { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId, posCustomerGroupId }
*/ */
return async ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }) => { return async ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }, tx) => {
const tx = pool.transaction(); const runCreate = async (runner) => {
await tx.begin(); const existing = await nameTaken(name, runner);
try {
const existing = await nameTaken(name, tx);
if (existing) { if (existing) {
throw new Error('Company name not available'); throw new Error('Company name not available');
} }
const languageId = await ensureLang(iso639, tx); const languageId = await ensureLang(iso639, runner);
const taxrateId = await ensureTax(taxrate, tx); const taxrateId = await ensureTax(taxrate, runner);
const reducedtaxrateId = await ensureTax(reducedtaxrate, tx); const reducedtaxrateId = await ensureTax(reducedtaxrate, runner);
const taxfreerateId = await ensureTax(0, tx); const taxfreerateId = await ensureTax(0, runner);
const category = await request(pool, tx) const category = await runner.query(createCategorySql, {
.input('parentId', null) parentId: null,
.input('sort', 0) sort: 0,
.query(CREATE_CATEGORY); });
const rootCategoryId = category.rows[0]?.id ?? category.insertId;
const rootCategoryId = category.recordset[0].id;
// Circular FK: create POS group first (company_id null), company with // Circular FK: create POS group first (company_id null), company with
// poscustomergroup_id NOT NULL, then link the group back to the company. // poscustomergroup_id NOT NULL, then link the group back to the company.
const posGroup = await request(pool, tx) const posGroup = await runner.query(createCustGroupSql, {
.input('name', POS_GROUP_NAME) name: POS_GROUP_NAME,
.input('sort', 0) sort: 0,
.query(CREATE_CUSTOMERGROUP); });
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) await runner.query(setCustGroupCompanySql, {
.input('name', name) id: posCustomerGroupId,
.input('rootCategoryId', rootCategoryId) companyId,
.input('languageId', languageId) });
.input('taxrate', taxrateId)
.input('reducedtaxrate', reducedtaxrateId)
.input('taxfreerate', taxfreerateId)
.input('posCustomerGroupId', posCustomerGroupId)
.query(CREATE);
const companyId = result.recordset[0].id; await runner.query(addCompanyUserSql, {
companyId,
await request(pool, tx) googleAuthId,
.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();
return { return {
id: companyId, id: companyId,
@@ -103,13 +110,14 @@ export default function create(pool) {
taxfreerateId, taxfreerateId,
posCustomerGroupId, posCustomerGroupId,
}; };
} catch (err) { };
try {
await tx.rollback(); if (tx) {
} catch { return runCreate(makeRunner(provider, tx));
// ignore
}
throw err;
} }
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. * Deletes a company by its id.
* *
* @param {number} companyId - The id of the company to delete. * @param {number} companyId - The id of the company to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* *
* @example * @example
* await store.company.delete(1); * await store.company.delete(1);
*/ */
return async (companyId) => { return async (companyId, tx) => {
await pool await makeRunner(provider, tx).query(sql, { companyId });
.request()
.input('companyId', companyId)
.query(DELETE);
}; };
} }

View File

@@ -1,7 +1,11 @@
import { GET_BY_ID } from '../../providers/mssql/queries/company.js'; import { GET_BY_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/company.js';
import request from '../request.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. * Returns a single company by its id.
* *
@@ -14,10 +18,8 @@ export default function getById(pool) {
* // => { id, name, ... } * // => { id, name, ... }
*/ */
return async (id, tx) => { return async (id, tx) => {
const result = await request(pool, tx) const result = await makeRunner(provider, tx).query(sql, { id });
.input('id', id)
.query(GET_BY_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 { GET_BY_NAME as MSSQL_GET_BY_NAME } from '../../providers/mssql/queries/company.js';
import request from '../request.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. * Returns a single company by its name.
* *
@@ -14,10 +18,8 @@ export default function getByName(pool) {
* // => { id, name, ... } * // => { id, name, ... }
*/ */
return async (name, tx) => { return async (name, tx) => {
const result = await request(pool, tx) const result = await makeRunner(provider, tx).query(sql, { name });
.input('name', name)
.query(GET_BY_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. * Lists the companies accessible to the given Google auth user.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<object>>} The accessible company rows. * @returns {Promise<Array<object>>} The accessible company rows.
* *
* @example * @example
* const companies = await store.company.list(42); * const companies = await store.company.list(42);
* // => [{ id, name, ... }] * // => [{ id, name, ... }]
*/ */
return async (googleAuthId) => { return async (googleAuthId, tx) => {
const result = await pool const result = await makeRunner(provider, tx).query(sql, { googleAuthId });
.request() return result.rows;
.input('googleAuthId', googleAuthId)
.query(LIST);
return result.recordset;
}; };
} }

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 getById from './getById.js';
import getByName from './getByName.js'; import getByName from './getByName.js';
import langEnsure from '../lang/ensure.js'; import langEnsure from '../lang/ensure.js';
import taxEnsure from '../tax/ensure.js'; import taxEnsure from '../tax/ensure.js';
import request from '../request.js'; import makeRunner from '../runner.js';
export default function update(pool) { export default function update(provider) {
const byId = getById(pool); const byId = getById(provider);
const byName = getByName(pool); const byName = getByName(provider);
const ensureLang = langEnsure(pool); const ensureLang = langEnsure(provider);
const ensureTax = taxEnsure(pool); 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. * 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 {string} input.name - The new company name (must be unique).
* @param {number} input.taxrate - The standard tax rate in percent. * @param {number} input.taxrate - The standard tax rate in percent.
* @param {number} input.reducedtaxrate - The reduced 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. * @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. * @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 }); * const result = await store.company.update(1, { iso639: 'de', name: 'Acme', taxrate: 19, reducedtaxrate: 7 });
* // => { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId } * // => { id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId }
*/ */
return async (id, { iso639, name, taxrate, reducedtaxrate }) => { return async (id, { iso639, name, taxrate, reducedtaxrate }, tx) => {
const tx = pool.transaction(); const runUpdate = async (runner) => {
await tx.begin(); const company = await byId(id, runner);
try {
const company = await byId(id, tx);
if (!company) { if (!company) {
throw new Error('Company not found'); throw new Error('Company not found');
} }
if (name !== company.name) { if (name !== company.name) {
const taken = await byName(name, tx); const taken = await byName(name, runner);
if (taken) { if (taken) {
throw new Error('Company name not available'); throw new Error('Company name not available');
} }
} }
const languageId = await ensureLang(iso639, tx); const languageId = await ensureLang(iso639, runner);
const taxrateId = await ensureTax(taxrate, tx); const taxrateId = await ensureTax(taxrate, runner);
const reducedtaxrateId = await ensureTax(reducedtaxrate, tx); const reducedtaxrateId = await ensureTax(reducedtaxrate, runner);
await request(pool, tx) await runner.query(updateSql, {
.input('id', id) id,
.input('name', name) name,
.input('languageId', languageId) languageId,
.input('taxrate', taxrateId) taxrate: taxrateId,
.input('reducedtaxrate', reducedtaxrateId) reducedtaxrate: reducedtaxrateId,
.query(UPDATE); });
await tx.commit();
return { return {
id, id,
@@ -66,13 +65,14 @@ export default function update(pool) {
reducedtaxrateId, reducedtaxrateId,
taxfreerateId: company.taxfreerate, taxfreerateId: company.taxfreerate,
}; };
} catch (err) { };
try {
await tx.rollback(); if (tx) {
} catch { return runUpdate(makeRunner(provider, tx));
// ignore
}
throw err;
} }
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js'; import shapeSetting from './shapeSetting.js';
import makeRunner from '../runner.js';
export default function get(pool) { export default function get(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return the setting if it is frontend-visible. * @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 {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. * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>} The setting, or `undefined` if not found.
* *
* @example * @example
@@ -23,20 +30,20 @@ export default function get(pool) {
googleAuthId, googleAuthId,
key, key,
{ frontendOnly = false, maskWriteonly } = {}, { frontendOnly = false, maskWriteonly } = {},
tx,
) => { ) => {
if (!key?.trim()) { if (!key?.trim()) {
return undefined; return undefined;
} }
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
const result = await pool const result = await makeRunner(provider, tx).query(sql, {
.request() companyId,
.input('companyId', companyId) key: key.trim(),
.input('key', key.trim()) });
.query(GET_BY_COMPANY_AND_KEY);
const row = result.recordset[0]; const row = result.rows[0];
if (!row) { if (!row) {
return undefined; return undefined;
} }

View File

@@ -1,12 +1,22 @@
import { import {
LIST_BY_COMPANY, LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
LIST_BY_COMPANY_FRONTEND, LIST_BY_COMPANY_FRONTEND as MSSQL_LIST_BY_COMPANY_FRONTEND,
} from '../../providers/mssql/queries/companysetting.js'; } 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js'; import shapeSetting from './shapeSetting.js';
import makeRunner from '../runner.js';
export default function list(pool) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {object} [options] - Optional options.
* @param {boolean} [options.frontendOnly] - Only return frontend-visible settings. * @param {boolean} [options.frontendOnly] - Only return frontend-visible settings.
* @param {boolean} [options.maskWriteonly] - Whether to mask writeonly settings with '***'. Defaults to `true`. * @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. * @returns {Promise<Array<{key: string, value: string, frontend: boolean, writeonly: boolean}>>} The settings.
* *
* @example * @example
@@ -27,15 +38,16 @@ export default function list(pool) {
return async ( return async (
googleAuthId, googleAuthId,
{ frontendOnly = false, maskWriteonly = true } = {}, { frontendOnly = false, maskWriteonly = true } = {},
tx,
) => { ) => {
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
const result = await pool const result = await makeRunner(provider, tx).query(
.request() frontendOnly ? listFrontendSql : listSql,
.input('companyId', companyId) { companyId },
.query(frontendOnly ? LIST_BY_COMPANY_FRONTEND : LIST_BY_COMPANY); );
return result.recordset.map((row) => return result.rows.map((row) =>
shapeSetting(row, { exposeValue: !maskWriteonly }), 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shapeSetting from './shapeSetting.js'; import shapeSetting from './shapeSetting.js';
import makeRunner from '../runner.js';
const ALLOWED_KEYS = new Set([ const ALLOWED_KEYS = new Set([
'ui.theme', 'ui.theme',
@@ -10,32 +18,29 @@ const ALLOWED_KEYS = new Set([
'labelprinter.type', '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([ const WRITEONLY_KEYS = new Set([
'keys.tavily', 'keys.tavily',
'keys.openrouter', 'keys.openrouter',
]); ]);
export default function set(pool) { export default function set(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The setting input. * @param {object} input - The setting input.
* @param {string} input.key - The setting key (must be in the allow-list). * @param {string} input.key - The setting key (must be in the allow-list).
* @param {string} input.value - The setting value. * @param {string} input.value - The setting value.
* @param {boolean} [input.frontend] - Whether the setting is frontend-visible. * @param {boolean} [input.frontend] - Whether the setting is frontend-visible.
* @param {boolean} [input.writeonly] - Whether the setting is write-only. Defaults to true for `keys.tavily` and `keys.openrouter`, false otherwise. * @param {boolean} [input.writeonly] - Whether the setting is write-only. Defaults to true for `keys.tavily` 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. * @returns {Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>} The upserted setting.
* @throws {Error} If the input is invalid. * @throws {Error} If the input is invalid.
* *
@@ -43,7 +48,7 @@ export default function set(pool) {
* const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' }); * const setting = await store.companysetting.set(42, { key: 'ui.theme', value: 'dark' });
* // => { key, value, frontend, writeonly } * // => { key, value, frontend, writeonly }
*/ */
return async (googleAuthId, input = {}) => { return async (googleAuthId, input = {}, tx) => {
const key = input.key?.trim(); const key = input.key?.trim();
if (!ALLOWED_KEYS.has(key)) { if (!ALLOWED_KEYS.has(key)) {
throw new Error('Invalid setting'); throw new Error('Invalid setting');
@@ -52,29 +57,25 @@ export default function set(pool) {
throw new Error('Invalid setting'); throw new Error('Invalid setting');
} }
const runner = makeRunner(provider, tx);
const frontend = Boolean(input.frontend); const frontend = Boolean(input.frontend);
const writeonly = const writeonly =
input.writeonly !== undefined input.writeonly !== undefined
? Boolean(input.writeonly) ? Boolean(input.writeonly)
: WRITEONLY_KEYS.has(key); : WRITEONLY_KEYS.has(key);
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
await pool await runner.query(upsertSql, {
.request() companyId,
.input('companyId', companyId) key,
.input('key', key) value: input.value,
.input('value', input.value) frontend: isSqlite ? (frontend ? 1 : 0) : frontend,
.input('frontend', frontend) writeonly: isSqlite ? (writeonly ? 1 : 0) : writeonly,
.input('writeonly', writeonly) });
.query(UPSERT);
const result = await pool const result = await runner.query(getSql, { companyId, key });
.request()
.input('companyId', companyId)
.input('key', key)
.query(GET_BY_COMPANY_AND_KEY);
// Return the masked value to the GUI; the DB holds the real value. // 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. * Adds a company user linking a company to a Google auth account.
* *
* @param {number} companyId - The id of the company. * @param {number} companyId - The id of the company.
* @param {number} googleAuthId - The id of the Google auth account. * @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. * @returns {Promise<number>} The id of the created company user link.
* *
* @example * @example
* const id = await store.companyuser.add(1, 42); * const id = await store.companyuser.add(1, 42);
* // => 7 * // => 7
*/ */
return async (companyId, googleAuthId) => { return async (companyId, googleAuthId, tx) => {
const result = await pool const result = await makeRunner(provider, tx).query(sql, {
.request() companyId,
.input('companyId', companyId) googleAuthId,
.input('googleAuthId', googleAuthId) });
.query(ADD);
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. * Deletes a company user link by its id.
* *
* @param {number} id - The id of the company user link to delete. * @param {number} id - The id of the company user link to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* *
* @example * @example
* await store.companyuser.delete(7); * await store.companyuser.delete(7);
*/ */
return async (id) => { return async (id, tx) => {
await pool await makeRunner(provider, tx).query(sql, { id });
.request()
.input('id', id)
.query(DELETE);
}; };
} }

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. * Returns the company user ids associated with the given email address.
* *
* @param {string} email - The email address. * @param {string} email - The email address.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<number>>} The company user ids. * @returns {Promise<Array<number>>} The company user ids.
* *
* @example * @example
* const ids = await store.companyuser.getByEmail('user@example.com'); * const ids = await store.companyuser.getByEmail('user@example.com');
* // => [1, 2, 3] * // => [1, 2, 3]
*/ */
return async (email) => { return async (email, tx) => {
const result = await pool const result = await makeRunner(provider, tx).query(sql, { email });
.request()
.input('email', email)
.query(GET_BY_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) { export default function resolveCompanyAccess(provider) {
return async (googleAuthId, companyId) => { const sql =
const result = await pool provider?.dialect === 'sqlite3'
.request() ? SQLITE_GET_COMPANY_ACCESS
.input('googleAuthId', googleAuthId) : MSSQL_GET_COMPANY_ACCESS;
.input('companyId', companyId)
.query(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) { if (!row) {
throw new Error('Company not found'); 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'; import resolveCompanyAccess from './resolveCompanyAccess.js';
export default function resolveSelectedCompanyAccess(pool) { export default function resolveSelectedCompanyAccess(provider) {
const access = resolveCompanyAccess(pool); 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. * Resolves the user's selected company and checks company membership.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{companyId: number, rootCategoryId: number, languageId: number, posCustomerGroupId: number|null, companyuserId: number}>} The resolved company access context. * @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. * @throws {Error} If the user is not authorized or has no company selected.
*/ */
return async (googleAuthId) => { return async (googleAuthId, tx) => {
if (!Number.isFinite(googleAuthId)) { if (!Number.isFinite(googleAuthId)) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
const result = await pool const result = await makeRunner(provider, tx).query(sql, { googleAuthId });
.request()
.input('googleAuthId', googleAuthId)
.query(GET_BY_ID);
const row = result.recordset[0]; const row = result.rows[0];
if (!row) { if (!row) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
@@ -31,7 +33,7 @@ export default function resolveSelectedCompanyAccess(pool) {
throw new Error('No company selected'); throw new Error('No company selected');
} }
const ctx = await access(googleAuthId, companyId); const ctx = await access(googleAuthId, companyId, tx);
return { companyId, ...ctx }; 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 { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
const RESERVED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]); 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 }; return { id: row.id, name: row.name, sort: row.sort };
} }
export default function create(pool) { export default function create(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The customer group input. * @param {object} input - The customer group input.
* @param {string} input.name - The customer group name (must not be a reserved name). * @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. * @returns {Promise<{id: number, name: string, sort: number}>} The created customer group.
* @throws {Error} If the name is empty or reserved. * @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' }); * const group = await store.customergroup.create(42, { name: 'Wholesale' });
* // => { id, name, sort } * // => { id, name, sort }
*/ */
return async (googleAuthId, { name } = {}) => { return async (googleAuthId, { name } = {}, tx) => {
const cleaned = name?.trim(); const cleaned = name?.trim();
if (!cleaned) { if (!cleaned) {
throw new Error('Name required'); throw new Error('Name required');
@@ -33,21 +45,19 @@ export default function create(pool) {
throw new Error('Name not allowed'); 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 const next = await runner.query(getSortSql, { companyId });
.request() const sort = next.rows[0].sort;
.input('companyId', companyId)
.query(GET_NEXT_SORT);
const sort = next.recordset[0].sort;
const result = await pool const result = await runner.query(createSql, {
.request() companyId,
.input('companyId', companyId) name: cleaned,
.input('name', cleaned) sort,
.input('sort', sort) });
.query(CREATE);
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the customer group to delete. * @param {number} id - The id of the customer group to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the customer group is not found. * @throws {Error} If the customer group is not found.
* *
* @example * @example
* await store.customergroup.delete(42, 5); * await store.customergroup.delete(42, 5);
*/ */
return async (googleAuthId, id) => { return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Customer group not found'); throw new Error('Customer group not found');
} }
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
try { try {
const result = await pool const result = await makeRunner(provider, tx).query(sql, {
.request() id,
.input('id', id) companyId,
.input('companyId', companyId) });
.query(DELETE_BY_COMPANY_AND_ID);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Customer group not found'); throw new Error('Customer group not found');
} }
} catch (err) { } catch (err) {
if (err.number === 547) { if (isForeignKeyConstraintError(err)) {
throw new Error('Customer group in use'); throw new Error('Customer group in use');
} }
throw err; 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function list(pool) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * Lists the customer groups of the user's selected company.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, name: string, sort: number}>>} The customer groups. * @returns {Promise<Array<{id: number, name: string, sort: number}>>} The customer groups.
* *
* @example * @example
* const groups = await store.customergroup.list(42); * const groups = await store.customergroup.list(42);
* // => [{ id, name, sort }] * // => [{ id, name, sort }]
*/ */
return async (googleAuthId) => { return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
const result = await pool const result = await makeRunner(provider, tx).query(sql, { companyId });
.request()
.input('companyId', companyId)
.query(LIST_BY_COMPANY);
return result.recordset.map((row) => ({ return result.rows.map((row) => ({
id: row.id, id: row.id,
name: row.name, name: row.name,
sort: row.sort, sort: row.sort,

View File

@@ -1,12 +1,21 @@
import { import {
LIST_IDS_BY_COMPANY, LIST_IDS_BY_COMPANY as MSSQL_LIST_IDS_BY_COMPANY,
UPDATE_SORT, UPDATE_SORT as MSSQL_UPDATE_SORT,
} from '../../providers/mssql/queries/customergroup.js'; } 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function order(pool) { export default function order(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The reorder input. * @param {object} input - The reorder input.
* @param {Array<number>} input.customergroupIds - The customer group ids in their new order. * @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. * @returns {Promise<{customergroupIds: Array<number>}>} The applied order.
* @throws {Error} If the order is invalid. * @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] }); * const result = await store.customergroup.order(42, { customergroupIds: [3, 1, 2] });
* // => { customergroupIds: [3, 1, 2] } * // => { customergroupIds: [3, 1, 2] }
*/ */
return async (googleAuthId, { customergroupIds } = {}) => { return async (googleAuthId, { customergroupIds } = {}, tx) => {
if (!Array.isArray(customergroupIds)) { if (!Array.isArray(customergroupIds)) {
throw new Error('Invalid customer group order'); 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 const groups = await runner.query(listIdsSql, { companyId });
.request()
.input('companyId', companyId)
.query(LIST_IDS_BY_COMPANY);
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) { 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)
) {
throw new Error('Invalid customer group order'); throw new Error('Invalid customer group order');
} }
seen.add(customergroupId);
}
const tx = pool.transaction(); const seen = new Set();
await tx.begin(); for (const customergroupId of customergroupIds) {
if (
try { !Number.isFinite(customergroupId) ||
for (let sort = 0; sort < customergroupIds.length; sort++) { !existing.has(customergroupId) ||
await request(pool, tx) seen.has(customergroupId)
.input('id', customergroupIds[sort]) ) {
.input('companyId', companyId) throw new Error('Invalid customer group order');
.input('sort', sort) }
.query(UPDATE_SORT); 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 }; return { customergroupIds };
} catch (err) { };
try {
await tx.rollback(); if (tx) {
} catch { return runOrder(makeRunner(provider, tx));
// ignore
}
throw err;
} }
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 { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID } from '../../providers/mssql/queries/customergroup.js';
import request from '../request.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. */ /** 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) => { return async (companyId, customergroupId, tx) => {
if (!Number.isFinite(customergroupId)) { if (!Number.isFinite(customergroupId)) {
throw new Error('Customer group not found'); throw new Error('Customer group not found');
} }
const result = await request(pool, tx) const result = await makeRunner(provider, tx).query(sql, {
.input('id', customergroupId) id: customergroupId,
.input('companyId', companyId) companyId,
.query(GET_BY_COMPANY_AND_ID); });
const row = result.recordset[0]; const row = result.rows[0];
if (!row) { if (!row) {
throw new Error('Customer group not found'); 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 { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../providers/mssql/queries/entity.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
const LOCKED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]); 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 }; return { id: row.id, name: row.name, sort: row.sort };
} }
export default function update(pool) { export default function update(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {number} id - The id of the customer group to update.
* @param {object} input - The update input. * @param {object} input - The update input.
* @param {string} input.name - The new customer group name. * @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. * @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. * @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' }); * const group = await store.customergroup.update(42, 5, { name: 'Retail' });
* // => { id, name, sort } * // => { id, name, sort }
*/ */
return async (googleAuthId, id, { name } = {}) => { return async (googleAuthId, id, { name } = {}, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Customer group not found'); throw new Error('Customer group not found');
} }
@@ -35,15 +49,12 @@ export default function update(pool) {
throw new Error('Name required'); 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 const existing = await runner.query(getSql, { id, companyId });
.request()
.input('id', id)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const current = existing.recordset[0]; const current = existing.rows[0];
if (!current) { if (!current) {
throw new Error('Customer group not found'); throw new Error('Customer group not found');
} }
@@ -52,17 +63,13 @@ export default function update(pool) {
throw new Error('Name cannot be changed'); throw new Error('Name cannot be changed');
} }
const result = await pool const result = await runner.query(updateSql, {
.request() id,
.input('id', id) companyId,
.input('companyId', companyId) name: cleaned,
.input('name', cleaned) });
.query(UPDATE);
const row = result.recordset[0]; const row = result.rows[0] ?? { id, name: cleaned, sort: current.sort };
if (!row) {
throw new Error('Customer group not found');
}
return shape(row); return shape(row);
}; };
} }

View File

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

View File

@@ -1,11 +1,5 @@
import { import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
CREATE_MANIFEST, import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
CREATE_FILE,
CREATE_ATTACHMENT,
GET_NEXT_ATTACHMENT_SORT,
DELIVERY_TYPE,
} from '../../providers/mssql/queries/delivery.js';
import request from '../request.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import nextNumber from '../numbers/nextNumber.js'; import nextNumber from '../numbers/nextNumber.js';
import taxEnsure from '../tax/ensure.js'; import taxEnsure from '../tax/ensure.js';
@@ -13,30 +7,15 @@ import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { loadDelivery } from './helpers.js'; import { loadDelivery } from './helpers.js';
import { insertAttachments } from './attachments.js'; import { insertAttachments } from './attachments.js';
import { assertSupplier } from './writeLines.js'; import { assertSupplier } from './writeLines.js';
import makeRunner from '../runner.js';
export default function create(pool) { export default function create(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const ensureTax = taxEnsure(pool); const ensureTax = taxEnsure(provider);
const allocateNumber = nextNumber(pool); 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 = {}) => { return async (googleAuthId, body = {}) => {
const date = parseDate(body.date); const date = parseDate(body.date);
if (!date) { if (!date) {
@@ -50,58 +29,51 @@ export default function create(pool) {
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId);
const tx = pool.transaction(); const run = async (runner, tx) => {
await tx.begin();
try {
const supplierId = await assertSupplier( const supplierId = await assertSupplier(
pool, provider,
tx, tx,
companyId, companyId,
body.supplierId ?? null, 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) const created = await runner(queries.CREATE_MANIFEST, {
.input('companyId', companyId) companyId,
.input('number', number) number,
.input('type', DELIVERY_TYPE) type: queries.DELIVERY_TYPE,
.input('entityId', supplierId) entityId: supplierId,
.input('externalNumber', externalNumber) externalNumber,
.input('date', date) date,
.query(CREATE_MANIFEST); });
const manifestId = created.recordset[0].id; const manifestId = created.rows[0]?.id ?? created.insertId;
await insertLines(pool, tx, { await insertLines(provider, tx, {
manifestId, manifestId,
lines, lines,
ensureTax, ensureTax,
}); });
await insertAttachments( await insertAttachments(
pool, provider,
tx, tx,
{ {
GET_NEXT_ATTACHMENT_SORT, GET_NEXT_ATTACHMENT_SORT: queries.GET_NEXT_ATTACHMENT_SORT,
CREATE_FILE, CREATE_FILE: queries.CREATE_FILE,
CREATE_ATTACHMENT, CREATE_ATTACHMENT: queries.CREATE_ATTACHMENT,
}, },
manifestId, manifestId,
body.files, body.files,
); );
const delivery = await loadDelivery(pool, tx, companyId, manifestId); return loadDelivery(provider, tx, companyId, manifestId);
await tx.commit(); };
return delivery;
} catch (err) { if (typeof provider?.transaction === 'function') {
try { return provider.transaction((runner, tx) => run(runner, tx));
await tx.rollback();
} catch {
// ignore
}
throw err;
} }
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const access = resolveSelectedCompanyAccess(pool); 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) => { return async (googleAuthId, id) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Delivery not found'); throw new Error('Delivery not found');
} }
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider);
const result = await pool const result = await runner(queries.DELETE_MANIFEST, {
.request() id,
.input('id', id) companyId,
.input('companyId', companyId) type: queries.DELIVERY_TYPE,
.input('type', DELIVERY_TYPE) });
.query(DELETE_MANIFEST);
if (!result.rowsAffected[0]) { const rowsAffected = result.rowsAffected?.[0] ?? (typeof result.rowsAffected === 'number' ? result.rowsAffected : 0);
if (!rowsAffected) {
throw new Error('Delivery not found'); throw new Error('Delivery not found');
} }
}; };

View File

@@ -1,21 +1,9 @@
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { loadDelivery } from './helpers.js'; import { loadDelivery } from './helpers.js';
export default function getById(pool) { export default function getById(provider) {
const access = resolveSelectedCompanyAccess(pool); 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) => { return async (googleAuthId, id) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Delivery not found'); throw new Error('Delivery not found');
@@ -23,7 +11,7 @@ export default function getById(pool) {
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId);
const delivery = await loadDelivery(pool, null, companyId, id); const delivery = await loadDelivery(provider, null, companyId, id);
if (!delivery) { if (!delivery) {
throw new Error('Delivery not found'); throw new Error('Delivery not found');
} }

View File

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

View File

@@ -1,45 +1,37 @@
import { DELIVERY_TYPE, LIST_DELIVERIES, LIST_DELIVERY_FILES } from '../../providers/mssql/queries/delivery.js'; import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
import request from '../request.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
import makeRunner from '../runner.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import { shapeSummary } from './helpers.js'; import { shapeSummary } from './helpers.js';
import { shapeAttachmentMeta } from './attachments.js'; import { shapeAttachmentMeta } from './attachments.js';
export default function list(pool) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(pool); 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) => { return async (googleAuthId) => {
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId);
const runner = makeRunner(provider);
const result = await pool const result = await runner(queries.LIST_DELIVERIES, {
.request() companyId,
.input('companyId', companyId) type: queries.DELIVERY_TYPE,
.input('type', DELIVERY_TYPE) });
.query(LIST_DELIVERIES);
const filesResult = await pool const filesResult = await runner(queries.LIST_DELIVERY_FILES, {
.request() companyId,
.input('companyId', companyId) type: queries.DELIVERY_TYPE,
.input('type', DELIVERY_TYPE) });
.query(LIST_DELIVERY_FILES);
const filesByManifest = new Map(); const filesByManifest = new Map();
for (const row of filesResult.recordset) { for (const row of filesResult.rows) {
const list = filesByManifest.get(row.manifestId) ?? []; const list = filesByManifest.get(row.manifestId) ?? [];
list.push(shapeAttachmentMeta(row)); list.push(shapeAttachmentMeta(row));
filesByManifest.set(row.manifestId, list); filesByManifest.set(row.manifestId, list);
} }
return result.recordset.map((row) => ({ return result.rows.map((row) => ({
...shapeSummary(row), ...shapeSummary(row),
files: filesByManifest.get(row.id) ?? [], files: filesByManifest.get(row.id) ?? [],
})); }));

View File

@@ -1,42 +1,19 @@
import { import * as mssqlQueries from '../../providers/mssql/queries/delivery.js';
DELETE_LINES_BY_MANIFEST, import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js';
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import taxEnsure from '../tax/ensure.js'; import taxEnsure from '../tax/ensure.js';
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
import { loadDelivery, shapeSummary } from './helpers.js'; import { loadDelivery, shapeSummary } from './helpers.js';
import { insertAttachments } from './attachments.js'; import { insertAttachments } from './attachments.js';
import { assertSupplier } from './writeLines.js'; import { assertSupplier } from './writeLines.js';
import makeRunner from '../runner.js';
export default function update(pool) { export default function update(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const ensureTax = taxEnsure(pool); 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 = {}) => { return async (googleAuthId, id, patch = {}) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Delivery not found'); throw new Error('Delivery not found');
@@ -63,17 +40,14 @@ export default function update(pool) {
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId);
const tx = pool.transaction(); const run = async (runner, tx) => {
await tx.begin(); const existing = await runner(queries.GET_DELIVERY_HEADER, {
id,
companyId,
type: queries.DELIVERY_TYPE,
});
try { const current = existing.rows[0];
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];
if (!current) { if (!current) {
throw new Error('Delivery not found'); throw new Error('Delivery not found');
} }
@@ -99,7 +73,7 @@ export default function update(pool) {
let supplierId = summary.supplierId; let supplierId = summary.supplierId;
if (hasSupplierId) { if (hasSupplierId) {
supplierId = await assertSupplier( supplierId = await assertSupplier(
pool, provider,
tx, tx,
companyId, companyId,
patch.supplierId ?? null, patch.supplierId ?? null,
@@ -113,22 +87,22 @@ export default function update(pool) {
: null; : null;
} }
await request(pool, tx) await runner(queries.UPDATE_MANIFEST, {
.input('id', id) id,
.input('companyId', companyId) companyId,
.input('type', DELIVERY_TYPE) type: queries.DELIVERY_TYPE,
.input('number', number) number,
.input('entityId', supplierId) entityId: supplierId,
.input('externalNumber', externalNumber) externalNumber,
.input('date', date) date,
.query(UPDATE_MANIFEST); });
if (hasLines) { if (hasLines) {
await request(pool, tx) await runner(queries.DELETE_LINES_BY_MANIFEST, {
.input('manifestId', id) manifestId: id,
.query(DELETE_LINES_BY_MANIFEST); });
await insertLines(pool, tx, { await insertLines(provider, tx, {
manifestId: id, manifestId: id,
lines: normalizedLines, lines: normalizedLines,
ensureTax, ensureTax,
@@ -137,28 +111,24 @@ export default function update(pool) {
if (hasFiles) { if (hasFiles) {
await insertAttachments( await insertAttachments(
pool, provider,
tx, tx,
{ {
GET_NEXT_ATTACHMENT_SORT, GET_NEXT_ATTACHMENT_SORT: queries.GET_NEXT_ATTACHMENT_SORT,
CREATE_FILE, CREATE_FILE: queries.CREATE_FILE,
CREATE_ATTACHMENT, CREATE_ATTACHMENT: queries.CREATE_ATTACHMENT,
}, },
id, id,
patch.files, patch.files,
); );
} }
const delivery = await loadDelivery(pool, tx, companyId, id); return loadDelivery(provider, tx, companyId, id);
await tx.commit(); };
return delivery;
} catch (err) { if (typeof provider?.transaction === 'function') {
try { return provider.transaction((runner, tx) => run(runner, tx));
await tx.rollback();
} catch {
// ignore
}
throw err;
} }
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 * as mssqlQueries from '../../providers/mssql/queries/entity.js';
import request from '../request.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) { if (supplierId == null) {
return null; return null;
} }
@@ -9,13 +10,17 @@ export async function assertSupplier(pool, tx, companyId, supplierId) {
throw new Error('Supplier not found'); throw new Error('Supplier not found');
} }
const result = await request(pool, tx) const isSqlite = provider?.dialect === 'sqlite3';
.input('id', supplierId) const queries = isSqlite ? sqliteQueries : mssqlQueries;
.input('companyId', companyId) const runner = makeRunner(provider, tx);
.input('groupName', SUPPLIER_GROUP_NAME)
.query(GET_SUPPLIER_BY_ID);
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'); throw new Error('Supplier not found');
} }
return supplierId; return supplierId;

View File

@@ -1,19 +1,26 @@
import { CREATE } from '../../providers/mssql/queries/entity.js'; import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/entity.js';
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/entity.js';
import request from '../request.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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js'; import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js';
import nextNumber from '../numbers/nextNumber.js'; import nextNumber from '../numbers/nextNumber.js';
import shape from './shape.js'; import shape from './shape.js';
import makeRunner from '../runner.js';
export default function create(pool) { export default function create(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const groupAccess = resolveCustomerGroupAccess(pool); const groupAccess = resolveCustomerGroupAccess(provider);
const allocateNumber = nextNumber(pool); 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. * 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`). * (`type` `entity`).
* *
* @param {number} googleAuthId - The id of the Google auth user. * @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.name - The entity name.
* @param {string} [input.firstName] - The entity first name. * @param {string} [input.firstName] - The entity first name.
* @param {string} [input.number] - Optional explicit customer number. * @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. * @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. * @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' }); * const entity = await store.entity.create(42, { customergroupId: 5, name: 'Doe', firstName: 'John' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt } * // => { 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(); const cleaned = name?.trim();
if (!cleaned) { if (!cleaned) {
throw new Error('Name required'); throw new Error('Name required');
@@ -45,43 +53,50 @@ export default function create(pool) {
? null ? null
: String(number).trim() || null; : String(number).trim() || null;
const { companyId, companyuserId } = await access(googleAuthId); const runCreate = async (runner) => {
await groupAccess(companyId, customergroupId); const { companyId, companyuserId } = await access(googleAuthId, runner);
await groupAccess(companyId, customergroupId, runner);
const tx = pool.transaction();
await tx.begin();
try {
const entityNumber = 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 { try {
await tx.rollback(); const created = await runner.query(createSql, {
} catch { customergroupId,
// ignore 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');
} if (tx) {
throw err; 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * Deletes an entity by its id within the user's selected company.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the entity to delete. * @param {number} id - The id of the entity to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the entity is not found. * @throws {Error} If the entity is not found.
* *
* @example * @example
* await store.entity.delete(42, 5); * await store.entity.delete(42, 5);
*/ */
return async (googleAuthId, id) => { return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Entity not found'); throw new Error('Entity not found');
} }
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
const result = await pool const result = await makeRunner(provider, tx).query(sql, {
.request() id,
.input('id', id) companyId,
.input('companyId', companyId) });
.query(DELETE_BY_COMPANY_AND_ID);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Entity not found'); 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shape from './shape.js'; import shape from './shape.js';
import makeRunner from '../runner.js';
export default function getById(pool) { export default function getById(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the entity. * @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. * @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. * @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); * const entity = await store.entity.getById(42, 5);
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt } * // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/ */
return async (googleAuthId, id) => { return async (googleAuthId, id, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Entity not found'); throw new Error('Entity not found');
} }
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
const result = await pool const result = await makeRunner(provider, tx).query(sql, {
.request() id,
.input('id', id) companyId,
.input('companyId', companyId) });
.query(GET_BY_COMPANY_AND_ID);
const row = result.recordset[0]; const row = result.rows[0];
if (!row) { if (!row) {
throw new Error('Entity not found'); 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function isNumberFree(pool) { export default function isNumberFree(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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 {string} number - The customer number to check.
* @param {object} [options] - Optional options. * @param {object} [options] - Optional options.
* @param {number} [options.excludeEntityId] - An entity id to exclude from the check. * @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. * @returns {Promise<boolean>} Whether the number is free.
* @throws {Error} If the number is empty. * @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 }); * const freeOnEdit = await store.entity.isNumberFree(42, 'C-100', { excludeEntityId: 5 });
* // => true * // => true
*/ */
return async (googleAuthId, number, { excludeEntityId } = {}) => { return async (googleAuthId, number, { excludeEntityId } = {}, tx) => {
const cleaned = const cleaned =
number == null || number === '' ? null : String(number).trim() || null; number == null || number === '' ? null : String(number).trim() || null;
if (!cleaned) { if (!cleaned) {
@@ -35,15 +42,14 @@ export default function isNumberFree(pool) {
throw new Error('Entity not found'); throw new Error('Entity not found');
} }
const { companyId } = await access(googleAuthId); const { companyId } = await access(googleAuthId, tx);
const result = await pool const result = await makeRunner(provider, tx).query(sql, {
.request() companyId,
.input('companyId', companyId) number: cleaned,
.input('number', cleaned) excludeEntityId: excludeEntityId ?? null,
.input('excludeEntityId', excludeEntityId ?? null) });
.query(NUMBER_TAKEN_IN_COMPANY);
return !result.recordset[0]; return !result.rows[0];
}; };
} }

View File

@@ -1,17 +1,34 @@
import { import {
LIST_BY_COMPANY, LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
LIST_BY_COMPANY_AND_GROUP, LIST_BY_COMPANY_AND_GROUP as MSSQL_LIST_BY_COMPANY_AND_GROUP,
LIST_CUSTOMERS_BY_COMPANY, LIST_CUSTOMERS_BY_COMPANY as MSSQL_LIST_CUSTOMERS_BY_COMPANY,
POS_GROUP_NAME, POS_GROUP_NAME,
SUPPLIER_GROUP_NAME, SUPPLIER_GROUP_NAME,
} from '../../providers/mssql/queries/entity.js'; } 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js'; import resolveCustomerGroupAccess from '../customergroup/resolveCustomerGroupAccess.js';
import shape from './shape.js'; import shape from './shape.js';
import makeRunner from '../runner.js';
export default function list(pool) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const groupAccess = resolveCustomerGroupAccess(pool); 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 * 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 {object} [options] - Optional options.
* @param {number} [options.customergroupId] - Filter by customer group id. * @param {number} [options.customergroupId] - Filter by customer group id.
* @param {string} [options.role] - Restrict to `'customer'` role if set. * @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. * @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. * @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 }); * const entities = await store.entity.list(42, { customergroupId: 5 });
* // => [{ id, customergroupId, firstName, name, number, createdAt, updatedAt }] * // => [{ id, customergroupId, firstName, name, number, createdAt, updatedAt }]
*/ */
return async (googleAuthId, { customergroupId, role } = {}) => { return async (googleAuthId, { customergroupId, role } = {}, tx) => {
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, tx);
if (role != null && role !== 'customer') { if (role != null && role !== 'customer') {
throw new Error('Invalid role'); throw new Error('Invalid role');
} }
if (role === 'customer') { if (role === 'customer') {
const result = await pool const result = await runner.query(listCustomersSql, {
.request() companyId,
.input('companyId', companyId) supplierGroupName: SUPPLIER_GROUP_NAME,
.input('supplierGroupName', SUPPLIER_GROUP_NAME) posGroupName: POS_GROUP_NAME,
.input('posGroupName', POS_GROUP_NAME) });
.query(LIST_CUSTOMERS_BY_COMPANY); return result.rows.map(shape);
return result.recordset.map(shape);
} }
if (customergroupId != null) { if (customergroupId != null) {
await groupAccess(companyId, customergroupId); await groupAccess(companyId, customergroupId, tx);
const result = await pool const result = await runner.query(listGroupSql, {
.request() companyId,
.input('companyId', companyId) customergroupId,
.input('customergroupId', customergroupId) });
.query(LIST_BY_COMPANY_AND_GROUP); return result.rows.map(shape);
return result.recordset.map(shape);
} }
const result = await pool const result = await runner.query(listAllSql, { companyId });
.request() return result.rows.map(shape);
.input('companyId', companyId)
.query(LIST_BY_COMPANY);
return result.recordset.map(shape);
}; };
} }

View File

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

View File

@@ -1,19 +1,25 @@
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 request from '../request.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. */ /** 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) => { return async (companyId, entityId, tx) => {
if (!Number.isFinite(entityId)) { if (!Number.isFinite(entityId)) {
throw new Error('Entity not found'); throw new Error('Entity not found');
} }
const result = await request(pool, tx) const result = await makeRunner(provider, tx).query(sql, {
.input('id', entityId) id: entityId,
.input('companyId', companyId) companyId,
.query(GET_BY_COMPANY_AND_ID); });
const row = result.recordset[0]; const row = result.rows[0];
if (!row) { if (!row) {
throw new Error('Entity not found'); 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 {
import { INSERT as INSERT_HISTORY } from '../../providers/mssql/queries/entityhistory.js'; GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_COMPANY_AND_ID,
import request from '../request.js'; 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import shape from './shape.js'; import shape from './shape.js';
import makeRunner from '../runner.js';
export default function update(pool) { export default function update(provider) {
const access = resolveSelectedCompanyAccess(pool); 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. * 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.name] - The new entity name.
* @param {string} [patch.firstName] - The new first name. * @param {string} [patch.firstName] - The new first name.
* @param {string} [patch.number] - The new customer number. * @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. * @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. * @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' }); * const entity = await store.entity.update(42, 5, { name: 'Doe', firstName: 'Jane', number: 'C-42' });
* // => { id, customergroupId, firstName, name, number, createdAt, updatedAt } * // => { id, customergroupId, firstName, name, number, createdAt, updatedAt }
*/ */
return async (googleAuthId, id, patch = {}) => { return async (googleAuthId, id, patch = {}, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Entity not found'); throw new Error('Entity not found');
} }
@@ -36,102 +54,101 @@ export default function update(pool) {
throw new Error('Name required'); 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 const existing = await runner.query(getSql, { id, companyId });
.request()
.input('id', id)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const current = existing.recordset[0]; const current = existing.rows[0];
if (!current) { 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) {
throw new Error('Entity not found'); throw new Error('Entity not found');
} }
await request(pool, tx) let name = current.name;
.input('entityId', id) if (hasName) {
.input('companyuserId', companyuserId) name = patch.name?.trim();
.input('text', historyLines.join('\n')) if (!name) {
.query(INSERT_HISTORY); 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 { try {
await tx.rollback(); const updated = await runner.query(updateSql, {
} catch { id,
// ignore 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');
} if (tx) {
throw err; 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
@@ -24,9 +32,12 @@ function optionalString(value) {
return s || null; return s || null;
} }
export default function create(pool) { export default function create(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); 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. * 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.city] - The city.
* @param {string} [input.county] - The county. * @param {string} [input.county] - The county.
* @param {number} [input.sort] - Optional sort order. * @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. * @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. * @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' }); * 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 } * // => { 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?.() const addressType = input.addressType?.trim?.()
? input.addressType.trim() ? input.addressType.trim()
: null; : null;
@@ -58,34 +70,45 @@ export default function create(pool) {
throw new Error('Address type required'); throw new Error('Address type required');
} }
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
let sort = input.sort; let sort = input.sort;
if (sort === undefined || sort === null) { if (sort === undefined || sort === null) {
const next = await pool const next = await runner.query(getSortSql, { entityId });
.request() sort = next.rows[0].sort;
.input('entityId', entityId)
.query(GET_NEXT_SORT);
sort = next.recordset[0].sort;
} else if (!Number.isFinite(sort)) { } else if (!Number.isFinite(sort)) {
throw new Error('Invalid sort'); throw new Error('Invalid sort');
} }
const result = await pool const result = await runner.query(createSql, {
.request() entityId,
.input('entityId', entityId) firstName: optionalString(input.firstName),
.input('firstName', optionalString(input.firstName)) name: optionalString(input.name),
.input('name', optionalString(input.name)) street: optionalString(input.street),
.input('street', optionalString(input.street)) streetNumber: optionalString(input.streetNumber),
.input('streetNumber', optionalString(input.streetNumber)) zip: optionalString(input.zip),
.input('zip', optionalString(input.zip)) city: optionalString(input.city),
.input('city', optionalString(input.city)) county: optionalString(input.county),
.input('county', optionalString(input.county)) addressType,
.input('addressType', addressType) sort,
.input('sort', sort) });
.query(CREATE);
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); const entityAccess = resolveEntityAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/** /**
* Deletes an address of an entity. * 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} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity. * @param {number} entityId - The id of the entity.
* @param {number} id - The id of the address to delete. * @param {number} id - The id of the address to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the address is not found. * @throws {Error} If the address is not found.
* *
* @example * @example
* await store.entityaddress.delete(42, 5, 9); * await store.entityaddress.delete(42, 5, 9);
*/ */
return async (googleAuthId, entityId, id) => { return async (googleAuthId, entityId, id, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Address not found'); throw new Error('Address not found');
} }
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool const result = await runner.query(sql, {
.request() id,
.input('id', id) entityId,
.input('entityId', entityId) });
.query(DELETE_BY_ENTITY_AND_ID);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Address not found'); 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
@@ -18,30 +20,33 @@ function shape(row) {
}; };
} }
export default function list(pool) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); const entityAccess = resolveEntityAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_LIST_BY_ENTITY
: MSSQL_LIST_BY_ENTITY;
/** /**
* Lists the addresses of an entity. * Lists the addresses of an entity.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity. * @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. * @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 * @example
* const addresses = await store.entityaddress.list(42, 5); * const addresses = await store.entityaddress.list(42, 5);
* // => [{ id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }] * // => [{ id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort }]
*/ */
return async (googleAuthId, entityId) => { return async (googleAuthId, entityId, tx) => {
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool const result = await runner.query(sql, { entityId });
.request()
.input('entityId', entityId)
.query(LIST_BY_ENTITY);
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
@@ -24,9 +32,14 @@ function optionalString(value) {
return s || null; return s || null;
} }
export default function update(pool) { export default function update(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); 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. * Updates an address of an entity.
@@ -44,6 +57,7 @@ export default function update(pool) {
* @param {string} [patch.city] - The city. * @param {string} [patch.city] - The city.
* @param {string} [patch.county] - The county. * @param {string} [patch.county] - The county.
* @param {number} [patch.sort] - The sort order. * @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. * @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. * @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' }); * const address = await store.entityaddress.update(42, 5, 9, { city: 'Hamburg' });
* // => { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType, sort } * // => { 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)) { if (!Number.isFinite(id)) {
throw new Error('Address not found'); throw new Error('Address not found');
} }
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const existing = await pool const existing = await runner.query(getSql, { id, entityId });
.request()
.input('id', id)
.input('entityId', entityId)
.query(GET_BY_ENTITY_AND_ID);
const current = existing.recordset[0]; const current = existing.rows[0];
if (!current) { if (!current) {
throw new Error('Address not found'); throw new Error('Address not found');
} }
@@ -113,25 +124,34 @@ export default function update(pool) {
sort = patch.sort; sort = patch.sort;
} }
const result = await pool const result = await runner.query(updateSql, {
.request() id,
.input('id', id) entityId,
.input('entityId', entityId) firstName,
.input('firstName', firstName) name,
.input('name', name) street,
.input('street', street) streetNumber,
.input('streetNumber', streetNumber) zip,
.input('zip', zip) city,
.input('city', city) county,
.input('county', county) addressType,
.input('addressType', addressType) sort,
.input('sort', sort) });
.query(UPDATE);
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); 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
@@ -12,9 +20,12 @@ function shape(row) {
}; };
} }
export default function create(pool) { export default function create(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); 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. * 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.contactType - The contact type (e.g. `'email'`).
* @param {string} input.contact - The contact value. * @param {string} input.contact - The contact value.
* @param {number} [input.sort] - Optional sort order. * @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. * @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. * @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' }); * const contact = await store.entitycontact.create(42, 5, { contactType: 'email', contact: 'a@b.c' });
* // => { id, entityId, contactType, contact, sort } * // => { id, entityId, contactType, contact, sort }
*/ */
return async (googleAuthId, entityId, input = {}) => { return async (googleAuthId, entityId, input = {}, tx) => {
const contactType = input.contactType?.trim?.() const contactType = input.contactType?.trim?.()
? input.contactType.trim() ? input.contactType.trim()
: null; : null;
@@ -41,28 +53,33 @@ export default function create(pool) {
throw new Error('Contact required'); throw new Error('Contact required');
} }
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
let sort = input.sort; let sort = input.sort;
if (sort === undefined || sort === null) { if (sort === undefined || sort === null) {
const next = await pool const next = await runner.query(getSortSql, { entityId });
.request() sort = next.rows[0].sort;
.input('entityId', entityId)
.query(GET_NEXT_SORT);
sort = next.recordset[0].sort;
} else if (!Number.isFinite(sort)) { } else if (!Number.isFinite(sort)) {
throw new Error('Invalid sort'); throw new Error('Invalid sort');
} }
const result = await pool const result = await runner.query(createSql, {
.request() entityId,
.input('entityId', entityId) contactType,
.input('contactType', contactType) contact,
.input('contact', contact) sort,
.input('sort', sort) });
.query(CREATE);
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
export default function del(pool) { export default function del(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); const entityAccess = resolveEntityAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/** /**
* Deletes a contact of an entity. * 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} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity. * @param {number} entityId - The id of the entity.
* @param {number} id - The id of the contact to delete. * @param {number} id - The id of the contact to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the contact is not found. * @throws {Error} If the contact is not found.
* *
* @example * @example
* await store.entitycontact.delete(42, 5, 9); * await store.entitycontact.delete(42, 5, 9);
*/ */
return async (googleAuthId, entityId, id) => { return async (googleAuthId, entityId, id, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Contact not found'); throw new Error('Contact not found');
} }
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool const result = await runner.query(sql, {
.request() id,
.input('id', id) entityId,
.input('entityId', entityId) });
.query(DELETE_BY_ENTITY_AND_ID);
if (!result.rowsAffected[0]) { if (!result.rowsAffected) {
throw new Error('Contact not found'); 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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
@@ -12,30 +14,33 @@ function shape(row) {
}; };
} }
export default function list(pool) { export default function list(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); const entityAccess = resolveEntityAccess(provider);
const sql =
provider?.dialect === 'sqlite3'
? SQLITE_LIST_BY_ENTITY
: MSSQL_LIST_BY_ENTITY;
/** /**
* Lists the contacts of an entity. * Lists the contacts of an entity.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} entityId - The id of the entity. * @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. * @returns {Promise<Array<{id: number, entityId: number, contactType: string, contact: string, sort: number}>>} The contacts.
* *
* @example * @example
* const contacts = await store.entitycontact.list(42, 5); * const contacts = await store.entitycontact.list(42, 5);
* // => [{ id, entityId, contactType, contact, sort }] * // => [{ id, entityId, contactType, contact, sort }]
*/ */
return async (googleAuthId, entityId) => { return async (googleAuthId, entityId, tx) => {
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const result = await pool const result = await runner.query(sql, { entityId });
.request()
.input('entityId', entityId)
.query(LIST_BY_ENTITY);
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 resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
import makeRunner from '../runner.js';
function shape(row) { function shape(row) {
return { return {
@@ -12,9 +20,14 @@ function shape(row) {
}; };
} }
export default function update(pool) { export default function update(provider) {
const access = resolveSelectedCompanyAccess(pool); const access = resolveSelectedCompanyAccess(provider);
const entityAccess = resolveEntityAccess(pool); 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. * 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} entityId - The id of the entity.
* @param {number} id - The id of the contact to update. * @param {number} id - The id of the contact to update.
* @param {object} patch - The update patch (any subset of contact fields). * @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. * @returns {Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>} The updated contact.
* @throws {Error} If the contact is not found. * @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' }); * const contact = await store.entitycontact.update(42, 5, 9, { contact: 'b@c.d' });
* // => { id, entityId, contactType, contact, sort } * // => { id, entityId, contactType, contact, sort }
*/ */
return async (googleAuthId, entityId, id, patch = {}) => { return async (googleAuthId, entityId, id, patch = {}, tx) => {
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
throw new Error('Contact not found'); throw new Error('Contact not found');
} }
const { companyId } = await access(googleAuthId); const runner = makeRunner(provider, tx);
await entityAccess(companyId, entityId); const { companyId } = await access(googleAuthId, tx);
await entityAccess(companyId, entityId, tx);
const existing = await pool const existing = await runner.query(getSql, { id, entityId });
.request()
.input('id', id)
.input('entityId', entityId)
.query(GET_BY_ENTITY_AND_ID);
const current = existing.recordset[0]; const current = existing.rows[0];
if (!current) { if (!current) {
throw new Error('Contact not found'); throw new Error('Contact not found');
} }
@@ -73,19 +84,22 @@ export default function update(pool) {
sort = patch.sort; sort = patch.sort;
} }
const result = await pool const result = await runner.query(updateSql, {
.request() id,
.input('id', id) entityId,
.input('entityId', entityId) contactType,
.input('contactType', contactType) contact,
.input('contact', contact) sort,
.input('sort', sort) });
.query(UPDATE);
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); return shape(row);
}; };
} }

View File

@@ -2,7 +2,7 @@ import { SCHEMA_SCRIPTS } from '../providers/mssql/schema.js';
export default function initialize(provider) { 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>} * @returns {Promise<void>}
* *
@@ -10,8 +10,16 @@ export default function initialize(provider) {
* await store.initialize(); * await store.initialize();
*/ */
return async () => { return async () => {
if (typeof provider.initialize === 'function') {
return provider.initialize();
}
// Transitional fallback for raw mssql pool
for (const script of SCHEMA_SCRIPTS) { 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