641 lines
23 KiB
JavaScript
641 lines
23 KiB
JavaScript
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:
|
|
* 204:
|
|
* description: Updated
|
|
* 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 {
|
|
await db.toggleParent(req.user.id, parsed.sku, {
|
|
isParent,
|
|
attributeNameId,
|
|
});
|
|
emitArticleSignal(req, parsed.sku);
|
|
emitArticlesSignal(req);
|
|
res.status(204).end();
|
|
} 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:
|
|
* sku:
|
|
* type: string
|
|
* 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: List of children after attaching
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* 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) {
|
|
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({ sku: result.article.sku });
|
|
} 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() : '';
|
|
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({ children: result.children });
|
|
} 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';
|
|
|
|
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
|
|
}
|