This commit is contained in:
seb
2026-08-01 02:15:21 +02:00
parent 59c874144b
commit 69a24192d3
8 changed files with 206 additions and 0 deletions

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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