From 1756dc7ef099179d0c49b7d6cfe6cb3f1445513c Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 1 Aug 2026 03:37:45 +0200 Subject: [PATCH] u --- README.md | 10 ++- scripts/test-api.js | 19 ++++- src/index.js | 2 + src/schema.js | 7 +- src/sql/articleDelete.js | 50 ++++++++++++++ src/store/article/addImage.js | 6 +- src/store/article/delete.js | 88 ++++++++++++++++++++++++ src/store/article/resolveArticleBySku.js | 3 +- src/util/newFullHash.js | 5 ++ src/util/processAvifImage.js | 2 - 10 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 src/sql/articleDelete.js create mode 100644 src/store/article/delete.js create mode 100644 src/util/newFullHash.js diff --git a/README.md b/README.md index 3429525..db07c9f 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,9 @@ Backend mapping: | `addImage` | `(googleAuthId, sku, imageBuffer)` | image result (see below) | | `deleteImage` | `(googleAuthId, sku, fullHash)` | `void` | | `orderImages` | `(googleAuthId, sku, { fullHashes })` | `{ fullHashes }` | +| `delete` | `(googleAuthId, sku)` | `void` | -`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Computes `hash` / `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. +`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Assigns random `fullHash` (`pic.hash`, unique), computes `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. `addImage` returns: @@ -158,7 +159,7 @@ Backend mapping: sort, full: { w, h, size }, // AVIF, max 1000×1000 (stored as-is when already AVIF and within bounds) thumb: { w, h }, // scaled AVIF, max 200×200 (fit inside) - fullHash, // SHA-256 hex — use for GET /api/image/:fullHash and GET /api/thumb/:fullHash + fullHash, // random 64-char hex id (pic.hash, unique) thumbHash, // base64 thumbhash of thumb preview } ``` @@ -192,11 +193,13 @@ Auth via the article's `categoryarticle` category + `resolveCategoryAccess`. Err `orderImages` sets `articlepic.sort` to `0..n-1` in the given order. `fullHashes` must list every image of the article exactly once. Errors: `Article not found`, `Invalid image order`, `Not authorized`. +`delete` removes the article when it has no child articles (`fatherarticle_id` FK is `ON DELETE NO ACTION`). Cascades: `articlehistory`, `articleean`, `categoryarticle`, `price`, `articletext`, `articlelistarticle`, `optionslistarticle`, `articlepic`; deletes linked `pic` rows and owned `articlelist` / `optionslist`. `manifestarticle.article_id` is set to `NULL` and `comment` gets `article deleted` appended. Errors: `Article not found`, `Article has children`, `Not authorized`. + --- ## `pic` -Lookup by `pic.hash` (`fullHash` — SHA-256 hex from `addImage.fullHash`), not `thumbhash`. +Lookup by `pic.hash` (`fullHash` from `addImage.fullHash`), not `thumbhash`. Random 64-char hex (`crypto.randomBytes(32)`), unique in DB. | Method | Signature | Returns | | --- | --- | --- | @@ -234,6 +237,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` | | `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` | | `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name })` | +| `DELETE /api/article/:sku` | `article.delete(googleAuthId, sku)` | | `POST /api/article/:sku/image` | `article.addImage(googleAuthId, sku, imageBuffer)` | | `DELETE /api/article/:sku/image/:fullHash` | `article.deleteImage(googleAuthId, sku, fullHash)` | | `PUT /api/article/:sku/images/order` | `article.orderImages(googleAuthId, sku, { fullHashes })` | diff --git a/scripts/test-api.js b/scripts/test-api.js index dbcf51d..1f2b35c 100644 --- a/scripts/test-api.js +++ b/scripts/test-api.js @@ -135,6 +135,11 @@ const MENU = { label: 'orderImages', hint: 'googleAuthId, sku, comma fullHashes', }, + { + value: 'article.delete', + label: 'delete', + hint: 'googleAuthId, sku', + }, ], }, pic: { @@ -508,10 +513,20 @@ async function runMethod(client, action) { }), ]; } + case 'article.delete': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const sku = await askText('sku', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (sku == null) return; + await client.article.delete(Number(googleAuthId), sku); + return ['ok', true]; + } case 'pic.getImageByHash': { const googleAuthId = await askInt('google_auth id'); if (googleAuthId == null) return; - const fullHash = await askText('fullHash (SHA-256 hex)', { + const fullHash = await askText('fullHash', { validate: (v) => (!v?.trim() ? 'Required' : undefined), }); if (fullHash == null) return; @@ -533,7 +548,7 @@ async function runMethod(client, action) { case 'pic.getThumbByHash': { const googleAuthId = await askInt('google_auth id'); if (googleAuthId == null) return; - const fullHash = await askText('fullHash (SHA-256 hex)', { + const fullHash = await askText('fullHash', { validate: (v) => (!v?.trim() ? 'Required' : undefined), }); if (fullHash == null) return; diff --git a/src/index.js b/src/index.js index 69fe13b..fc3a630 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import categoryGetTreeForCompany from './store/category/getTreeForCompany.js'; import categoryGetCategoryArticles from './store/category/getCategoryArticles.js'; import categoryCreateUnderParent from './store/category/createUnderParent.js'; import categoryDelete from './store/category/delete.js'; +import articleDelete from './store/article/delete.js'; import articleDeleteImage from './store/article/deleteImage.js'; import articleOrderImages from './store/article/orderImages.js'; import articleAddImage from './store/article/addImage.js'; @@ -65,6 +66,7 @@ export default class LibDashStore { addImage: articleAddImage(pool), deleteImage: articleDeleteImage(pool), orderImages: articleOrderImages(pool), + delete: articleDelete(pool), }; this.lang = { diff --git a/src/schema.js b/src/schema.js index 29ecb8f..2fea415 100644 --- a/src/schema.js +++ b/src/schema.js @@ -92,7 +92,8 @@ BEGIN thumbhash NVARCHAR(64) NOT NULL, source NVARCHAR(MAX) NOT NULL, fullbinary VARBINARY(MAX) NOT NULL, - thumbbinary VARBINARY(MAX) NOT NULL + thumbbinary VARBINARY(MAX) NOT NULL, + CONSTRAINT UQ_pic_hash UNIQUE (hash) ); END `; @@ -440,7 +441,7 @@ BEGIN CREATE TABLE manifestarticle ( id INT IDENTITY(1,1) PRIMARY KEY, manifest_id INT NOT NULL, - article_id INT NOT NULL, + article_id INT NULL, amount FLOAT NOT NULL, net FLOAT NOT NULL, transactedamount FLOAT NOT NULL, @@ -448,7 +449,7 @@ BEGIN rebate FLOAT NOT NULL CONSTRAINT DF_manifestarticle_rebate DEFAULT 0, tax_id INT NOT NULL, CONSTRAINT FK_manifestarticle_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, - CONSTRAINT FK_manifestarticle_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE, + CONSTRAINT FK_manifestarticle_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE SET NULL, CONSTRAINT FK_manifestarticle_tax FOREIGN KEY (tax_id) REFERENCES tax(id) ON DELETE CASCADE ); END diff --git a/src/sql/articleDelete.js b/src/sql/articleDelete.js new file mode 100644 index 0000000..785f277 --- /dev/null +++ b/src/sql/articleDelete.js @@ -0,0 +1,50 @@ +export const GET_CHILD_COUNT = ` +SELECT COUNT(*) AS count +FROM article +WHERE fatherarticle_id = @articleId +`; + +export const GET_LIST_IDS = ` +SELECT articlelist_id AS articlelistId, optionslist_id AS optionslistId +FROM article +WHERE id = @articleId +`; + +export const GET_NAME_FOR_DELETE = ` +SELECT TOP 1 at.name +FROM articletext at +WHERE at.article_id = @articleId AND at.language_id = @languageId +`; + +export const DETACH_MANIFEST_ARTICLES = ` +UPDATE manifestarticle +SET article_id = NULL, + comment = CONCAT( + ISNULL(comment, ''), + CASE WHEN comment IS NULL OR comment = '' THEN '' ELSE ' ' END, + @suffix + ) +WHERE article_id = @articleId +`; + +export const DELETE_PICS_BY_ARTICLE = ` +DELETE p +FROM pic p +INNER JOIN articlepic ap ON ap.pic_id = p.id +WHERE ap.article_id = @articleId +`; + +export const DELETE_BY_ID = ` +DELETE FROM article +WHERE id = @articleId +`; + +export const DELETE_ARTICLELIST = ` +DELETE FROM articlelist +WHERE id = @id +`; + +export const DELETE_OPTIONSLIST = ` +DELETE FROM optionslist +WHERE id = @id +`; diff --git a/src/store/article/addImage.js b/src/store/article/addImage.js index c95ea9e..90054fa 100644 --- a/src/store/article/addImage.js +++ b/src/store/article/addImage.js @@ -2,6 +2,7 @@ import { GET_CATEGORY_ID_BY_SKU } from '../../sql/article.js'; import { ADD as ADD_ARTICLEPIC, GET_NEXT_SORT } from '../../sql/articlepic.js'; import { CREATE as CREATE_PIC } from '../../sql/pic.js'; import processAvifImage from '../../util/processAvifImage.js'; +import newFullHash from '../../util/newFullHash.js'; import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; import request from '../request.js'; @@ -32,12 +33,13 @@ export default function addImage(pool) { .query(GET_NEXT_SORT); const sort = sortResult.recordset[0].sort; + const fullHash = newFullHash(); const picResult = await request(pool, tx) .input('w', processed.full.w) .input('h', processed.full.h) .input('size', processed.full.size) - .input('hash', processed.full.hash) + .input('hash', fullHash) .input('thumbw', processed.thumb.w) .input('thumbh', processed.thumb.h) .input('thumbhash', processed.thumb.hash) @@ -68,7 +70,7 @@ export default function addImage(pool) { w: processed.thumb.w, h: processed.thumb.h, }, - fullHash: processed.full.hash, + fullHash, thumbHash: processed.thumb.hash, }; } catch (err) { diff --git a/src/store/article/delete.js b/src/store/article/delete.js new file mode 100644 index 0000000..57004dc --- /dev/null +++ b/src/store/article/delete.js @@ -0,0 +1,88 @@ +import { + DELETE_ARTICLELIST, + DELETE_BY_ID, + DELETE_OPTIONSLIST, + DELETE_PICS_BY_ARTICLE, + DETACH_MANIFEST_ARTICLES, + GET_CHILD_COUNT, + GET_LIST_IDS, + GET_NAME_FOR_DELETE, +} from '../../sql/articleDelete.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; +import request from '../request.js'; + +export default function del(pool) { + const resolve = resolveArticleBySku(pool); + + return async (googleAuthId, sku) => { + const { articleId, languageId } = await resolve(googleAuthId, sku); + + const childCount = await pool + .request() + .input('articleId', articleId) + .query(GET_CHILD_COUNT); + + if (childCount.recordset[0].count > 0) { + throw new Error('Article has children'); + } + + const nameResult = await pool + .request() + .input('articleId', articleId) + .input('languageId', languageId) + .query(GET_NAME_FOR_DELETE); + + const name = nameResult.recordset[0]?.name ?? sku; + const suffix = `article ${name} deleted`; + + const listIds = await pool + .request() + .input('articleId', articleId) + .query(GET_LIST_IDS); + + const { articlelistId, optionslistId } = listIds.recordset[0] ?? {}; + + const tx = pool.transaction(); + await tx.begin(); + + try { + await request(pool, tx) + .input('articleId', articleId) + .input('suffix', suffix) + .query(DETACH_MANIFEST_ARTICLES); + + 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'); + } + + if (articlelistId != null) { + await request(pool, tx) + .input('id', articlelistId) + .query(DELETE_ARTICLELIST); + } + + if (optionslistId != null) { + await request(pool, tx) + .input('id', optionslistId) + .query(DELETE_OPTIONSLIST); + } + + await tx.commit(); + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + }; +} diff --git a/src/store/article/resolveArticleBySku.js b/src/store/article/resolveArticleBySku.js index 4e5bb62..48393d9 100644 --- a/src/store/article/resolveArticleBySku.js +++ b/src/store/article/resolveArticleBySku.js @@ -15,11 +15,12 @@ export default function resolveArticleBySku(pool) { throw new Error('Article not found'); } - await access(googleAuthId, row.categoryId); + const { languageId } = await access(googleAuthId, row.categoryId); return { articleId: row.articleId, categoryId: row.categoryId, + languageId, }; }; } diff --git a/src/util/newFullHash.js b/src/util/newFullHash.js new file mode 100644 index 0000000..bde7ffd --- /dev/null +++ b/src/util/newFullHash.js @@ -0,0 +1,5 @@ +import { randomBytes } from 'node:crypto'; + +export default function newFullHash() { + return randomBytes(32).toString('hex'); +} diff --git a/src/util/processAvifImage.js b/src/util/processAvifImage.js index 1b9462f..e66d29c 100644 --- a/src/util/processAvifImage.js +++ b/src/util/processAvifImage.js @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import sharp from 'sharp'; import { rgbaToThumbHash } from 'thumbhash'; @@ -59,7 +58,6 @@ export default async function processAvifImage(inputBuffer) { h: full.info.height, size: full.data.length, buffer: full.data, - hash: createHash('sha256').update(full.data).digest('hex'), }, thumb: { w: thumb.info.width,