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 }