This commit is contained in:
seb
2026-09-30 22:37:34 +02:00
parent 71c92e0718
commit 120e59631e
16 changed files with 137 additions and 35 deletions

View File

@@ -13,7 +13,7 @@ Overview of all 136 API endpoints across all modules.
| Method | Route | Params | Return Type | Summary |
|:---|:---|:---|:---|:---|
| `POST` | `/article` | `body: { categoryId, name, track_stock? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Create article in category |
| `DELETE` | `/article/{sku}` | `sku:string` | `204 void` | Delete article by SKU |
| `DELETE` | `/article/{sku}` | `sku:string, query: cascade?:boolean` | `204 void` | Delete article by SKU |
| `GET` | `/article/{sku}` | `sku:string` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Get article by SKU |
| `PATCH` | `/article/{sku}` | `sku:string, body: { name?, unitamount?, unitSi?, track_stock? }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Update article by SKU |
| `GET` | `/article/{sku}/articlelist` | `sku:string` | `200 { items: { id, articleId, sku, name, count, sort }[] }` | Get Stückliste (bundle items) for an article |

View File

@@ -17,7 +17,7 @@ Unlinks the child from the father (or deletes if deleteArticle query/body is tru
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `deleteArticle` | no | boolean | If true, permanently deletes the child article instead of just unlinking |
| `deleteArticle` | no | boolean | If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a father) instead of just unlinking |
## Response `200`

View File

@@ -15,6 +15,12 @@ Auth like GET by SKU.
|-------|----------|------|-------------|
| `sku` | yes | string | Article SKU |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `cascade` | no | boolean | If true, recursively deletes all descendant child articles in the subtree |
## Signals
On success, emits SSE signal: `articles` (see [`GET /events`](../events.md)).

View File

@@ -75,6 +75,7 @@ Updated article detail
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |
| 409 | EAN already in use — `{"error":"…"}` |
---

View File

@@ -83,7 +83,7 @@ Updated article detail
| Status | Description |
|--------|-------------|
| 400 | Invalid prices or POS customer group required — `{"error":"…"}` |
| 400 | Invalid prices, POS customer group required, or father article cannot have prices — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |

View File

@@ -77,7 +77,7 @@ Stock adjusted successfully
| Status | Description |
|--------|-------------|
| 400 | Missing fields, invalid amount, or no company selected — `{"error":"…"}` |
| 400 | Missing fields, invalid amount, father article cannot have stock, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article or warehouse not found — `{"error":"…"}` |

View File

@@ -64,8 +64,12 @@ function articleErrorStatus(error) {
if (error.message === 'Invalid unitamount') return 400;
if (error.message === 'Unit not found') return 404;
if (error.message === 'Invalid eans') return 400;
if (error.message === 'EAN already in use' || error.message === 'EAN already assigned') return 409;
if (error.message?.includes('UNIQUE constraint') || error.message?.includes('duplicate key') || error.message?.includes('Unique constraint')) return 409;
if (error.message === 'Company not found') return 404;
if (error.message === 'Brand not found') return 404;
if (error.message === 'Invalid brandId') return 400;
if (error.message === 'Invalid attributeValueId') return 400;
if (error.message === 'Invalid weight') return 400;
if (error.message === 'Invalid track_stock') return 400;
if (error.message === 'Invalid categoryIds') return 400;
@@ -73,6 +77,7 @@ function articleErrorStatus(error) {
if (error.message === 'attributeNameId is required to become a father article') return 400;
if (error.message?.includes('already used by an ancestor father')) return 400;
if (error.message?.includes('cannot have attribute values for its variant attribute')) return 400;
if (error.message?.includes('A father article cannot have') || error.message?.includes('father article cannot have')) return 400;
if (error.message === 'Cannot disable father mode on an article that still has children') return 400;
if (error.message === 'Article is not a father article') return 400;
if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400;
@@ -186,10 +191,14 @@ export function register(app, basePath = '') {
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/article`, async (req, res) => {
const parent = parsePositiveInt(req.body?.categoryId ?? req.body?.parentId, 'categoryId');
if (parent.error) {
return res.status(400).json({ error: parent.error });
const rawParent = req.body?.categoryId ?? req.body?.parentId;
if (rawParent === undefined || rawParent === null) {
return res.status(400).json({ error: 'categoryId is required' });
}
if (typeof rawParent !== 'number' || !Number.isInteger(rawParent) || rawParent <= 0) {
return res.status(400).json({ error: 'categoryId must be an integer' });
}
const parentId = rawParent;
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name) {
@@ -205,7 +214,7 @@ export function register(app, basePath = '') {
try {
const created = await db.createUnderParent({
googleAuthId: req.user.id,
parentId: parent.id,
parentId,
name,
track_stock,
});
@@ -467,6 +476,11 @@ export function register(app, basePath = '') {
* schema:
* type: string
* description: Article SKU
* - in: query
* name: cascade
* schema:
* type: boolean
* description: If true, recursively deletes all descendant child articles in the subtree
* responses:
* 204:
* description: Deleted
@@ -638,8 +652,10 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: parsed.error });
}
const cascade = req.query.cascade === 'true';
try {
await db.remove(req.user.id, parsed.sku);
await db.remove(req.user.id, parsed.sku, { cascade });
emitArticlesSignal(req);
res.sendStatus(204);
} catch (error) {
@@ -710,7 +726,7 @@ export function register(app, basePath = '') {
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Invalid prices or POS customer group required
* description: Invalid prices, POS customer group required, or father article cannot have prices
* content:
* application/json:
* schema:
@@ -1094,6 +1110,12 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: EAN already in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.put(`${basePath}/article/:sku/eans`, async (req, res) => {
const skuParsed = parseSku(req.params.sku);
@@ -1190,6 +1212,9 @@ export function register(app, basePath = '') {
}
const brandId = req.body?.brandId ?? null;
if (brandId !== null && (typeof brandId !== 'number' || !Number.isInteger(brandId) || brandId <= 0)) {
return res.status(400).json({ error: 'Invalid brandId' });
}
try {
const article = await db.setBrandIdBySku(req.user.id, skuParsed.sku, brandId);
@@ -1602,16 +1627,32 @@ export function register(app, basePath = '') {
if (skuError) return res.status(400).json({ error: skuError });
const sku = parsedSku;
const { templateId, printerId, quantity } = req.body;
const parsedTemplateId = parsePositiveInt(templateId, 'templateId');
if (parsedTemplateId.error) return res.status(400).json({ error: parsedTemplateId.error });
const parsedPrinterId = parsePositiveInt(printerId, 'printerId');
if (parsedPrinterId.error) return res.status(400).json({ error: parsedPrinterId.error });
const parsedQuantity = parsePositiveInt(quantity, 'quantity');
if (parsedQuantity.error) return res.status(400).json({ error: parsedQuantity.error });
const { templateId, printerId, quantity } = req.body ?? {};
if (templateId === undefined || templateId === null) {
return res.status(400).json({ error: 'templateId is required' });
}
if (typeof templateId !== 'number' || !Number.isInteger(templateId) || templateId <= 0) {
return res.status(400).json({ error: 'templateId must be an integer' });
}
if (printerId === undefined || printerId === null) {
return res.status(400).json({ error: 'printerId is required' });
}
if (typeof printerId !== 'number' || !Number.isInteger(printerId) || printerId <= 0) {
return res.status(400).json({ error: 'printerId must be an integer' });
}
if (quantity === undefined || quantity === null) {
return res.status(400).json({ error: 'quantity is required' });
}
if (typeof quantity !== 'number' || !Number.isInteger(quantity) || quantity <= 0) {
return res.status(400).json({ error: 'quantity must be an integer' });
}
const parsedPrinterId = { id: printerId };
const parsedTemplateId = { id: templateId };
const parsedQuantity = { id: quantity };
try {
const printer = await printerDb.getById(req.user.id, parsedPrinterId.id);
@@ -1907,7 +1948,7 @@ export function register(app, basePath = '') {
* name: deleteArticle
* schema:
* type: boolean
* description: If true, permanently deletes the child article instead of just unlinking
* description: If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a father) instead of just unlinking
* responses:
* 200:
* description: Child removed

View File

@@ -35,6 +35,7 @@ function articleSupplierErrorStatus(error) {
if (error.message === 'Company not found') return 404;
if (error.message === 'Article not found') return 404;
if (error.message === 'Supplier not found') return 404;
if (error.message === 'Invalid supplierId') return 400;
if (error.message === 'Supplier assignment not found') return 404;
if (error.message === 'Supplier already assigned') return 409;
if (error.message === 'Supplier required' || error.message === 'entityId is required') return 400;
@@ -245,21 +246,30 @@ export function register(app, basePath = '') {
}
const { entityId, sku: supplierSku, pu, stock, sort, netPrice, taxId, taxRate } = req.body ?? {};
if (entityId == null || !Number.isFinite(entityId)) {
if (entityId == null) {
return res.status(400).json({ error: 'entityId is required' });
}
if (pu !== undefined && pu !== null && (!Number.isFinite(pu) || pu <= 0)) {
if (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0) {
return res.status(400).json({ error: 'entityId must be an integer' });
}
if (pu !== undefined && pu !== null && (typeof pu !== 'number' || !Number.isFinite(pu) || pu <= 0)) {
return res.status(400).json({ error: 'pu must be greater than 0' });
}
if (stock !== undefined && stock !== null && !Number.isFinite(stock)) {
if (stock !== undefined && stock !== null && (typeof stock !== 'number' || !Number.isFinite(stock))) {
return res.status(400).json({ error: 'Invalid stock' });
}
if (sort !== undefined && sort !== null && (!Number.isInteger(sort) || sort < 0)) {
if (sort !== undefined && sort !== null && (typeof sort !== 'number' || !Number.isInteger(sort) || sort < 0)) {
return res.status(400).json({ error: 'Invalid sort' });
}
if (netPrice !== undefined && netPrice !== null && !Number.isFinite(netPrice)) {
if (netPrice !== undefined && netPrice !== null && (typeof netPrice !== 'number' || !Number.isFinite(netPrice) || netPrice < 0)) {
return res.status(400).json({ error: 'Invalid price' });
}
if (taxId !== undefined && taxId !== null && (typeof taxId !== 'number' || !Number.isInteger(taxId) || taxId <= 0)) {
return res.status(400).json({ error: 'Invalid taxId' });
}
if (taxRate !== undefined && taxRate !== null && (typeof taxRate !== 'number' || !Number.isFinite(taxRate) || taxRate < 0)) {
return res.status(400).json({ error: 'Invalid taxRate' });
}
try {
const supplier = await db.create(req.user.id, skuParsed.sku, {
@@ -465,8 +475,18 @@ export function register(app, basePath = '') {
}
patch.netPrice = body.netPrice;
}
if (body.taxId !== undefined) patch.taxId = body.taxId;
if (body.taxRate !== undefined) patch.taxRate = body.taxRate;
if (body.taxId !== undefined) {
if (body.taxId !== null && (typeof body.taxId !== 'number' || !Number.isInteger(body.taxId) || body.taxId <= 0)) {
return res.status(400).json({ error: 'Invalid taxId' });
}
patch.taxId = body.taxId;
}
if (body.taxRate !== undefined) {
if (body.taxRate !== null && (typeof body.taxRate !== 'number' || !Number.isFinite(body.taxRate) || body.taxRate < 0)) {
return res.status(400).json({ error: 'Invalid taxRate' });
}
patch.taxRate = body.taxRate;
}
try {
const supplier = await db.update(req.user.id, skuParsed.sku, idParsed.id, patch);

View File

@@ -261,10 +261,14 @@ export function register(app, basePath = '') {
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/category`, async (req, res) => {
const parent = parsePositiveInt(req.body?.parentId, 'parentId');
if (parent.error) {
return res.status(400).json({ error: parent.error });
const rawParent = req.body?.parentId;
if (rawParent === undefined || rawParent === null) {
return res.status(400).json({ error: 'parentId is required' });
}
if (typeof rawParent !== 'number' || !Number.isInteger(rawParent) || rawParent <= 0) {
return res.status(400).json({ error: 'parentId must be an integer' });
}
const parentId = rawParent;
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name) {
@@ -274,7 +278,7 @@ export function register(app, basePath = '') {
try {
const category = await db.createUnderParent({
googleAuthId: req.user.id,
parentId: parent.id,
parentId,
name,
});
emitCategorySignal(req);

View File

@@ -15,6 +15,12 @@ function parseCompanyBody(body) {
if (!iso639 || !name || taxrate == null || reducedtaxrate == null) {
return { error: 'iso639, name, taxrate, and reducedtaxrate are required' };
}
if (typeof taxrate !== 'number' || !Number.isFinite(taxrate) || taxrate < 0) {
return { error: 'taxrate must be a non-negative number' };
}
if (typeof reducedtaxrate !== 'number' || !Number.isFinite(reducedtaxrate) || reducedtaxrate < 0) {
return { error: 'reducedtaxrate must be a non-negative number' };
}
return { iso639, name, taxrate, reducedtaxrate };
}
@@ -29,6 +35,7 @@ function parseCompanyId(id) {
function companyErrorStatus(error) {
if (error.message === 'Company not found') return 404;
if (error.message === 'Company name not available') return 409;
if (error.message === 'Invalid tax rate') return 400;
return 500;
}

View File

@@ -48,6 +48,9 @@ function parseDeliveryPayload(req) {
}
const { externalNumber, date, createdAt, supplierId, lines } = payload;
if (supplierId !== undefined && supplierId !== null && (typeof supplierId !== 'number' || !Number.isInteger(supplierId) || supplierId <= 0)) {
throw new Error('Invalid supplierId');
}
let files = payload.files;
// If binary files were uploaded via multer:
if (Array.isArray(req.files) && req.files.length > 0) {
@@ -161,6 +164,7 @@ function deliveryErrorStatus(error) {
if (error.message === 'Attachment not found') return 404;
if (error.message === 'File not found') return 404;
if (error.message === 'Supplier not found') return 404;
if (error.message === 'Invalid supplierId') return 400;
if (error.message === 'Article not found') return 404;
if (error.message === 'Invalid delivery') return 400;
if (error.message === 'Invalid lines') return 400;

View File

@@ -17,6 +17,7 @@ function entityErrorStatus(error) {
if (error.message === 'Name required') return 400;
if (error.message === 'Number required') return 400;
if (error.message === 'Number already in use') return 409;
if (error.message === 'Invalid customergroupId') return 400;
if (error.message === 'Invalid role') return 400;
return 500;
}
@@ -196,6 +197,9 @@ export function register(app, basePath = '') {
}
const body = req.body ?? {};
if (body.customergroupId !== undefined && body.customergroupId !== null && (typeof body.customergroupId !== 'number' || !Number.isInteger(body.customergroupId) || body.customergroupId <= 0)) {
return res.status(400).json({ error: 'Invalid customergroupId' });
}
const data = {
customergroupId: body.customergroupId,
name: body.name,

View File

@@ -15,6 +15,7 @@ function orderErrorStatus(error) {
if (error.message === 'Order not found') return 404;
if (error.message === 'Entity not found') return 404;
if (error.message === 'Article not found') return 404;
if (error.message === 'Invalid entityId') return 400;
if (error.message === 'Invalid order') return 400;
if (error.message === 'Invalid lines') return 400;
return 500;
@@ -176,6 +177,9 @@ export function register(app, basePath = '') {
}
const { number, date, createdAt, entityId, lines } = req.body ?? {};
if (entityId !== undefined && entityId !== null && (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0)) {
return res.status(400).json({ error: 'Invalid entityId' });
}
try {
const order = await db.create(req.user.id, {
@@ -401,6 +405,9 @@ export function register(app, basePath = '') {
patch.createdAt = body.createdAt;
}
if (Object.prototype.hasOwnProperty.call(body, 'entityId')) {
if (body.entityId !== null && (typeof body.entityId !== 'number' || !Number.isInteger(body.entityId) || body.entityId <= 0)) {
return res.status(400).json({ error: 'Invalid entityId' });
}
patch.entityId = body.entityId;
}
if (Object.prototype.hasOwnProperty.call(body, 'lines')) {

View File

@@ -15,6 +15,7 @@ function quoteErrorStatus(error) {
if (error.message === 'Quote not found') return 404;
if (error.message === 'Entity not found') return 404;
if (error.message === 'Article not found') return 404;
if (error.message === 'Invalid entityId') return 400;
if (error.message === 'Invalid quote') return 400;
if (error.message === 'Invalid lines') return 400;
return 500;
@@ -176,6 +177,9 @@ export function register(app, basePath = '') {
}
const { number, date, createdAt, entityId, lines } = req.body ?? {};
if (entityId !== undefined && entityId !== null && (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0)) {
return res.status(400).json({ error: 'Invalid entityId' });
}
try {
const quote = await db.create(req.user.id, {
@@ -401,6 +405,9 @@ export function register(app, basePath = '') {
patch.createdAt = body.createdAt;
}
if (Object.prototype.hasOwnProperty.call(body, 'entityId')) {
if (body.entityId !== null && (typeof body.entityId !== 'number' || !Number.isInteger(body.entityId) || body.entityId <= 0)) {
return res.status(400).json({ error: 'Invalid entityId' });
}
patch.entityId = body.entityId;
}
if (Object.prototype.hasOwnProperty.call(body, 'lines')) {

View File

@@ -20,6 +20,7 @@ function stockErrorStatus(error) {
if (error.message?.includes('Amount must be')) return 400;
if (error.message?.includes('Invalid amount')) return 400;
if (error.message === 'No items to adjust') return 400;
if (error.message?.includes('cannot have stock') || error.message?.includes('A father article cannot have') || error.message?.includes('father article')) return 400;
return 500;
}
@@ -84,7 +85,7 @@ export function register(app, basePath = '') {
* schema:
* $ref: '#/components/schemas/StockAdjustmentResult'
* 400:
* description: Missing fields, invalid amount, or no company selected
* description: Missing fields, invalid amount, father article cannot have stock, or no company selected
* content:
* application/json:
* schema:

View File

@@ -35,9 +35,9 @@ export async function orderImages(googleAuthId, sku, data) {
return store.article.orderImages(googleAuthId, sku, data);
}
export async function remove(googleAuthId, sku) {
export async function remove(googleAuthId, sku, options) {
const store = await getStore();
return store.article.delete(googleAuthId, sku);
return store.article.delete(googleAuthId, sku, options);
}
export async function setPricesBySku(googleAuthId, sku, prices) {