From 69a24192d36fd6fdd3650e79e4959b25cb551032 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 1 Aug 2026 02:15:21 +0200 Subject: [PATCH] u --- README.md | 8 +++ scripts/test-api.js | 50 +++++++++++++++++ src/index.js | 4 ++ src/sql/articlepic.js | 22 ++++++++ src/sql/pic.js | 7 +++ src/store/article/deleteImage.js | 20 +++++++ src/store/article/orderImages.js | 70 ++++++++++++++++++++++++ src/store/article/resolveArticleBySku.js | 25 +++++++++ 8 files changed, 206 insertions(+) create mode 100644 src/store/article/deleteImage.js create mode 100644 src/store/article/orderImages.js create mode 100644 src/store/article/resolveArticleBySku.js diff --git a/README.md b/README.md index 6a87ad3..3429525 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ Backend mapping: | `getBySku` | `(googleAuthId, sku)` | detail object (see below) | | `updateBySku` | `(googleAuthId, sku, { name })` | same shape as `getBySku` | | `addImage` | `(googleAuthId, sku, imageBuffer)` | image result (see below) | +| `deleteImage` | `(googleAuthId, sku, fullHash)` | `void` | +| `orderImages` | `(googleAuthId, sku, { fullHashes })` | `{ fullHashes }` | `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`. @@ -186,6 +188,10 @@ Auth via the article's `categoryarticle` category + `resolveCategoryAccess`. Err `updateBySku` updates `articletext` (company default language) in a transaction (`name` + slugified `slug`), then returns the same object as `getBySku`. Errors: `Article not found`, `Not authorized`, `Name required`. +`deleteImage` removes `articlepic` and the `pic` row for this article (`fullHash`). Errors: `Article not found`, `Image not found`, `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`. + --- ## `pic` @@ -229,6 +235,8 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` | | `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name })` | | `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 })` | | `GET /api/image/:fullHash` | `pic.getImageByHash(googleAuthId, fullHash)` | | `GET /api/thumb/:fullHash` | `pic.getThumbByHash(googleAuthId, fullHash)` | diff --git a/scripts/test-api.js b/scripts/test-api.js index 1511eb3..dbcf51d 100644 --- a/scripts/test-api.js +++ b/scripts/test-api.js @@ -125,6 +125,16 @@ const MENU = { label: 'addImage', hint: 'googleAuthId, sku, avif file path → sizes', }, + { + value: 'article.deleteImage', + label: 'deleteImage', + hint: 'googleAuthId, sku, fullHash', + }, + { + value: 'article.orderImages', + label: 'orderImages', + hint: 'googleAuthId, sku, comma fullHashes', + }, ], }, pic: { @@ -458,6 +468,46 @@ async function runMethod(client, action) { await client.article.addImage(Number(googleAuthId), sku, image), ]; } + case 'article.deleteImage': { + 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; + const fullHash = await askText('fullHash', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (fullHash == null) return; + await client.article.deleteImage( + Number(googleAuthId), + sku, + fullHash.trim(), + ); + return ['ok', true]; + } + case 'article.orderImages': { + 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; + const hashes = await askText('fullHashes (comma-separated)', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (hashes == null) return; + const fullHashes = hashes + .split(',') + .map((h) => h.trim()) + .filter(Boolean); + return [ + 'result', + await client.article.orderImages(Number(googleAuthId), sku, { + fullHashes, + }), + ]; + } case 'pic.getImageByHash': { const googleAuthId = await askInt('google_auth id'); if (googleAuthId == null) return; diff --git a/src/index.js b/src/index.js index c5c6a1d..69fe13b 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,8 @@ 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 articleDeleteImage from './store/article/deleteImage.js'; +import articleOrderImages from './store/article/orderImages.js'; import articleAddImage from './store/article/addImage.js'; import articleCreateUnderParent from './store/article/createUnderParent.js'; import articleGetBySku from './store/article/getBySku.js'; @@ -61,6 +63,8 @@ export default class LibDashStore { getBySku: articleGetBySku(pool), updateBySku: articleUpdateBySku(pool), addImage: articleAddImage(pool), + deleteImage: articleDeleteImage(pool), + orderImages: articleOrderImages(pool), }; this.lang = { diff --git a/src/sql/articlepic.js b/src/sql/articlepic.js index e448cf0..7472fd4 100644 --- a/src/sql/articlepic.js +++ b/src/sql/articlepic.js @@ -8,3 +8,25 @@ export const ADD = ` INSERT INTO articlepic (article_id, pic_id, sort) VALUES (@articleId, @picId, @sort) `; + +export const LIST_HASHES_BY_ARTICLE = ` +SELECT p.hash AS fullHash +FROM articlepic ap +INNER JOIN pic p ON p.id = ap.pic_id +WHERE ap.article_id = @articleId +ORDER BY ap.sort ASC, ap.id ASC +`; + +export const SET_TEMP_SORTS = ` +UPDATE articlepic +SET sort = -id +WHERE article_id = @articleId +`; + +export const UPDATE_SORT_BY_HASH = ` +UPDATE ap +SET sort = @sort +FROM articlepic ap +INNER JOIN pic p ON p.id = ap.pic_id +WHERE ap.article_id = @articleId AND p.hash = @fullHash +`; diff --git a/src/sql/pic.js b/src/sql/pic.js index 1867592..f532334 100644 --- a/src/sql/pic.js +++ b/src/sql/pic.js @@ -27,3 +27,10 @@ SELECT thumbw AS w, thumbh AS h, thumbbinary AS buffer FROM pic WHERE hash = @hash `; + +export const DELETE_BY_ARTICLE_AND_HASH = ` +DELETE p +FROM pic p +INNER JOIN articlepic ap ON ap.pic_id = p.id AND ap.article_id = @articleId +WHERE p.hash = @fullHash +`; diff --git a/src/store/article/deleteImage.js b/src/store/article/deleteImage.js new file mode 100644 index 0000000..5fda6d4 --- /dev/null +++ b/src/store/article/deleteImage.js @@ -0,0 +1,20 @@ +import { DELETE_BY_ARTICLE_AND_HASH } from '../../sql/pic.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; + +export default function deleteImage(pool) { + const resolve = resolveArticleBySku(pool); + + return async (googleAuthId, sku, fullHash) => { + const { articleId } = await resolve(googleAuthId, sku); + + const result = await pool + .request() + .input('articleId', articleId) + .input('fullHash', fullHash) + .query(DELETE_BY_ARTICLE_AND_HASH); + + if (!result.rowsAffected[0]) { + throw new Error('Image not found'); + } + }; +} diff --git a/src/store/article/orderImages.js b/src/store/article/orderImages.js new file mode 100644 index 0000000..7e9fc64 --- /dev/null +++ b/src/store/article/orderImages.js @@ -0,0 +1,70 @@ +import { + LIST_HASHES_BY_ARTICLE, + SET_TEMP_SORTS, + UPDATE_SORT_BY_HASH, +} from '../../sql/articlepic.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; +import request from '../request.js'; + +export default function orderImages(pool) { + const resolve = resolveArticleBySku(pool); + + return async (googleAuthId, sku, { fullHashes }) => { + if (!Array.isArray(fullHashes)) { + throw new Error('Invalid image order'); + } + + const { articleId } = await resolve(googleAuthId, sku); + + const current = await pool + .request() + .input('articleId', articleId) + .query(LIST_HASHES_BY_ARTICLE); + + const existing = new Set(current.recordset.map((r) => r.fullHash)); + + if (fullHashes.length !== existing.size) { + throw new Error('Invalid image order'); + } + + const seen = new Set(); + for (const fullHash of fullHashes) { + if (!existing.has(fullHash) || seen.has(fullHash)) { + throw new Error('Invalid image order'); + } + seen.add(fullHash); + } + + const tx = pool.transaction(); + await tx.begin(); + + try { + await request(pool, tx) + .input('articleId', articleId) + .query(SET_TEMP_SORTS); + + for (let sort = 0; sort < fullHashes.length; sort++) { + const update = await request(pool, tx) + .input('articleId', articleId) + .input('fullHash', fullHashes[sort]) + .input('sort', sort) + .query(UPDATE_SORT_BY_HASH); + + if (!update.rowsAffected[0]) { + throw new Error('Invalid image order'); + } + } + + await tx.commit(); + + return { fullHashes }; + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + }; +} diff --git a/src/store/article/resolveArticleBySku.js b/src/store/article/resolveArticleBySku.js new file mode 100644 index 0000000..4e5bb62 --- /dev/null +++ b/src/store/article/resolveArticleBySku.js @@ -0,0 +1,25 @@ +import { GET_CATEGORY_ID_BY_SKU } from '../../sql/article.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; + +export default function resolveArticleBySku(pool) { + const access = resolveCategoryAccess(pool); + + return async (googleAuthId, sku) => { + const lookup = await pool + .request() + .input('sku', sku) + .query(GET_CATEGORY_ID_BY_SKU); + + const row = lookup.recordset[0]; + if (!row?.categoryId || !row?.articleId) { + throw new Error('Article not found'); + } + + await access(googleAuthId, row.categoryId); + + return { + articleId: row.articleId, + categoryId: row.categoryId, + }; + }; +}