From 9495ad51cc166adc3df2e6a5524fc99e116526da Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 22 Oct 2026 12:30:06 +0200 Subject: [PATCH] u --- API.md | 6 +- API.posterminal.md | 51 ++- generate-api-doc.js | 2 +- generate-schema-graph.js | 3 +- scripts/test-category-delete-recursive.js | 11 +- scripts/test-tombstone.js | 310 ++++++++++-------- src/index.js | 4 + src/providers/mssql/queries/articleDelete.js | 23 -- src/providers/mssql/queries/posterminal.js | 24 ++ src/providers/mssql/schema.js | 27 +- .../sqlite3/queries/articleDelete.js | 23 -- src/providers/sqlite3/queries/posterminal.js | 14 +- src/providers/sqlite3/schema.js | 31 +- src/store/article/delete.js | 6 +- src/store/category/addImage.js | 5 + src/store/category/delete.js | 8 +- src/store/category/deleteImage.js | 6 + src/store/category/rename.js | 9 +- src/store/customergroup/delete.js | 7 +- src/store/entity/delete.js | 4 + .../posterminal/getDeletedEntityCount.js | 27 +- src/store/posterminal/getDeletedEntityList.js | 32 ++ src/store/posterminal/recordTombstone.js | 27 ++ src/store/posterminal/setOff.js | 30 ++ src/store/posterminal/setOffAll.js | 18 + src/store/posterminal/setOn.js | 33 ++ src/store/posterminal/setOnAll.js | 36 ++ 27 files changed, 539 insertions(+), 238 deletions(-) create mode 100644 src/store/posterminal/getDeletedEntityList.js create mode 100644 src/store/posterminal/recordTombstone.js diff --git a/API.md b/API.md index 5afa6a3..2a3e016 100644 --- a/API.md +++ b/API.md @@ -19,7 +19,6 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - **articleproperty**: `id PK, article_id->article, property_id->propertyname, value:txt, sort:i=0` - **articlesupplier**: `id PK, article_id->article, entity_id->entity, net?:f, tax_id?->tax, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)` - **articletext**: `id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)` -- **articletombstone**: `id PK, company_id->company, article_id?:i, sku?:s(100), name?:s(255), taxrate?:d(18,4), net?:f, deleted_at:dt` - **attributename**: `id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)` - **attributevalue**: `id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)` - **attributevaluepic**: `id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)` @@ -66,6 +65,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - **pic**: `id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin` - **posterminal**: `id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt` - **posterminalcategory**: `id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)` +- **posterminaltombstone**: `id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt` - **price**: `id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f` - **printer**: `id PK, company_id->company, address:s(255), type:s(50), model?:s(255), serial?:s(255), sort:i=0, desc?:txt` - **propertyname**: `id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)` @@ -263,12 +263,14 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `getCustomerGroupCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` - `getCustomerGroupList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise>` - `getCustomerList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise>` - - `getDeletedEntityCount(input?: {posterminalId?: number, cursor?: number}): Promise` + - `getDeletedEntityCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` + - `getDeletedEntityList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise>` - `getMaxOrderIdCount(input: {posterminalId: number}, tx?: object): Promise` - `getProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` - `getProductList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise>` - `list(googleAuthId: number, tx?: object): Promise>` - `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>` + - `recordTombstone(input: {companyId: number, entityType: string, entityId: number}, tx?: object): Promise` - `setMaxKnownTombstoneId(id: number, maxknowntombstoneid: number|null, tx?: object): Promise<{id: number, maxknowntombstoneid: number|null}>` - `setOff(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array}>` - `setOffAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array}>` diff --git a/API.posterminal.md b/API.posterminal.md index 33ede5c..a5da219 100644 --- a/API.posterminal.md +++ b/API.posterminal.md @@ -204,19 +204,40 @@ Lists customers for a POS terminal modified after the cursor, excluding 'Liefera *Source: `src/store/posterminal/getDeletedEntityCount.js`* -Counts deleted entities after the given cursor (currently always 0). +Counts deleted entities for a POS terminal after the given cursor ID. **Parameters** | Name | Type | Description | | --- | --- | --- | -| `[input]` | `object` | | -| `[input.posterminalId]` | `number` | The POS terminal id. | -| `[input.cursor=0]` | `number` | Unix timestamp cursor. | +| `input` | `object` | | +| `input.posterminalId` | `number` | The POS terminal id. | +| `[input.cursor=0]` | `number` | The highest synced tombstone ID. | +| `[tx]` | `object` | Optional transaction. | **Returns** -`Promise` — Always 0. +`Promise` — Count of deleted entities. + +## `posterminal.getDeletedEntityList` + +*Source: `src/store/posterminal/getDeletedEntityList.js`* + +Lists deleted entities for a POS terminal after the given cursor ID. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `input` | `object` | | +| `input.posterminalId` | `number` | The POS terminal id. | +| `[input.cursor=0]` | `number` | The highest synced tombstone ID. | +| `[input.limit=200]` | `number` | Max items to return. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise>` ## `posterminal.getMaxOrderIdCount` @@ -320,6 +341,26 @@ Completes POS device pairing for a terminal. `Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>` +## `posterminal.recordTombstone` + +*Source: `src/store/posterminal/recordTombstone.js`* + +Records a tombstone for all active paired POS terminals of a company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `input` | `object` | | +| `input.companyId` | `number` | The company id. | +| `input.entityType` | `string` | 'category' | 'article' | 'customer' | 'customergroup' | +| `input.entityId` | `number` | The deleted entity's id. | +| `[tx]` | `object` | Optional transaction/runner. | + +**Returns** + +`Promise` + ## `posterminal.setMaxKnownTombstoneId` *Source: `src/store/posterminal/setMaxKnownTombstoneId.js`* diff --git a/generate-api-doc.js b/generate-api-doc.js index 9d70803..b9d0c6d 100644 --- a/generate-api-doc.js +++ b/generate-api-doc.js @@ -335,7 +335,6 @@ const SCHEMA_TABLES = [ ['articleproperty', 'id PK, article_id->article, property_id->propertyname, value:txt, sort:i=0'], ['articlesupplier', 'id PK, article_id->article, entity_id->entity, net?:f, tax_id?->tax, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)'], ['articletext', 'id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)'], - ['articletombstone', 'id PK, company_id->company, article_id?:i, sku?:s(100), name?:s(255), taxrate?:d(18,4), net?:f, deleted_at:dt'], ['attributename', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'], ['attributevalue', 'id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)'], ['attributevaluepic', 'id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)'], @@ -382,6 +381,7 @@ const SCHEMA_TABLES = [ ['pic', 'id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin'], ['posterminal', 'id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin?:s(50), root_category_id?->category, sync_root_articles:b=1, lastseen_at?:dt, updated_at:dt'], ['posterminalcategory', 'id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)'], + ['posterminaltombstone', 'id PK, posterminal_id->posterminal, entity_type:s(50), entity_id:i, created_at:dt'], ['price', 'id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f'], ['printer', 'id PK, company_id->company, address:s(255), type:s(50), model?:s(255), serial?:s(255), sort:i=0, desc?:txt'], ['propertyname', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'], diff --git a/generate-schema-graph.js b/generate-schema-graph.js index 36e3722..7bbe1dc 100644 --- a/generate-schema-graph.js +++ b/generate-schema-graph.js @@ -354,7 +354,6 @@ const CLUSTERS = [ 'article', 'articleean', 'articlehistory', - 'articletombstone', 'articlelist', 'articlelistarticle', 'optionslist', @@ -391,7 +390,7 @@ const CLUSTERS = [ tables: ['tax', 'price', 'account', 'payment'], }, { id: 'units', label: 'Units', tables: ['unit'] }, - { id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer', 'labeltemplate'] }, + { id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'posterminaltombstone', 'printer', 'labeltemplate'] }, { id: 'ai', label: 'AI & Models', diff --git a/scripts/test-category-delete-recursive.js b/scripts/test-category-delete-recursive.js index a0ab8cc..ae2e3a6 100644 --- a/scripts/test-category-delete-recursive.js +++ b/scripts/test-category-delete-recursive.js @@ -127,6 +127,11 @@ async function run() { name: 'POS Register 1', pairingpin: '1234', }); + await store.posterminal.pair(posTerminal.id, { + token: 'pos-token-1', + clientserial: 'SN1', + pairedAt: new Date().toISOString(), + }); await store.posterminal.setOn(googleAuthId, posTerminal.id, subCat2.id); await store.posterminal.setOn(googleAuthId, posTerminal.id, outsideCat.id); @@ -170,7 +175,7 @@ async function run() { /Article not found/ ); // tombstone for artOnlySub exists - const t1 = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artOnlySub.sku); + const t1 = provider.db.prepare("SELECT * FROM posterminaltombstone WHERE posterminal_id = ? AND entity_type = 'article' AND entity_id = ?").all(posTerminal.id, artOnlySub.id); assert.equal(t1.length, 1); // artParent and its child artChild: both deleted @@ -182,8 +187,8 @@ async function run() { () => store.article.getBySku(googleAuthId, artChild.article.sku), /Article not found/ ); - const tParent = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artParent.sku); - const tChild = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artChild.article.sku); + const tParent = provider.db.prepare("SELECT * FROM posterminaltombstone WHERE posterminal_id = ? AND entity_type = 'article' AND entity_id = ?").all(posTerminal.id, artParent.id); + const tChild = provider.db.prepare("SELECT * FROM posterminaltombstone WHERE posterminal_id = ? AND entity_type = 'article' AND entity_id = ?").all(posTerminal.id, artChild.article.id); assert.equal(tParent.length, 1); assert.equal(tChild.length, 1); diff --git a/scripts/test-tombstone.js b/scripts/test-tombstone.js index 232592c..9e3768c 100644 --- a/scripts/test-tombstone.js +++ b/scripts/test-tombstone.js @@ -6,7 +6,7 @@ import LibDashStore from '../src/index.js'; import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; async function run() { - console.log('--- Testing articletombstone table and deletion flow ---'); + console.log('--- Testing posterminaltombstone table and deletion flow ---'); const tempDir = mkdtempSync(join(tmpdir(), 'test-tombstone-')); const dbFile = join(tempDir, 'test.db'); @@ -21,20 +21,17 @@ async function run() { const store = await new LibDashStore(provider); try { - // 1. Verify table exists and has expected columns - console.log('1. Checking articletombstone schema...'); - const tableInfo = provider.db.pragma('table_info("articletombstone")'); + // 1. Verify posterminaltombstone table exists and has expected columns + console.log('1. Checking posterminaltombstone schema...'); + const tableInfo = provider.db.pragma('table_info("posterminaltombstone")'); const colNames = tableInfo.map((c) => c.name); - console.log('articletombstone columns:', colNames); + console.log('posterminaltombstone columns:', colNames); assert.deepEqual(colNames, [ 'id', - 'company_id', - 'article_id', - 'sku', - 'name', - 'taxrate', - 'net', - 'deleted_at', + 'posterminal_id', + 'entity_type', + 'entity_id', + 'created_at', ]); // Setup company first @@ -49,154 +46,201 @@ async function run() { }); await store.google_auth.setSelectedCompany(googleAuthId, company.id); - // 3. Test CHECK constraint (article_id IS NOT NULL OR sku IS NOT NULL) - console.log('3. Testing CHECK constraint...'); - // Setting both article_id and sku to NULL should fail - assert.throws(() => { - provider.db.prepare(` - INSERT INTO articletombstone (company_id, article_id, sku, name) - VALUES (?, NULL, NULL, 'Test') - `).run(company.id); - }, /CHECK constraint failed/); - - // article_id present, sku null -> should succeed - const r1 = provider.db.prepare(` - INSERT INTO articletombstone (company_id, article_id, sku, name) - VALUES (?, 999, NULL, 'Only ID') - `).run(company.id); - assert.ok(r1.lastInsertRowid); - - // sku present, article_id null -> should succeed - const r2 = provider.db.prepare(` - INSERT INTO articletombstone (company_id, article_id, sku, name) - VALUES (?, NULL, 'SKU-ONLY', 'Only SKU') - `).run(company.id); - assert.ok(r2.lastInsertRowid); - - // Clean up manual test entries - provider.db.prepare('DELETE FROM articletombstone').run(); - - // 4. Test article deletion flow - console.log('4. Creating test article...'); + // Setup active paired POS terminal + const terminal = await store.posterminal.upsert(googleAuthId, { + name: 'POS Terminal 1', + pairingpin: '1234', + }); + await store.posterminal.pair(terminal.id, { + token: 'pos-token-123', + clientserial: 'SN-001', + pairedAt: new Date().toISOString(), + }); + // 3. Test article deletion flow + console.log('3. Testing article deletion tombstone...'); const article = await store.article.createUnderParent({ googleAuthId, parentId: company.rootCategoryId, name: 'Coffee Mug', }); - - // Set prices for the article - await store.article.setPricesBySku(googleAuthId, article.sku, [ - { price: 12.50, tax: 19, minmultiplier: 1 }, - ]); - - // Verify article exists - const detailBefore = await store.article.getBySku(googleAuthId, article.sku); - assert.equal(detailBefore.article.name, 'Coffee Mug'); - assert.equal(detailBefore.article.prices[0].net, 12.50); - assert.equal(detailBefore.article.prices[0].taxRate, 19); - - // Delete the article - console.log('4. Deleting article and checking tombstone creation...'); await store.article.delete(googleAuthId, article.sku); - // Verify article is deleted - await assert.rejects( - () => store.article.getBySku(googleAuthId, article.sku), - /Article not found/ - ); - - // Verify tombstone record - const tombstones = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(article.sku); - assert.equal(tombstones.length, 1); - const tomb = tombstones[0]; - console.log('Created tombstone:', tomb); - assert.equal(tomb.company_id, company.id); - assert.equal(tomb.article_id, article.id); - assert.equal(tomb.sku, article.sku); - assert.equal(tomb.name, 'Coffee Mug'); - assert.equal(tomb.net, 12.50); - assert.equal(tomb.taxrate, 19); - assert.ok(tomb.deleted_at); - - // 5. Test cascading deletion with parent and variants - console.log('5. Testing parent with variant child deletion...'); - const parent = await store.article.createUnderParent({ + // 4. Test category deletion flow + console.log('4. Testing category deletion tombstone...'); + const cat = await store.category.createUnderParent({ googleAuthId, parentId: company.rootCategoryId, - name: 'Pants', + name: 'Snacks', }); - const attrName = await store.attributename.create(googleAuthId, { name: 'Größe' }); - await store.article.toggleParent(googleAuthId, parent.sku, { - isParent: true, - attributeNameId: attrName.id, + await store.category.delete({ googleAuthId, id: cat.id }); + + // 5. Test customer deletion flow + console.log('5. Testing customer deletion tombstone...'); + const customer = await store.entity.create(googleAuthId, { + name: 'Mustermann', + firstName: 'Max', + customergroupId: company.posCustomerGroupId, }); - const child = await store.article.addChild(googleAuthId, parent.sku, { value: 'M' }); - await store.article.setPricesBySku(googleAuthId, child.article.sku, [ - { price: 49.99, tax: 19, minmultiplier: 1 }, - ]); + await store.entity.delete(googleAuthId, customer.id); - // Delete child via removeChild with deleteArticle - console.log('Deleting child via removeChild({ deleteArticle: true })...'); - await store.article.removeChild(googleAuthId, parent.sku, child.article.sku, { deleteArticle: true }); + // 6. Test customer group deletion flow + console.log('6. Testing customer group deletion tombstone...'); + const cg = await store.customergroup.create(googleAuthId, { + name: 'VIP Guests', + }); + await store.customergroup.delete(googleAuthId, cg.id); - // Add another child and delete parent with cascade: true - const child2 = await store.article.addChild(googleAuthId, parent.sku, { value: 'L' }); - console.log('Deleting parent with cascade: true...'); - await store.article.delete(googleAuthId, parent.sku, { cascade: true }); - - const allTombstones = provider.db.prepare('SELECT * FROM articletombstone ORDER BY id ASC').all(); + // 7. Verify all tombstones exist in posterminaltombstone + console.log('7. Verifying tombstones in database...'); + const allTombstones = provider.db + .prepare('SELECT * FROM posterminaltombstone WHERE posterminal_id = ? ORDER BY id ASC') + .all(terminal.id); + console.log('Created tombstones:', allTombstones); assert.equal(allTombstones.length, 4); - // 1st tombstone: Coffee Mug - assert.equal(allTombstones[0].name, 'Coffee Mug'); - assert.equal(allTombstones[0].sku, '1'); - assert.equal(allTombstones[0].net, 12.5); - assert.equal(allTombstones[0].taxrate, 19); + assert.equal(allTombstones[0].entity_type, 'article'); + assert.equal(allTombstones[0].entity_id, article.id); - // 2nd tombstone: Pants - M (deleted via removeChild) - assert.equal(allTombstones[1].name, 'Pants - M'); - assert.equal(allTombstones[1].sku, child.article.sku); - assert.equal(allTombstones[1].net, 49.99); - assert.equal(allTombstones[1].taxrate, 19); + assert.equal(allTombstones[1].entity_type, 'category'); + assert.equal(allTombstones[1].entity_id, cat.id); - // 3rd tombstone: Pants - L (child deleted in cascade) - assert.equal(allTombstones[2].name, 'Pants - L'); - assert.equal(allTombstones[2].sku, child2.article.sku); + assert.equal(allTombstones[2].entity_type, 'customer'); + assert.equal(allTombstones[2].entity_id, customer.id); - // 4th tombstone: Pants (parent deleted in cascade) - assert.equal(allTombstones[3].name, 'Pants'); - assert.equal(allTombstones[3].sku, parent.sku); + assert.equal(allTombstones[3].entity_type, 'customergroup'); + assert.equal(allTombstones[3].entity_id, cg.id); - // 6. Test posterminal maxknowntombstoneid column and updates - console.log('6. Testing posterminal maxknowntombstoneid column...'); - const posterminalCols = provider.db.pragma('table_info("posterminal")').map((c) => c.name); - assert.ok(posterminalCols.includes('maxknowntombstoneid'), 'posterminal should have maxknowntombstoneid column'); - - const terminal = await store.posterminal.upsert(googleAuthId, { - name: 'POS Terminal 1', - pairingpin: '1234', + // 8. Test getDeletedEntityCount & getDeletedEntityList APIs + console.log('8. Testing posterminal.getDeletedEntityCount & getDeletedEntityList...'); + const initialCount = await store.posterminal.getDeletedEntityCount({ + posterminalId: terminal.id, + cursor: 0, }); - assert.equal(terminal.maxknowntombstoneid, null); + assert.equal(initialCount, 4); - // Pair terminal - await store.posterminal.pair(terminal.id, { - token: 'tok-123', - clientserial: 'SN-001', - pairedAt: new Date().toISOString(), + const list = await store.posterminal.getDeletedEntityList({ + posterminalId: terminal.id, + cursor: 0, + limit: 10, + }); + assert.equal(list.length, 4); + assert.equal(list[0].entityType, 'article'); + assert.equal(list[0].entityId, article.id); + assert.equal(list[1].entityType, 'category'); + assert.equal(list[2].entityType, 'customer'); + assert.equal(list[3].entityType, 'customergroup'); + + // Test cursor pagination + const cursor = allTombstones[1].id; + const countAfterCursor = await store.posterminal.getDeletedEntityCount({ + posterminalId: terminal.id, + cursor, + }); + assert.equal(countAfterCursor, 2); + + const listAfterCursor = await store.posterminal.getDeletedEntityList({ + posterminalId: terminal.id, + cursor, + limit: 10, + }); + assert.equal(listAfterCursor.length, 2); + assert.equal(listAfterCursor[0].id, allTombstones[2].id); + assert.equal(listAfterCursor[1].id, allTombstones[3].id); + + // 9. Test maxknowntombstoneid + console.log('9. Testing maxknowntombstoneid...'); + await store.posterminal.setMaxKnownTombstoneId(terminal.id, allTombstones[3].id); + const updatedTerminal = await store.posterminal.findByToken('pos-token-123'); + assert.equal(updatedTerminal.maxknowntombstoneid, allTombstones[3].id); + + // 10. Test setOff category & orphaned article tombstone generation + console.log('10. Testing setOff with orphaned and non-orphaned articles...'); + // Create Cat A and Cat B + const catA = await store.category.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Category A', + }); + const catB = await store.category.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Category B', }); - const foundByToken = await store.posterminal.findByToken('tok-123'); - assert.equal(foundByToken.id, terminal.id); - assert.equal(foundByToken.maxknowntombstoneid, null); + // Art A only in Cat A + const artA = await store.article.createUnderParent({ + googleAuthId, + parentId: catA.id, + name: 'Article A (only in Cat A)', + }); - // Update maxknowntombstoneid - await store.posterminal.setMaxKnownTombstoneId(terminal.id, 4); + // Art AB in both Cat A and Cat B + const artAB = await store.article.createUnderParent({ + googleAuthId, + parentId: catA.id, + name: 'Article AB (in Cat A and B)', + }); + await store.article.setCategoryIdsBySku(googleAuthId, artAB.sku, [catA.id, catB.id]); - const foundAfterUpdate = await store.posterminal.findByToken('tok-123'); - assert.equal(foundAfterUpdate.maxknowntombstoneid, 4); + // Turn both categories on for the terminal + await store.posterminal.setOn(googleAuthId, terminal.id, catA.id); + await store.posterminal.setOn(googleAuthId, terminal.id, catB.id); - console.log('✅ ALL TOMBSTONE TESTS PASSED'); + // Clear previous tombstones to test cleanly + provider.db.prepare('DELETE FROM posterminaltombstone WHERE posterminal_id = ?').run(terminal.id); + + // Turn off Cat A: + // - Cat A should get a tombstone + // - Art A should get a tombstone (orphaned on this terminal) + // - Art AB should NOT get a tombstone (still has active Cat B on this terminal) + await store.posterminal.setOff(googleAuthId, terminal.id, catA.id); + + const tombstonesAfterSetOff = provider.db + .prepare('SELECT entity_type, entity_id FROM posterminaltombstone WHERE posterminal_id = ? ORDER BY id ASC') + .all(terminal.id); + + console.log('Tombstones after setOff(Cat A):', tombstonesAfterSetOff); + assert.equal(tombstonesAfterSetOff.length, 2); + assert.deepEqual(tombstonesAfterSetOff, [ + { entity_type: 'category', entity_id: catA.id }, + { entity_type: 'article', entity_id: artA.id }, + ]); + + // 11. Test setOn category clears pending tombstones for that category and its articles + console.log('11. Testing setOn clears pending category and article tombstones...'); + await store.posterminal.setOn(googleAuthId, terminal.id, catA.id); + const tombstonesAfterSetOn = provider.db + .prepare('SELECT entity_type, entity_id FROM posterminaltombstone WHERE posterminal_id = ?') + .all(terminal.id); + console.log('Tombstones after setOn(Cat A):', tombstonesAfterSetOn); + assert.equal(tombstonesAfterSetOn.length, 0); + + // 12. Test setOffAll generates tombstones for all active categories and all their articles + console.log('12. Testing setOffAll tombstones...'); + await store.posterminal.setOffAll(googleAuthId, terminal.id); + const tombstonesAfterSetOffAll = provider.db + .prepare('SELECT entity_type, entity_id FROM posterminaltombstone WHERE posterminal_id = ? ORDER BY id ASC') + .all(terminal.id); + console.log('Tombstones after setOffAll:', tombstonesAfterSetOffAll); + // Cat A, Cat B, Art A, Art AB + const catTombstones = tombstonesAfterSetOffAll.filter((t) => t.entity_type === 'category').map((t) => t.entity_id); + const artTombstones = tombstonesAfterSetOffAll.filter((t) => t.entity_type === 'article').map((t) => t.entity_id); + assert(catTombstones.includes(catA.id)); + assert(catTombstones.includes(catB.id)); + assert(artTombstones.includes(artA.id)); + assert(artTombstones.includes(artAB.id)); + + // 13. Test setOnAll clears category and article tombstones + console.log('13. Testing setOnAll clears all category & article tombstones...'); + await store.posterminal.setOnAll(googleAuthId, terminal.id); + const tombstonesAfterSetOnAll = provider.db + .prepare('SELECT entity_type, entity_id FROM posterminaltombstone WHERE posterminal_id = ?') + .all(terminal.id); + console.log('Tombstones after setOnAll:', tombstonesAfterSetOnAll); + assert.equal(tombstonesAfterSetOnAll.length, 0); + + console.log('✅ ALL POSTERMINALTOMBSTONE TESTS PASSED'); } finally { try { rmSync(tempDir, { recursive: true, force: true }); diff --git a/src/index.js b/src/index.js index 4f640a1..d1be9d8 100644 --- a/src/index.js +++ b/src/index.js @@ -126,6 +126,8 @@ import posterminalGetCustomerGroupCount from './store/posterminal/getCustomerGro import posterminalGetCustomerGroupList from './store/posterminal/getCustomerGroupList.js'; import posterminalGetCompositeProductCount from './store/posterminal/getCompositeProductCount.js'; import posterminalGetDeletedEntityCount from './store/posterminal/getDeletedEntityCount.js'; +import posterminalGetDeletedEntityList from './store/posterminal/getDeletedEntityList.js'; +import posterminalRecordTombstone from './store/posterminal/recordTombstone.js'; import posterminalGetMaxOrderIdCount from './store/posterminal/getMaxOrderIdCount.js'; import posterminalGetCategoryList from './store/posterminal/getCategoryList.js'; import posterminalGetProductList from './store/posterminal/getProductList.js'; @@ -488,6 +490,8 @@ export default class LibDashStore { getCustomerGroupList: posterminalGetCustomerGroupList(provider), getCompositeProductCount: posterminalGetCompositeProductCount(provider), getDeletedEntityCount: posterminalGetDeletedEntityCount(provider), + getDeletedEntityList: posterminalGetDeletedEntityList(provider), + recordTombstone: posterminalRecordTombstone(provider), getMaxOrderIdCount: posterminalGetMaxOrderIdCount(provider), getCategoryList: posterminalGetCategoryList(provider), getProductList: posterminalGetProductList(provider), diff --git a/src/providers/mssql/queries/articleDelete.js b/src/providers/mssql/queries/articleDelete.js index 51078a8..cb0aa75 100644 --- a/src/providers/mssql/queries/articleDelete.js +++ b/src/providers/mssql/queries/articleDelete.js @@ -17,29 +17,6 @@ WHERE id = @articleId `; -export const CREATE_TOMBSTONE = ` -INSERT INTO articletombstone (company_id, article_id, sku, name, taxrate, net) -SELECT - a.company_id, - a.id AS article_id, - a.sku, - COALESCE( - (SELECT TOP 1 at.name FROM articletext at WHERE at.article_id = a.id AND at.language_id = co.default_language_id), - (SELECT TOP 1 at.name FROM articletext at WHERE at.article_id = a.id ORDER BY at.id ASC) - ) AS name, - COALESCE( - (SELECT TOP 1 t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), - (SELECT TOP 1 t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id ORDER BY pr.id ASC) - ) AS taxrate, - COALESCE( - (SELECT TOP 1 pr.net FROM price pr WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), - (SELECT TOP 1 pr.net FROM price pr WHERE pr.article_id = a.id ORDER BY pr.id ASC) - ) AS net -FROM article a -INNER JOIN company co ON co.id = a.company_id -WHERE a.id = @articleId -`; - export const DELETE_PICS_BY_ARTICLE = ` DELETE p FROM pic p diff --git a/src/providers/mssql/queries/posterminal.js b/src/providers/mssql/queries/posterminal.js index 0fdfa16..bc6eb70 100644 --- a/src/providers/mssql/queries/posterminal.js +++ b/src/providers/mssql/queries/posterminal.js @@ -482,3 +482,27 @@ INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = a.compan WHERE pr.minmultiplier = 1 GROUP BY pr.article_id, pr.customergroup_id `; + +export const RECORD_TOMBSTONE = ` +INSERT INTO posterminaltombstone (posterminal_id, entity_type, entity_id) +SELECT pt.id, @entityType, @entityId +FROM posterminal pt +WHERE pt.company_id = @companyId AND pt.token IS NOT NULL +`; + +export const COUNT_DELETED_ENTITIES = ` +SELECT COUNT(*) AS count +FROM posterminaltombstone +WHERE posterminal_id = @posterminalId AND id > @cursor +`; + +export const LIST_DELETED_ENTITIES = ` +SELECT TOP (@limit) + id, + entity_type AS entityType, + entity_id AS entityId +FROM posterminaltombstone +WHERE posterminal_id = @posterminalId AND id > @cursor +ORDER BY id ASC +`; + diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index e450916..ff19883 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -392,27 +392,24 @@ BEGIN END `; -export const INIT_ARTICLETOMBSTONE = ` -IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articletombstone') +export const INIT_POSTERMINALTOMBSTONE = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'posterminaltombstone') BEGIN - CREATE TABLE articletombstone ( + CREATE TABLE posterminaltombstone ( id INT IDENTITY(1,1) PRIMARY KEY, - company_id INT NOT NULL, - article_id INT NULL, - sku NVARCHAR(100) NULL, - name NVARCHAR(255) NULL, - taxrate DECIMAL(18,4) NULL, - net FLOAT NULL, - deleted_at DATETIME2 NOT NULL CONSTRAINT DF_articletombstone_deleted_at DEFAULT SYSUTCDATETIME(), - CONSTRAINT CK_articletombstone_article_or_sku CHECK (article_id IS NOT NULL OR sku IS NOT NULL), - CONSTRAINT FK_articletombstone_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE + posterminal_id INT NOT NULL, + entity_type NVARCHAR(50) NOT NULL, + entity_id INT NOT NULL, + created_at DATETIME2 NOT NULL CONSTRAINT DF_posterminaltombstone_created_at DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_posterminaltombstone_posterminal FOREIGN KEY (posterminal_id) REFERENCES posterminal(id) ON DELETE CASCADE ); - CREATE INDEX IX_articletombstone_company_deleted - ON articletombstone (company_id, deleted_at); + CREATE INDEX IX_posterminaltombstone_posterminal_id + ON posterminaltombstone (posterminal_id, id); END `; + export const INIT_ARTICLEEAN = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articleean') BEGIN @@ -1501,12 +1498,12 @@ export const SCHEMA_SCRIPTS = [ INIT_MODELMESSAGE, INIT_MODELVFS, INIT_ARTICLEHISTORY, - INIT_ARTICLETOMBSTONE, INIT_ENTITYHISTORY, INIT_COMPANYHISTORY, INIT_COMPANYSETTING, INIT_POSTERMINAL, INIT_POSTERMINALCATEGORY, + INIT_POSTERMINALTOMBSTONE, INIT_PRINTER, INIT_LABELTEMPLATE, INIT_FILE, diff --git a/src/providers/sqlite3/queries/articleDelete.js b/src/providers/sqlite3/queries/articleDelete.js index a1a4500..11cc3a0 100644 --- a/src/providers/sqlite3/queries/articleDelete.js +++ b/src/providers/sqlite3/queries/articleDelete.js @@ -7,29 +7,6 @@ export const { GET_CHILDREN_IDS, GET_LIST_IDS, } = queries; -export const CREATE_TOMBSTONE = ` -INSERT INTO articletombstone (company_id, article_id, sku, name, taxrate, net) -SELECT - a.company_id, - a.id AS article_id, - a.sku, - COALESCE( - (SELECT at.name FROM articletext at WHERE at.article_id = a.id AND at.language_id = co.default_language_id), - (SELECT at.name FROM articletext at WHERE at.article_id = a.id ORDER BY at.id ASC LIMIT 1) - ) AS name, - COALESCE( - (SELECT t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), - (SELECT t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id ORDER BY pr.id ASC LIMIT 1) - ) AS taxrate, - COALESCE( - (SELECT pr.net FROM price pr WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), - (SELECT pr.net FROM price pr WHERE pr.article_id = a.id ORDER BY pr.id ASC LIMIT 1) - ) AS net -FROM article a -INNER JOIN company co ON co.id = a.company_id -WHERE a.id = @articleId -`; - export const DELETE_PICS_BY_ARTICLE = ` DELETE FROM pic WHERE id IN (SELECT pic_id FROM articlepic WHERE article_id = @articleId) diff --git a/src/providers/sqlite3/queries/posterminal.js b/src/providers/sqlite3/queries/posterminal.js index 0491f2a..c2faaf6 100644 --- a/src/providers/sqlite3/queries/posterminal.js +++ b/src/providers/sqlite3/queries/posterminal.js @@ -9,7 +9,7 @@ export const { FIND_BY_TOKEN, COUNT_PRODUCTS, COUNT_CATEGORIES, COUNT_CUSTOMERS, COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID, LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES, - TOUCH_LASTSEEN, + TOUCH_LASTSEEN, RECORD_TOMBSTONE, COUNT_DELETED_ENTITIES, } = queries; export const SET_ON = ` @@ -263,3 +263,15 @@ AND CAST(strftime('%s', a.updated_at) AS INTEGER) > @cursor ORDER BY lastChanged ASC, a.id ASC LIMIT @limit `; + +export const LIST_DELETED_ENTITIES = ` +SELECT + id, + entity_type AS entityType, + entity_id AS entityId +FROM posterminaltombstone +WHERE posterminal_id = @posterminalId AND id > @cursor +ORDER BY id ASC +LIMIT @limit +`; + diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 3d7115b..f9b458a 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -573,22 +573,6 @@ CREATE TABLE IF NOT EXISTS companyhistory ( ); `; -export const INIT_ARTICLETOMBSTONE = ` -CREATE TABLE IF NOT EXISTS articletombstone ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - company_id INTEGER NOT NULL, - article_id INTEGER, - sku TEXT COLLATE NOCASE, - name TEXT, - taxrate NUMERIC, - net REAL, - deleted_at TEXT NOT NULL DEFAULT (${UTC_NOW}), - CHECK (article_id IS NOT NULL OR sku IS NOT NULL), - FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS IX_articletombstone_company_deleted - ON articletombstone (company_id, deleted_at); -`; export const INIT_ENTITYHISTORY = ` CREATE TABLE IF NOT EXISTS entityhistory ( @@ -647,6 +631,19 @@ CREATE TABLE IF NOT EXISTS posterminalcategory ( ); `; +export const INIT_POSTERMINALTOMBSTONE = ` +CREATE TABLE IF NOT EXISTS posterminaltombstone ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + posterminal_id INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (${UTC_NOW}), + FOREIGN KEY (posterminal_id) REFERENCES posterminal(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS IX_posterminaltombstone_posterminal_id + ON posterminaltombstone (posterminal_id, id); +`; + export const INIT_PRINTER = ` CREATE TABLE IF NOT EXISTS printer ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1117,12 +1114,12 @@ export const SCHEMA_SCRIPTS = [ INIT_MODELMESSAGE, INIT_MODELVFS, INIT_ARTICLEHISTORY, - INIT_ARTICLETOMBSTONE, INIT_ENTITYHISTORY, INIT_COMPANYHISTORY, INIT_COMPANYSETTING, INIT_POSTERMINAL, INIT_POSTERMINALCATEGORY, + INIT_POSTERMINALTOMBSTONE, INIT_PRINTER, INIT_LABELTEMPLATE, INIT_FILE, diff --git a/src/store/article/delete.js b/src/store/article/delete.js index 947850c..86d5c83 100644 --- a/src/store/article/delete.js +++ b/src/store/article/delete.js @@ -3,10 +3,12 @@ import * as sqliteQueries from '../../providers/sqlite3/queries/articleDelete.js import * as mssqlArticle from '../../providers/mssql/queries/article.js'; import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; import resolveArticleBySku from './resolveArticleBySku.js'; +import recordTombstone from '../posterminal/recordTombstone.js'; import makeRunner from '../runner.js'; export default function del(provider) { const resolve = resolveArticleBySku(provider); + const tombstone = recordTombstone(provider); const isSqlite = provider?.dialect === 'sqlite3'; const queries = isSqlite ? sqliteQueries : mssqlQueries; const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; @@ -37,13 +39,13 @@ export default function del(provider) { const cascade = Boolean(options.cascade); const runDelete = async (runner) => { - const { articleId } = await resolve(googleAuthId, sku, runner); + const { articleId, companyId } = await resolve(googleAuthId, sku, runner); const deleteArticleAndLists = async (id) => { const listIds = await runner.query(queries.GET_LIST_IDS, { articleId: id }); const { articlelistId, optionslistId } = listIds.rows[0] ?? {}; - await runner.query(queries.CREATE_TOMBSTONE, { articleId: id }); + await tombstone({ companyId, entityType: 'article', entityId: id }, runner); await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId: id }); diff --git a/src/store/category/addImage.js b/src/store/category/addImage.js index a937b02..c2ff104 100644 --- a/src/store/category/addImage.js +++ b/src/store/category/addImage.js @@ -20,6 +20,9 @@ export default function addImage(provider) { 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; + const touchSql = isSqlite + ? "UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = @categoryId" + : 'UPDATE category SET updated_at = SYSUTCDATETIME() WHERE id = @categoryId'; /** * Adds an image to a category, processing full, thumb, and 32px-edge small-thumb AVIF variants. @@ -95,6 +98,8 @@ export default function addImage(provider) { }); } + await runner.query(touchSql, { categoryId }); + return { id: categoryId, fullHash, diff --git a/src/store/category/delete.js b/src/store/category/delete.js index 770425b..90ed1fc 100644 --- a/src/store/category/delete.js +++ b/src/store/category/delete.js @@ -5,10 +5,12 @@ import * as sqliteArticleDelete from '../../providers/sqlite3/queries/articleDel import * as mssqlArticle from '../../providers/mssql/queries/article.js'; import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; import resolveCategoryAccess from './resolveCategoryAccess.js'; +import recordTombstone from '../posterminal/recordTombstone.js'; import makeRunner from '../runner.js'; export default function del(provider) { const access = resolveCategoryAccess(provider); + const tombstone = recordTombstone(provider); const isSqlite = provider?.dialect === 'sqlite3'; const sql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; const articleDeleteQueries = isSqlite ? sqliteArticleDelete : mssqlArticleDelete; @@ -32,7 +34,7 @@ export default function del(provider) { */ return async ({ googleAuthId, id, recursive = false }, tx) => { const run = async (runner) => { - const { rootCategoryId } = await access(googleAuthId, id, runner); + const { rootCategoryId, companyId } = await access(googleAuthId, id, runner); if (id === rootCategoryId) { throw new Error('Cannot delete root category'); @@ -65,6 +67,7 @@ export default function del(provider) { try { await runner.query(sql, { id }); + await tombstone({ companyId, entityType: 'category', entityId: id }, runner); } catch (err) { if ( err.message?.includes('FOREIGN KEY constraint failed') || @@ -146,7 +149,7 @@ export default function del(provider) { const listIds = await runner.query(articleDeleteQueries.GET_LIST_IDS, { articleId: artId }); const { articlelistId, optionslistId } = listIds.rows[0] ?? {}; - await runner.query(articleDeleteQueries.CREATE_TOMBSTONE, { articleId: artId }); + await tombstone({ companyId, entityType: 'article', entityId: artId }, runner); await runner.query(articleDeleteQueries.DELETE_PICS_BY_ARTICLE, { articleId: artId }); const deleted = await runner.query(articleDeleteQueries.DELETE_BY_ID, { articleId: artId }); @@ -215,6 +218,7 @@ export default function del(provider) { const reversedIds = [...subtreeIds].reverse(); for (const catId of reversedIds) { await runner.query(sql, { id: catId }); + await tombstone({ companyId, entityType: 'category', entityId: catId }, runner); } }; diff --git a/src/store/category/deleteImage.js b/src/store/category/deleteImage.js index 68c577e..ff9fadf 100644 --- a/src/store/category/deleteImage.js +++ b/src/store/category/deleteImage.js @@ -6,6 +6,10 @@ import makeRunner from '../runner.js'; export default function deleteImage(provider) { const access = resolveCategoryAccess(provider); const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; + const isSqlite = provider?.dialect === 'sqlite3'; + const touchSql = isSqlite + ? "UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = @categoryId" + : 'UPDATE category SET updated_at = SYSUTCDATETIME() WHERE id = @categoryId'; /** * Deletes the image of a category. @@ -30,6 +34,8 @@ export default function deleteImage(provider) { throw new Error('Image not found'); } + await runner.query(touchSql, { categoryId }); + return { id: categoryId, thumbW: null, diff --git a/src/store/category/rename.js b/src/store/category/rename.js index 169a5b6..49830f3 100644 --- a/src/store/category/rename.js +++ b/src/store/category/rename.js @@ -7,6 +7,10 @@ import makeRunner from '../runner.js'; export default function rename(provider) { const access = resolveCategoryAccess(provider); const sql = provider?.dialect === 'sqlite3' ? SQLITE_UPDATE : MSSQL_UPDATE; + const isSqlite = provider?.dialect === 'sqlite3'; + const touchSql = isSqlite + ? "UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = @categoryId" + : 'UPDATE category SET updated_at = SYSUTCDATETIME() WHERE id = @categoryId'; /** * Renames a category and updates its slug. @@ -33,7 +37,8 @@ export default function rename(provider) { const slug = slugify(cleaned); - const result = await makeRunner(provider, tx).query(sql, { + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { categoryId: id, languageId, name: cleaned, @@ -44,6 +49,8 @@ export default function rename(provider) { throw new Error('Category not found'); } + await runner.query(touchSql, { categoryId: id }); + return { id, name: cleaned, slug }; }; } diff --git a/src/store/customergroup/delete.js b/src/store/customergroup/delete.js index 017ad54..6f4b2a7 100644 --- a/src/store/customergroup/delete.js +++ b/src/store/customergroup/delete.js @@ -2,10 +2,12 @@ import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE_BY_COMPANY_AND_ID } from '../. 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 recordTombstone from '../posterminal/recordTombstone.js'; import makeRunner from '../runner.js'; export default function del(provider) { const access = resolveSelectedCompanyAccess(provider); + const tombstone = recordTombstone(provider); const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE_BY_COMPANY_AND_ID @@ -31,7 +33,8 @@ export default function del(provider) { const { companyId } = await access(googleAuthId, tx); try { - const result = await makeRunner(provider, tx).query(sql, { + const runner = makeRunner(provider, tx); + const result = await runner.query(sql, { id, companyId, }); @@ -39,6 +42,8 @@ export default function del(provider) { if (!result.rowsAffected) { throw new Error('Customer group not found'); } + + await tombstone({ companyId, entityType: 'customergroup', entityId: id }, runner); } catch (err) { if (isForeignKeyConstraintError(err)) { throw new Error('Customer group in use'); diff --git a/src/store/entity/delete.js b/src/store/entity/delete.js index d5aad20..b22eb10 100644 --- a/src/store/entity/delete.js +++ b/src/store/entity/delete.js @@ -9,10 +9,12 @@ import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID, } from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import recordTombstone from '../posterminal/recordTombstone.js'; import makeRunner from '../runner.js'; export default function del(provider) { const access = resolveSelectedCompanyAccess(provider); + const tombstone = recordTombstone(provider); const isSqlite = provider?.dialect === 'sqlite3'; const sql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; const checkInUseSql = isSqlite ? SQLITE_CHECK_IN_USE : MSSQL_CHECK_IN_USE; @@ -63,6 +65,8 @@ export default function del(provider) { if (!result.rowsAffected) { throw new Error('Entity not found'); } + + await tombstone({ companyId, entityType: 'customer', entityId: numericId }, runner); }; } diff --git a/src/store/posterminal/getDeletedEntityCount.js b/src/store/posterminal/getDeletedEntityCount.js index 5d56933..982838a 100644 --- a/src/store/posterminal/getDeletedEntityCount.js +++ b/src/store/posterminal/getDeletedEntityCount.js @@ -1,11 +1,24 @@ -export default function getDeletedEntityCount() { +import { COUNT_DELETED_ENTITIES as MSSQL_SQL } from '../../providers/mssql/queries/posterminal.js'; +import { COUNT_DELETED_ENTITIES as SQLITE_SQL } from '../../providers/sqlite3/queries/posterminal.js'; +import makeRunner from '../runner.js'; + +export default function getDeletedEntityCount(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_SQL : MSSQL_SQL; + /** - * Counts deleted entities after the given cursor (currently always 0). + * Counts deleted entities for a POS terminal after the given cursor ID. * - * @param {object} [input] - * @param {number} [input.posterminalId] - The POS terminal id. - * @param {number} [input.cursor=0] - Unix timestamp cursor. - * @returns {Promise} Always 0. + * @param {object} input + * @param {number} input.posterminalId - The POS terminal id. + * @param {number} [input.cursor=0] - The highest synced tombstone ID. + * @param {object} [tx] - Optional transaction. + * @returns {Promise} Count of deleted entities. */ - return async () => 0; + return async ({ posterminalId, cursor = 0 } = {}, tx) => { + const result = await makeRunner(provider, tx).query(sql, { + posterminalId: Number(posterminalId), + cursor: Number(cursor) || 0, + }); + return Number(result.rows[0]?.count ?? 0); + }; } diff --git a/src/store/posterminal/getDeletedEntityList.js b/src/store/posterminal/getDeletedEntityList.js new file mode 100644 index 0000000..b42c9e9 --- /dev/null +++ b/src/store/posterminal/getDeletedEntityList.js @@ -0,0 +1,32 @@ +import { LIST_DELETED_ENTITIES as MSSQL_SQL } from '../../providers/mssql/queries/posterminal.js'; +import { LIST_DELETED_ENTITIES as SQLITE_SQL } from '../../providers/sqlite3/queries/posterminal.js'; +import makeRunner from '../runner.js'; + +export default function getDeletedEntityList(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_SQL : MSSQL_SQL; + + /** + * Lists deleted entities for a POS terminal after the given cursor ID. + * + * @param {object} input + * @param {number} input.posterminalId - The POS terminal id. + * @param {number} [input.cursor=0] - The highest synced tombstone ID. + * @param {number} [input.limit=200] - Max items to return. + * @param {object} [tx] - Optional transaction. + * @returns {Promise>} + */ + return async ({ posterminalId, cursor = 0, limit = 200 } = {}, tx) => { + const safeLimit = Number.isInteger(limit) && limit > 0 ? limit : 200; + const result = await makeRunner(provider, tx).query(sql, { + posterminalId: Number(posterminalId), + cursor: Number(cursor) || 0, + limit: safeLimit, + }); + + return result.rows.map((row) => ({ + id: Number(row.id), + entityType: String(row.entityType), + entityId: Number(row.entityId), + })); + }; +} diff --git a/src/store/posterminal/recordTombstone.js b/src/store/posterminal/recordTombstone.js new file mode 100644 index 0000000..72d202a --- /dev/null +++ b/src/store/posterminal/recordTombstone.js @@ -0,0 +1,27 @@ +import { RECORD_TOMBSTONE as MSSQL_SQL } from '../../providers/mssql/queries/posterminal.js'; +import { RECORD_TOMBSTONE as SQLITE_SQL } from '../../providers/sqlite3/queries/posterminal.js'; +import makeRunner from '../runner.js'; + +export default function recordTombstone(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_SQL : MSSQL_SQL; + + /** + * Records a tombstone for all active paired POS terminals of a company. + * + * @param {object} input + * @param {number} input.companyId - The company id. + * @param {string} input.entityType - 'category' | 'article' | 'customer' | 'customergroup' + * @param {number} input.entityId - The deleted entity's id. + * @param {object} [tx] - Optional transaction/runner. + * @returns {Promise} + */ + return async ({ companyId, entityType, entityId }, tx) => { + if (!companyId || !entityType || !entityId) return; + const runner = makeRunner(provider, tx); + await runner.query(sql, { + companyId: Number(companyId), + entityType: String(entityType).toLowerCase().trim(), + entityId: Number(entityId), + }); + }; +} diff --git a/src/store/posterminal/setOff.js b/src/store/posterminal/setOff.js index 0cc041b..f508d68 100644 --- a/src/store/posterminal/setOff.js +++ b/src/store/posterminal/setOff.js @@ -21,6 +21,26 @@ export default function setOff(provider) { ? SQLITE_LIST_CATEGORY_IDS : MSSQL_LIST_CATEGORY_IDS; + const addCategoryTombstoneSql = ` + INSERT INTO posterminaltombstone (posterminal_id, entity_type, entity_id) + VALUES (@posterminalId, 'category', @categoryId) + `; + + const addArticleTombstonesSql = ` + INSERT INTO posterminaltombstone (posterminal_id, entity_type, entity_id) + SELECT DISTINCT @posterminalId, 'article', ca.article_id + FROM categoryarticle ca + WHERE ca.category_id = @categoryId + AND NOT EXISTS ( + SELECT 1 + FROM categoryarticle ca2 + INNER JOIN posterminalcategory pc ON pc.category_id = ca2.category_id + WHERE ca2.article_id = ca.article_id + AND pc.posterminal_id = @posterminalId + AND pc.category_id <> @categoryId + ) + `; + /** * Removes a category assignment from a posterminal (turns it off). * @@ -49,6 +69,16 @@ export default function setOff(provider) { throw new Error('Posterminal not found'); } + await runner.query(addCategoryTombstoneSql, { + posterminalId, + categoryId, + }); + + await runner.query(addArticleTombstonesSql, { + posterminalId, + categoryId, + }); + await runner.query(setOffSql, { posterminalId, categoryId, diff --git a/src/store/posterminal/setOffAll.js b/src/store/posterminal/setOffAll.js index 897a38d..e7719d0 100644 --- a/src/store/posterminal/setOffAll.js +++ b/src/store/posterminal/setOffAll.js @@ -21,6 +21,21 @@ export default function setOffAll(provider) { ? SQLITE_LIST_CATEGORY_IDS : MSSQL_LIST_CATEGORY_IDS; + const addCategoryTombstonesSql = ` + INSERT INTO posterminaltombstone (posterminal_id, entity_type, entity_id) + SELECT posterminal_id, 'category', category_id + FROM posterminalcategory + WHERE posterminal_id = @posterminalId + `; + + const addArticleTombstonesSql = ` + INSERT INTO posterminaltombstone (posterminal_id, entity_type, entity_id) + SELECT DISTINCT pc.posterminal_id, 'article', ca.article_id + FROM posterminalcategory pc + INNER JOIN categoryarticle ca ON ca.category_id = pc.category_id + WHERE pc.posterminal_id = @posterminalId + `; + /** * Removes all category assignments from a posterminal. * @@ -49,6 +64,9 @@ export default function setOffAll(provider) { throw new Error('Posterminal not found'); } + await runner.query(addCategoryTombstonesSql, { posterminalId }); + await runner.query(addArticleTombstonesSql, { posterminalId }); + await runner.query(setOffAllSql, { posterminalId }); const result = await runner.query(listCatIdsSql, { posterminalId }); diff --git a/src/store/posterminal/setOn.js b/src/store/posterminal/setOn.js index e20dfdf..6de2c01 100644 --- a/src/store/posterminal/setOn.js +++ b/src/store/posterminal/setOn.js @@ -20,6 +20,31 @@ export default function setOn(provider) { const listCatIdsSql = isSqlite ? SQLITE_LIST_CATEGORY_IDS : MSSQL_LIST_CATEGORY_IDS; + const touchSql = isSqlite + ? "UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = @categoryId" + : 'UPDATE category SET updated_at = SYSUTCDATETIME() WHERE id = @categoryId'; + const touchArticlesSql = isSqlite + ? `UPDATE article + SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id IN ( + SELECT ca.article_id FROM categoryarticle ca WHERE ca.category_id = @categoryId + )` + : `UPDATE article + SET updated_at = SYSUTCDATETIME() + WHERE id IN ( + SELECT ca.article_id FROM categoryarticle ca WHERE ca.category_id = @categoryId + )`; + + const clearTombstonesSql = ` + DELETE FROM posterminaltombstone + WHERE posterminal_id = @posterminalId + AND ( + (entity_type = 'category' AND entity_id = @categoryId) + OR (entity_type = 'article' AND entity_id IN ( + SELECT ca.article_id FROM categoryarticle ca WHERE ca.category_id = @categoryId + )) + ) + `; /** * Assigns a category to a posterminal (turns it on for that category). @@ -54,6 +79,14 @@ export default function setOn(provider) { categoryId, }); + await runner.query(clearTombstonesSql, { + posterminalId, + categoryId, + }); + + await runner.query(touchSql, { categoryId }); + await runner.query(touchArticlesSql, { categoryId }); + const result = await runner.query(listCatIdsSql, { posterminalId }); return { diff --git a/src/store/posterminal/setOnAll.js b/src/store/posterminal/setOnAll.js index c8eb37f..05f198c 100644 --- a/src/store/posterminal/setOnAll.js +++ b/src/store/posterminal/setOnAll.js @@ -23,6 +23,35 @@ export default function setOnAll(provider) { ? SQLITE_LIST_CATEGORY_IDS : MSSQL_LIST_CATEGORY_IDS; const rootSql = isSqlite ? SQLITE_ROOT : MSSQL_ROOT; + const touchAllSql = isSqlite + ? `WITH RECURSIVE cats AS ( + SELECT id FROM category WHERE id = @rootCategoryId + UNION ALL + SELECT c.id FROM category c INNER JOIN cats ON c.parent_id = cats.id + ) + UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id IN (SELECT id FROM cats)` + : `;WITH cats AS ( + SELECT id FROM category WHERE id = @rootCategoryId + UNION ALL + SELECT c.id FROM category c INNER JOIN cats ON c.parent_id = cats.id + ) + UPDATE category SET updated_at = SYSUTCDATETIME() + WHERE id IN (SELECT id FROM cats)`; + + const touchAllArticlesSql = isSqlite + ? `UPDATE article + SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE company_id = @companyId` + : `UPDATE article + SET updated_at = SYSUTCDATETIME() + WHERE company_id = @companyId`; + + const clearTombstonesSql = ` + DELETE FROM posterminaltombstone + WHERE posterminal_id = @posterminalId + AND entity_type IN ('category', 'article') + `; /** * Assigns all categories of the user's selected company to a posterminal. @@ -65,6 +94,13 @@ export default function setOnAll(provider) { rootCategoryId, }); + await runner.query(clearTombstonesSql, { + posterminalId, + }); + + await runner.query(touchAllSql, { rootCategoryId }); + await runner.query(touchAllArticlesSql, { companyId }); + const result = await runner.query(listCatIdsSql, { posterminalId }); return {