diff --git a/docs/api/README.md b/docs/api/README.md index fce1baf..714f214 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -91,8 +91,13 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/category/delete.md`](routes/category/delete.md) | `DELETE /category/{id}` | | [`routes/category/update.md`](routes/category/update.md) | `PATCH /category/{id}` | | [`routes/category/articles.md`](routes/category/articles.md) | `GET /category/{id}/articles` | +| [`routes/category/_id/attributevalue.get.md`](routes/category/_id/attributevalue.get.md) | `GET /category/{id}/attributevalue` | +| [`routes/category/_id/attributevalue.put.md`](routes/category/_id/attributevalue.put.md) | `PUT /category/{id}/attributevalue` | | [`routes/category/delete-image.md`](routes/category/delete-image.md) | `DELETE /category/{id}/image` | | [`routes/category/add-image.md`](routes/category/add-image.md) | `POST /category/{id}/image` | +| [`routes/category/_id/property.get.md`](routes/category/_id/property.get.md) | `GET /category/{id}/property` | +| [`routes/category/_id/property.put.md`](routes/category/_id/property.put.md) | `PUT /category/{id}/property` | +| [`routes/category/_id/property/_propertyId.md`](routes/category/_id/property/_propertyId.md) | `DELETE /category/{id}/property/{propertyId}` | | [`routes/company/list.md`](routes/company/list.md) | `GET /company` | | [`routes/company/create.md`](routes/company/create.md) | `POST /company` | | [`routes/companysetting/list.md`](routes/companysetting/list.md) | `GET /company/setting` | diff --git a/docs/api/routes.md b/docs/api/routes.md index ca4f23d..95626d5 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 136 API endpoints across all modules. +Overview of all 141 API endpoints across all modules. ### Parameter Notation @@ -66,7 +66,12 @@ Overview of all 136 API endpoints across all modules. | `DELETE` | `/category/{id}` | `id:integer` | `204 void` | Delete category | | `PATCH` | `/category/{id}` | `id:integer, body: { name?, parentId? }` | `200 { category: { id, name, slug, parentId, sort } }` | Update category (rename or move) | | `GET` | `/category/{id}/articles` | `id:integer` | `200 { articles: { id, sku, name, slug, net, taxRate, track_stock, optionsCount, articleListCount, childCount, fullHash, thumbW, thumbH, thumbHash, brand }[] }` | Articles in category | +| `GET` | `/category/{id}/attributevalue` | `id:integer` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List category attribute values | +| `PUT` | `/category/{id}/attributevalue` | `id:integer, body: { attributevalues: any[] }` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set category attribute values | | `DELETE` | `/category/{id}/image` | `id:integer` | `200 { category: { id, thumbW, thumbH, thumbHash } }` | Delete category image | +| `GET` | `/category/{id}/property` | `id:integer` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | List category properties | +| `PUT` | `/category/{id}/property` | `id:integer, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | Bulk set category properties | +| `DELETE` | `/category/{id}/property/{propertyId}` | `id:integer, propertyId:integer` | `200` | Delete category property | | `GET` | `/company` | — | `200 { companies: { id, iso639, name, taxrate, reducedtaxrate }[] }` | List companies | | `GET` | `/company/setting` | — | `200 { settings: { key, value }[] }` | List company settings | | `GET` | `/company/setting/{key}` | `key:string` | `200 { setting: { key, value } }` | Get company setting by key | diff --git a/docs/api/routes/category/_id/attributevalue.get.md b/docs/api/routes/category/_id/attributevalue.get.md new file mode 100644 index 0000000..ac0b3cc --- /dev/null +++ b/docs/api/routes/category/_id/attributevalue.get.md @@ -0,0 +1,38 @@ +# `GET /category/{id}/attributevalue` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List category attribute values + +Fetches all attribute values assigned to a category. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Category ID | + +## Response `200` + +List of attribute values assigned to this category + +```json +{ + "attributevalues": [ + { + "id": 1, + "categoryId": 1, + "attributeId": 1, + "attributeName": "…", + "exclusive": true, + "attributeValueId": 1, + "value": "…", + "sort": 1 + } + ] +} +``` + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/category/_id/attributevalue.put.md b/docs/api/routes/category/_id/attributevalue.put.md new file mode 100644 index 0000000..46219db --- /dev/null +++ b/docs/api/routes/category/_id/attributevalue.put.md @@ -0,0 +1,52 @@ +# `PUT /category/{id}/attributevalue` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Bulk set category attribute values + +Replaces all attribute values for a category. Enforces exclusive constraint (max one value per exclusive attribute). + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Category ID | + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `attributevalues` | yes | any[] | Array of attribute value IDs (numbers) or objects with id or attributeValueId | + +```json +{ + "attributevalues": [ + "…" + ] +} +``` + +## Response `200` + +Updated category attribute values + +```json +{ + "attributevalues": [ + { + "id": 1, + "categoryId": 1, + "attributeId": 1, + "attributeName": "…", + "exclusive": true, + "attributeValueId": 1, + "value": "…", + "sort": 1 + } + ] +} +``` + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/category/_id/property.get.md b/docs/api/routes/category/_id/property.get.md new file mode 100644 index 0000000..b2178c7 --- /dev/null +++ b/docs/api/routes/category/_id/property.get.md @@ -0,0 +1,37 @@ +# `GET /category/{id}/property` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List category properties + +Fetches all free-form properties set on a category. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Category ID | + +## Response `200` + +List of properties for this category + +```json +{ + "properties": [ + { + "id": 1, + "categoryId": 1, + "propertyId": 1, + "name": "…", + "exclusive": true, + "value": "…", + "sort": 1 + } + ] +} +``` + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/category/_id/property.put.md b/docs/api/routes/category/_id/property.put.md new file mode 100644 index 0000000..9fc14bb --- /dev/null +++ b/docs/api/routes/category/_id/property.put.md @@ -0,0 +1,58 @@ +# `PUT /category/{id}/property` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Bulk set category properties + +Replaces all properties for a category. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Category ID | + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `properties` | yes | object[] | | +| `properties[].propertyId` | yes | integer | | +| `properties[].value` | yes | string | | +| `properties[].sort` | no | integer | | + +```json +{ + "properties": [ + { + "propertyId": 1, + "value": "…", + "sort": 1 + } + ] +} +``` + +## Response `200` + +Updated category properties + +```json +{ + "properties": [ + { + "id": 1, + "categoryId": 1, + "propertyId": 1, + "name": "…", + "exclusive": true, + "value": "…", + "sort": 1 + } + ] +} +``` + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/category/_id/property/_propertyId.md b/docs/api/routes/category/_id/property/_propertyId.md new file mode 100644 index 0000000..9bd9a3e --- /dev/null +++ b/docs/api/routes/category/_id/property/_propertyId.md @@ -0,0 +1,22 @@ +# `DELETE /category/{id}/property/{propertyId}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete category property + +Removes a single property from a category. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | +| `propertyId` | yes | integer | | + +## Response `200` + +Deleted successfully + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/openapi.js b/src/api/openapi.js index b378b6b..381787b 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -1106,6 +1106,45 @@ * type: string * sort: * type: integer + * CategoryProperty: + * type: object + * required: [id, propertyId, name, exclusive, value, sort] + * properties: + * id: + * type: integer + * categoryId: + * type: integer + * propertyId: + * type: integer + * name: + * type: string + * exclusive: + * type: boolean + * value: + * type: string + * sort: + * type: integer + * CategoryAttributeValue: + * type: object + * required: [id, attributeId, attributeName, attributeValueId, value, sort] + * properties: + * id: + * type: integer + * categoryId: + * type: integer + * attributeId: + * type: integer + * attributeName: + * type: string + * exclusive: + * type: boolean + * attributeValueId: + * type: integer + * value: + * type: string + * sort: + * type: integer */ export {}; + diff --git a/src/api/routes/categoryattributevalue.js b/src/api/routes/categoryattributevalue.js new file mode 100644 index 0000000..79951fe --- /dev/null +++ b/src/api/routes/categoryattributevalue.js @@ -0,0 +1,164 @@ +import * as db from '../../db/categoryattributevalue.js'; +import { emit } from '../sse.js'; + +function requireSelectedCompany(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function parsePositiveInt(value, field) { + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) { + return { error: `${field} is required` }; + } + return { id }; +} + +function emitCategorySignal(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) return; + emit('category', { companyId }); +} + +function categoryAttributeValueErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found' || error.message === 'Category not found' || error.message === 'Parent not found') return 404; + if (error.message?.startsWith('Attribute value not found')) return 404; + if (error.message?.includes('exclusive')) return 400; + if (error.message === 'Invalid attribute values array' || error.message === 'Invalid attributeValueId' || error.message === 'Duplicate attributeValueId') return 400; + return 500; +} + +/** + * Registers categoryattributevalue routes under `/category/:id/attributevalue`. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /category/{id}/attributevalue: + * get: + * tags: [Category] + * summary: List category attribute values + * description: Fetches all attribute values assigned to a category. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Category ID + * responses: + * 200: + * description: List of attribute values assigned to this category + * content: + * application/json: + * schema: + * type: object + * properties: + * attributevalues: + * type: array + * items: + * $ref: '#/components/schemas/CategoryAttributeValue' + * put: + * tags: [Category] + * summary: Bulk set category attribute values + * description: Replaces all attribute values for a category. Enforces exclusive constraint (max one value per exclusive attribute). + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Category ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [attributevalues] + * properties: + * attributevalues: + * type: array + * description: Array of attribute value IDs (numbers) or objects with id or attributeValueId + * items: + * oneOf: + * - type: integer + * description: Attribute value ID + * - type: object + * properties: + * id: + * type: integer + * description: Attribute value ID + * attributeValueId: + * type: integer + * description: Attribute value ID + * sort: + * type: integer + * responses: + * 200: + * description: Updated category attribute values + * content: + * application/json: + * schema: + * type: object + * properties: + * attributevalues: + * type: array + * items: + * $ref: '#/components/schemas/CategoryAttributeValue' + */ + app.get(`${basePath}/category/:id/attributevalue`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const idParsed = parsePositiveInt(req.params.id, 'categoryId'); + if (idParsed.error) { + return res.status(400).json({ error: idParsed.error }); + } + + try { + const attributevalues = await db.listByCategoryId(req.user.id, idParsed.id); + res.json({ attributevalues }); + } catch (error) { + res.status(categoryAttributeValueErrorStatus(error)).json({ error: error.message }); + } + }); + + app.put(`${basePath}/category/:id/attributevalue`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const idParsed = parsePositiveInt(req.params.id, 'categoryId'); + if (idParsed.error) { + return res.status(400).json({ error: idParsed.error }); + } + + const valuesInput = req.body?.attributevalues ?? req.body?.attributeValues ?? req.body?.attribute_values ?? req.body?.values ?? req.body; + if (!Array.isArray(valuesInput)) { + return res.status(400).json({ error: 'attributevalues array required' }); + } + + try { + const attributevalues = await db.setByCategoryId(req.user.id, idParsed.id, valuesInput); + emitCategorySignal(req); + res.json({ attributevalues }); + } catch (error) { + res.status(categoryAttributeValueErrorStatus(error)).json({ error: error.message }); + } + }); +} diff --git a/src/api/routes/categoryproperty.js b/src/api/routes/categoryproperty.js new file mode 100644 index 0000000..80cb4a3 --- /dev/null +++ b/src/api/routes/categoryproperty.js @@ -0,0 +1,204 @@ +import * as db from '../../db/categoryproperty.js'; +import { emit } from '../sse.js'; + +function requireSelectedCompany(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function parsePositiveInt(value, field) { + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) { + return { error: `${field} is required` }; + } + return { id }; +} + +function emitCategorySignal(req) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) return; + emit('category', { companyId }); +} + +function categoryPropertyErrorStatus(error) { + if (error.message === 'No company selected') return 400; + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found' || error.message === 'Category not found' || error.message === 'Parent not found') return 404; + if (error.message?.startsWith('Property not found')) return 404; + if (error.message?.includes('exclusive')) return 400; + if (error.message === 'Invalid properties array' || error.message === 'Invalid propertyId' || error.message === 'Duplicate propertyId') return 400; + return 500; +} + +/** + * Registers categoryproperty routes under `/category/:id/property`. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /category/{id}/property: + * get: + * tags: [Category] + * summary: List category properties + * description: Fetches all free-form properties set on a category. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Category ID + * responses: + * 200: + * description: List of properties for this category + * content: + * application/json: + * schema: + * type: object + * properties: + * properties: + * type: array + * items: + * $ref: '#/components/schemas/CategoryProperty' + * put: + * tags: [Category] + * summary: Bulk set category properties + * description: Replaces all properties for a category. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Category ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [properties] + * properties: + * properties: + * type: array + * items: + * type: object + * required: [propertyId, value] + * properties: + * propertyId: + * type: integer + * value: + * type: string + * sort: + * type: integer + * responses: + * 200: + * description: Updated category properties + * content: + * application/json: + * schema: + * type: object + * properties: + * properties: + * type: array + * items: + * $ref: '#/components/schemas/CategoryProperty' + */ + app.get(`${basePath}/category/:id/property`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const idParsed = parsePositiveInt(req.params.id, 'categoryId'); + if (idParsed.error) { + return res.status(400).json({ error: idParsed.error }); + } + + try { + const properties = await db.listByCategoryId(req.user.id, idParsed.id); + res.json({ properties }); + } catch (error) { + res.status(categoryPropertyErrorStatus(error)).json({ error: error.message }); + } + }); + + app.put(`${basePath}/category/:id/property`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const idParsed = parsePositiveInt(req.params.id, 'categoryId'); + if (idParsed.error) { + return res.status(400).json({ error: idParsed.error }); + } + + const propertiesInput = req.body?.properties ?? req.body; + if (!Array.isArray(propertiesInput)) { + return res.status(400).json({ error: 'properties array required' }); + } + + try { + const properties = await db.setByCategoryId(req.user.id, idParsed.id, propertiesInput); + emitCategorySignal(req); + res.json({ properties }); + } catch (error) { + res.status(categoryPropertyErrorStatus(error)).json({ error: error.message }); + } + }); + + /** + * @openapi + * /category/{id}/property/{propertyId}: + * delete: + * tags: [Category] + * summary: Delete category property + * description: Removes a single property from a category. + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: path + * name: propertyId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Deleted successfully + */ + app.delete(`${basePath}/category/:id/property/:propertyId`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const idParsed = parsePositiveInt(req.params.id, 'categoryId'); + if (idParsed.error) { + return res.status(400).json({ error: idParsed.error }); + } + + try { + const propertyId = Number(req.params.propertyId); + await db.remove(req.user.id, idParsed.id, propertyId); + emitCategorySignal(req); + res.json({ ok: true }); + } catch (error) { + res.status(categoryPropertyErrorStatus(error)).json({ error: error.message }); + } + }); +} diff --git a/src/db/categoryattributevalue.js b/src/db/categoryattributevalue.js new file mode 100644 index 0000000..0efa0b6 --- /dev/null +++ b/src/db/categoryattributevalue.js @@ -0,0 +1,11 @@ +import { getStore } from './store.js'; + +export async function listByCategoryId(googleAuthId, categoryId) { + const store = await getStore(); + return store.categoryattributevalue.listByCategoryId(googleAuthId, categoryId); +} + +export async function setByCategoryId(googleAuthId, categoryId, values) { + const store = await getStore(); + return store.categoryattributevalue.setByCategoryId(googleAuthId, categoryId, values); +} diff --git a/src/db/categoryproperty.js b/src/db/categoryproperty.js new file mode 100644 index 0000000..9752e1e --- /dev/null +++ b/src/db/categoryproperty.js @@ -0,0 +1,16 @@ +import { getStore } from './store.js'; + +export async function listByCategoryId(googleAuthId, categoryId) { + const store = await getStore(); + return store.categoryproperty.listByCategoryId(googleAuthId, categoryId); +} + +export async function setByCategoryId(googleAuthId, categoryId, properties) { + const store = await getStore(); + return store.categoryproperty.setByCategoryId(googleAuthId, categoryId, properties); +} + +export async function remove(googleAuthId, categoryId, propertyId) { + const store = await getStore(); + return store.categoryproperty.delete(googleAuthId, categoryId, propertyId); +} diff --git a/src/db/posterminal.js b/src/db/posterminal.js index 51b1117..9bdccd2 100644 --- a/src/db/posterminal.js +++ b/src/db/posterminal.js @@ -95,3 +95,9 @@ export async function getProductList(params) { return store.posterminal.getProductList(params); } +export async function setMaxKnownTombstoneId(id, maxknowntombstoneid) { + const store = await getStore(); + return store.posterminal.setMaxKnownTombstoneId(id, maxknowntombstoneid); +} + + diff --git a/src/db/store.js b/src/db/store.js index 12eac9c..aa1d610 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -39,9 +39,11 @@ const STORE_NAMESPACES = [ 'stock', 'propertyname', 'articleproperty', + 'categoryproperty', 'attributename', 'attributevalue', 'articleattributevalue', + 'categoryattributevalue', ]; /**