This commit is contained in:
seb
2026-08-01 03:37:45 +02:00
parent 69a24192d3
commit 1756dc7ef0
10 changed files with 179 additions and 13 deletions

View File

@@ -147,8 +147,9 @@ Backend mapping:
| `addImage` | `(googleAuthId, sku, imageBuffer)` | image result (see below) | | `addImage` | `(googleAuthId, sku, imageBuffer)` | image result (see below) |
| `deleteImage` | `(googleAuthId, sku, fullHash)` | `void` | | `deleteImage` | `(googleAuthId, sku, fullHash)` | `void` |
| `orderImages` | `(googleAuthId, sku, { fullHashes })` | `{ fullHashes }` | | `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: `addImage` returns:
@@ -158,7 +159,7 @@ Backend mapping:
sort, sort,
full: { w, h, size }, // AVIF, max 1000×1000 (stored as-is when already AVIF and within bounds) 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) 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 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`. `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 <name> deleted` appended. Errors: `Article not found`, `Article has children`, `Not authorized`.
--- ---
## `pic` ## `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 | | Method | Signature | Returns |
| --- | --- | --- | | --- | --- | --- |
@@ -234,6 +237,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer);
| `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` | | `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` |
| `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` | | `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` |
| `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name })` | | `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)` | | `POST /api/article/:sku/image` | `article.addImage(googleAuthId, sku, imageBuffer)` |
| `DELETE /api/article/:sku/image/:fullHash` | `article.deleteImage(googleAuthId, sku, fullHash)` | | `DELETE /api/article/:sku/image/:fullHash` | `article.deleteImage(googleAuthId, sku, fullHash)` |
| `PUT /api/article/:sku/images/order` | `article.orderImages(googleAuthId, sku, { fullHashes })` | | `PUT /api/article/:sku/images/order` | `article.orderImages(googleAuthId, sku, { fullHashes })` |

View File

@@ -135,6 +135,11 @@ const MENU = {
label: 'orderImages', label: 'orderImages',
hint: 'googleAuthId, sku, comma fullHashes', hint: 'googleAuthId, sku, comma fullHashes',
}, },
{
value: 'article.delete',
label: 'delete',
hint: 'googleAuthId, sku',
},
], ],
}, },
pic: { 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': { case 'pic.getImageByHash': {
const googleAuthId = await askInt('google_auth id'); const googleAuthId = await askInt('google_auth id');
if (googleAuthId == null) return; if (googleAuthId == null) return;
const fullHash = await askText('fullHash (SHA-256 hex)', { const fullHash = await askText('fullHash', {
validate: (v) => (!v?.trim() ? 'Required' : undefined), validate: (v) => (!v?.trim() ? 'Required' : undefined),
}); });
if (fullHash == null) return; if (fullHash == null) return;
@@ -533,7 +548,7 @@ async function runMethod(client, action) {
case 'pic.getThumbByHash': { case 'pic.getThumbByHash': {
const googleAuthId = await askInt('google_auth id'); const googleAuthId = await askInt('google_auth id');
if (googleAuthId == null) return; if (googleAuthId == null) return;
const fullHash = await askText('fullHash (SHA-256 hex)', { const fullHash = await askText('fullHash', {
validate: (v) => (!v?.trim() ? 'Required' : undefined), validate: (v) => (!v?.trim() ? 'Required' : undefined),
}); });
if (fullHash == null) return; if (fullHash == null) return;

View File

@@ -9,6 +9,7 @@ import categoryGetTreeForCompany from './store/category/getTreeForCompany.js';
import categoryGetCategoryArticles from './store/category/getCategoryArticles.js'; import categoryGetCategoryArticles from './store/category/getCategoryArticles.js';
import categoryCreateUnderParent from './store/category/createUnderParent.js'; import categoryCreateUnderParent from './store/category/createUnderParent.js';
import categoryDelete from './store/category/delete.js'; import categoryDelete from './store/category/delete.js';
import articleDelete from './store/article/delete.js';
import articleDeleteImage from './store/article/deleteImage.js'; import articleDeleteImage from './store/article/deleteImage.js';
import articleOrderImages from './store/article/orderImages.js'; import articleOrderImages from './store/article/orderImages.js';
import articleAddImage from './store/article/addImage.js'; import articleAddImage from './store/article/addImage.js';
@@ -65,6 +66,7 @@ export default class LibDashStore {
addImage: articleAddImage(pool), addImage: articleAddImage(pool),
deleteImage: articleDeleteImage(pool), deleteImage: articleDeleteImage(pool),
orderImages: articleOrderImages(pool), orderImages: articleOrderImages(pool),
delete: articleDelete(pool),
}; };
this.lang = { this.lang = {

View File

@@ -92,7 +92,8 @@ BEGIN
thumbhash NVARCHAR(64) NOT NULL, thumbhash NVARCHAR(64) NOT NULL,
source NVARCHAR(MAX) NOT NULL, source NVARCHAR(MAX) NOT NULL,
fullbinary VARBINARY(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 END
`; `;
@@ -440,7 +441,7 @@ BEGIN
CREATE TABLE manifestarticle ( CREATE TABLE manifestarticle (
id INT IDENTITY(1,1) PRIMARY KEY, id INT IDENTITY(1,1) PRIMARY KEY,
manifest_id INT NOT NULL, manifest_id INT NOT NULL,
article_id INT NOT NULL, article_id INT NULL,
amount FLOAT NOT NULL, amount FLOAT NOT NULL,
net FLOAT NOT NULL, net FLOAT NOT NULL,
transactedamount FLOAT NOT NULL, transactedamount FLOAT NOT NULL,
@@ -448,7 +449,7 @@ BEGIN
rebate FLOAT NOT NULL CONSTRAINT DF_manifestarticle_rebate DEFAULT 0, rebate FLOAT NOT NULL CONSTRAINT DF_manifestarticle_rebate DEFAULT 0,
tax_id INT NOT NULL, tax_id INT NOT NULL,
CONSTRAINT FK_manifestarticle_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, 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 CONSTRAINT FK_manifestarticle_tax FOREIGN KEY (tax_id) REFERENCES tax(id) ON DELETE CASCADE
); );
END END

50
src/sql/articleDelete.js Normal file
View File

@@ -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
`;

View File

@@ -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 { ADD as ADD_ARTICLEPIC, GET_NEXT_SORT } from '../../sql/articlepic.js';
import { CREATE as CREATE_PIC } from '../../sql/pic.js'; import { CREATE as CREATE_PIC } from '../../sql/pic.js';
import processAvifImage from '../../util/processAvifImage.js'; import processAvifImage from '../../util/processAvifImage.js';
import newFullHash from '../../util/newFullHash.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import request from '../request.js'; import request from '../request.js';
@@ -32,12 +33,13 @@ export default function addImage(pool) {
.query(GET_NEXT_SORT); .query(GET_NEXT_SORT);
const sort = sortResult.recordset[0].sort; const sort = sortResult.recordset[0].sort;
const fullHash = newFullHash();
const picResult = await request(pool, tx) const picResult = await request(pool, tx)
.input('w', processed.full.w) .input('w', processed.full.w)
.input('h', processed.full.h) .input('h', processed.full.h)
.input('size', processed.full.size) .input('size', processed.full.size)
.input('hash', processed.full.hash) .input('hash', fullHash)
.input('thumbw', processed.thumb.w) .input('thumbw', processed.thumb.w)
.input('thumbh', processed.thumb.h) .input('thumbh', processed.thumb.h)
.input('thumbhash', processed.thumb.hash) .input('thumbhash', processed.thumb.hash)
@@ -68,7 +70,7 @@ export default function addImage(pool) {
w: processed.thumb.w, w: processed.thumb.w,
h: processed.thumb.h, h: processed.thumb.h,
}, },
fullHash: processed.full.hash, fullHash,
thumbHash: processed.thumb.hash, thumbHash: processed.thumb.hash,
}; };
} catch (err) { } catch (err) {

View File

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

View File

@@ -15,11 +15,12 @@ export default function resolveArticleBySku(pool) {
throw new Error('Article not found'); throw new Error('Article not found');
} }
await access(googleAuthId, row.categoryId); const { languageId } = await access(googleAuthId, row.categoryId);
return { return {
articleId: row.articleId, articleId: row.articleId,
categoryId: row.categoryId, categoryId: row.categoryId,
languageId,
}; };
}; };
} }

5
src/util/newFullHash.js Normal file
View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'node:crypto';
export default function newFullHash() {
return randomBytes(32).toString('hex');
}

View File

@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto';
import sharp from 'sharp'; import sharp from 'sharp';
import { rgbaToThumbHash } from 'thumbhash'; import { rgbaToThumbHash } from 'thumbhash';
@@ -59,7 +58,6 @@ export default async function processAvifImage(inputBuffer) {
h: full.info.height, h: full.info.height,
size: full.data.length, size: full.data.length,
buffer: full.data, buffer: full.data,
hash: createHash('sha256').update(full.data).digest('hex'),
}, },
thumb: { thumb: {
w: thumb.info.width, w: thumb.info.width,