This commit is contained in:
seb
2026-10-10 02:08:10 +02:00
parent d4e2b05cb0
commit 7f8661be3e
43 changed files with 3312 additions and 2339 deletions

View File

@@ -0,0 +1,651 @@
import * as db from '../../../db/article.js';
import {
emitArticlesSignal,
emitArticleSignal,
parseSku,
articleErrorStatus,
} from './shared.js';
/**
* Registers article variant routes — parent mode, children, Stückliste
* (articlelist) and options (requires auth middleware).
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
export function register(app, basePath = '') {
/**
* @openapi
* /article/{sku}/parent:
* post:
* summary: Toggle parent mode for an article
* description: |
* Enables or disables parent article status. Enabling requires specifying an
* attributeNameId; disabling requires the article to have zero children.
*
* `attributeNameId` is **conditionally required**: it is optional in the schema
* because it is only required when `isParent` is `true`. Omitting it (or passing
* `0`/a negative value) together with `isParent: true` returns `400`
* `attributeNameId is required to become a parent article`. It must not be sent
* when `isParent` is `false`.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [isParent]
* properties:
* isParent:
* type: boolean
* description: True to make article a parent, false to revert to regular article
* attributeNameId:
* type: integer
* description: |
* **Conditionally required when `isParent` is `true`** (required iff isParent=true).
* The attributename id used for the article's variants. Omitting it while
* enabling parent mode returns 400. Must be omitted when `isParent` is `false`.
* responses:
* 200:
* description: Updated article
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/Article'
* 400:
* description: |
* Invalid input — `attributeNameId` missing while `isParent` is true, article still
* has children (disabling), or the attribute is already used by an ancestor parent.
* 409:
* description: |
* Article state conflicts with becoming a variant parent — it still has stock,
* assigned suppliers, a Stückliste, or options. Clear these first.
* 404:
* description: Article or attribute name not found
*/
// #region POST /article/:sku/parent
app.post(`${basePath}/article/:sku/parent`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const isParent = req.body?.isParent;
if (typeof isParent !== 'boolean') {
return res.status(400).json({ error: 'isParent boolean is required' });
}
const attributeNameId = req.body?.attributeNameId;
try {
const result = await db.toggleParent(req.user.id, parsed.sku, {
isParent,
attributeNameId,
});
emitArticleSignal(req, parsed.sku);
emitArticlesSignal(req);
res.status(200).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Toggle parent failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}/children:
* get:
* summary: List child articles of a parent article
* description: Returns variants/children linked to this parent article.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Parent article SKU
* responses:
* 200:
* description: List of children
* content:
* application/json:
* schema:
* type: object
* properties:
* children:
* type: array
* items:
* $ref: '#/components/schemas/ArticleChild'
* 404:
* description: Article not found
*/
// #region GET /article/:sku/children
app.get(`${basePath}/article/:sku/children`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
try {
const children = await db.listChildren(req.user.id, parsed.sku);
res.status(200).json({ children });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('List children failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}/children:
* post:
* summary: Create and attach a new child article to a parent article
* description: Automatically generates SKU as parentSku-nextFreeIndex and name from parent name + value. Rejects a caller-supplied sku.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Parent article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* attributeValueId:
* type: integer
* description: ID of existing attribute value for the parent's attribute
* value:
* type: string
* description: String value to find or create under the parent's attribute
* name:
* type: string
* description: Optional custom variant name
* responses:
* 201:
* description: Created child article
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/Article'
* 400:
* description: Parent not configured as parent, missing value, duplicate variant, or sku was provided in body
* put:
* summary: Attach an existing article as a child to a parent article
* description: Attaches an existing article to this parent article. Idempotent if already a child. Returns 409 if the article already belongs to another parent.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Parent article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sku
* properties:
* sku:
* type: string
* description: SKU of the existing article to attach as child
* attributeValueId:
* type: integer
* description: ID of existing attribute value for the parent's attribute
* value:
* type: string
* description: String value to find or create under the parent's attribute
* reparent:
* type: boolean
* description: Whether to allow re-parenting if child already belongs to another parent
* responses:
* 200:
* description: Attached child article and list of children
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/Article'
* children:
* type: array
* items:
* $ref: '#/components/schemas/ArticleChild'
* 400:
* description: Missing sku or attribute value, or invalid input
* 404:
* description: Parent or child article not found
* 409:
* description: Article already belongs to another parent, or variant value already exists
*/
// #region POST /article/:sku/children
app.post(`${basePath}/article/:sku/children`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
if (req.body?.sku || req.body?.childSku) {
return res.status(400).json({
error: 'sku is not allowed when creating a child article; omit sku to auto-generate or use PUT to attach an existing article',
});
}
try {
const result = await db.addChild(req.user.id, parsed.sku, req.body || {});
emitArticleSignal(req, parsed.sku);
if (result?.article?.sku) {
emitArticleSignal(req, result.article.sku);
}
emitArticlesSignal(req);
res.status(201).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Add child failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
// #region PUT /article/:sku/children
app.put(`${basePath}/article/:sku/children`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const childSku = typeof req.body?.sku === 'string' && req.body.sku.trim()
? req.body.sku.trim()
: (typeof req.body?.childSku === 'string' && req.body.childSku.trim() ? req.body.childSku.trim() : '');
if (!childSku) {
return res.status(400).json({ error: 'sku is required' });
}
try {
const result = await db.attachChild(req.user.id, parsed.sku, req.body || {});
emitArticleSignal(req, parsed.sku);
emitArticleSignal(req, childSku);
emitArticlesSignal(req);
res.status(200).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Attach child failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}/children/{childSku}:
* delete:
* summary: Remove a child article from a parent article
* description: Unlinks the child from the parent (or deletes if deleteArticle query/body is true).
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Parent article SKU
* - in: path
* name: childSku
* required: true
* schema:
* type: string
* description: Child article SKU
* - in: query
* name: deleteArticle
* schema:
* type: boolean
* description: If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a parent) instead of just unlinking
* responses:
* 200:
* description: Child removed
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* deleted:
* type: boolean
*/
// #region DELETE /article/:sku/children/:childSku
app.delete(`${basePath}/article/:sku/children/:childSku`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const parsedChild = parseSku(req.params.childSku);
if (parsedChild.error) return res.status(400).json({ error: 'childSku is required' });
const deleteArticle = req.query.deleteArticle === 'true' || req.body?.deleteArticle === true;
try {
const result = await db.removeChild(req.user.id, parsed.sku, parsedChild.sku, {
deleteArticle,
});
emitArticleSignal(req, parsed.sku);
emitArticleSignal(req, parsedChild.sku);
emitArticlesSignal(req);
res.status(200).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Remove child failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}/articlelist:
* @openapi
* /article/{sku}/articlelist:
* get:
* summary: Get Stückliste (bundle items) for an article
* description: Returns the list of component items for a Stückliste article.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* responses:
* 200:
* description: List of bundle items
* content:
* application/json:
* schema:
* type: object
* properties:
* items:
* type: array
* items:
* $ref: '#/components/schemas/StuecklisteItem'
* 404:
* description: Article not found
*/
// #region GET /article/:sku/articlelist
app.get(`${basePath}/article/:sku/articlelist`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
try {
const items = await db.getArticlelist(req.user.id, parsed.sku);
res.status(200).json({ items });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Get articlelist failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}/articlelist:
* put:
* summary: Set Stückliste (bundle items) for an article
* description: Configures the article as a Stückliste covering a list of count * article. A parent article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [items]
* properties:
* items:
* type: array
* items:
* type: object
* properties:
* articleId:
* type: integer
* sku:
* type: string
* count:
* type: integer
* default: 1
* sort:
* type: integer
* responses:
* 200:
* description: Updated Stückliste items
* content:
* application/json:
* schema:
* type: object
* properties:
* items:
* type: array
* items:
* $ref: '#/components/schemas/StuecklisteItem'
* 400:
* description: Article is a parent, has options, or contains itself
* 404:
* description: Article or component article not found
*/
// #region PUT /article/:sku/articlelist
app.put(`${basePath}/article/:sku/articlelist`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const items = req.body?.items;
if (!Array.isArray(items)) {
return res.status(400).json({ error: 'items array is required' });
}
try {
const updated = await db.setArticlelist(req.user.id, parsed.sku, items);
emitArticleSignal(req, parsed.sku);
emitArticlesSignal(req);
res.status(200).json({ items: updated });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Set articlelist failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}/optionslist:
* get:
* summary: Get options list for an article
* description: Returns the available options for an article.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* responses:
* 200:
* description: List of options
* content:
* application/json:
* schema:
* type: object
* properties:
* options:
* type: array
* items:
* $ref: '#/components/schemas/ArticleOptionItem'
* 404:
* description: Article not found
*/
// #region GET /article/:sku/optionslist
app.get(`${basePath}/article/:sku/optionslist`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
try {
const options = await db.getOptionslist(req.user.id, parsed.sku);
res.status(200).json({ options });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Get optionslist failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}/optionslist:
* put:
* summary: Set options list for an article
* description: Configures options for an article. Some options can be mandated (come with the article in any case). A parent article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [options]
* properties:
* options:
* type: array
* items:
* type: object
* properties:
* articleId:
* type: integer
* sku:
* type: string
* count:
* type: integer
* default: 1
* sort:
* type: integer
* mandated:
* type: boolean
* default: false
* responses:
* 200:
* description: Updated options list
* content:
* application/json:
* schema:
* type: object
* properties:
* options:
* type: array
* items:
* $ref: '#/components/schemas/ArticleOptionItem'
* 400:
* description: Article is a parent, a Stückliste, or contains itself
* 404:
* description: Article or option article not found
*/
// #region PUT /article/:sku/optionslist
app.put(`${basePath}/article/:sku/optionslist`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const options = req.body?.options;
if (!Array.isArray(options)) {
return res.status(400).json({ error: 'options array is required' });
}
try {
const updated = await db.setOptionslist(req.user.id, parsed.sku, options);
emitArticleSignal(req, parsed.sku);
emitArticlesSignal(req);
res.status(200).json({ options: updated });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Set optionslist failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}