This commit is contained in:
seb
2026-10-18 03:15:23 +02:00
parent e085e064ee
commit 04943fb7a6
12 changed files with 1372 additions and 867 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 330 KiB

View File

@@ -39,6 +39,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/account/_id.md`](routes/account/_id.md) | `DELETE /account/{id}` |
| [`routes/article/create.md`](routes/article/create.md) | `POST /article` |
| [`routes/article/export.zip.md`](routes/article/export.zip.md) | `GET /article/export.zip` |
| [`routes/article/history-list.md`](routes/article/history-list.md) | `GET /article/history` |
| [`routes/article/delete.md`](routes/article/delete.md) | `DELETE /article/{sku}` |
| [`routes/article/get.md`](routes/article/get.md) | `GET /article/{sku}` |
| [`routes/article/update.md`](routes/article/update.md) | `PATCH /article/{sku}` |
@@ -166,6 +167,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PATCH /labeltemplate/{id}` |
| [`routes/labeltemplate/update.md`](routes/labeltemplate/update.md) | `PUT /labeltemplate/{id}` |
| [`routes/ledger.md`](routes/ledger.md) | `GET /ledger` |
| [`routes/ledger/history.md`](routes/ledger/history.md) | `GET /ledger/history` |
| [`routes/ledger/manifestarticle/_manifestarticleId.md`](routes/ledger/manifestarticle/_manifestarticleId.md) | `GET /ledger/manifestarticle/{manifestarticleId}` |
| [`routes/ledger/sku/_sku.md`](routes/ledger/sku/_sku.md) | `GET /ledger/sku/{sku}` |
| [`routes/mcp.md`](routes/mcp.md) | `POST /mcp` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 261 API endpoints across all modules.
Overview of all 263 API endpoints across all modules.
### Notation
@@ -83,6 +83,9 @@ _none_
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
#### Query company-wide article history — `GET /article/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { history: { id: int, date: str, sku: str, historytext: str, name?: str|null }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool`
- Returns: `204 void`
@@ -528,6 +531,9 @@ _none_
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T15[] }`
#### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T15[] }`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 186 API endpoints across all modules.
Overview of all 188 API endpoints across all modules.
### Notation
@@ -50,6 +50,9 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 { article: T26 }`
#### Query company-wide article history — `GET /article/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { history: { id: int, date: str, sku: str, historytext: str, name?: str|null }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool`
- Returns: `204 void`
@@ -410,6 +413,9 @@ _none_
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T11[] }`
#### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T11[] }`

View File

@@ -0,0 +1,52 @@
# `GET /article/history`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Query company-wide article history
Queries article change history across all articles for the authenticated user's selected company.
Supports pagination, search filter, and date cutoff via `seekMonths`.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `offset` | no | integer | Paging offset |
| `limit` | no | integer | Page size |
| `search` | no | string | Optional search filter across SKU, article name, and history text |
| `seekMonths` | no | integer | Negative integer cutoff months ago (e.g. -13) |
## Response `200`
Paged article history list
```json
{
"history": [
{
"id": 1,
"date": "2026-08-10T00:00:00.000Z",
"sku": "…",
"name": "…",
"historytext": "…"
}
],
"total": 1,
"unfilteredTotal": 1,
"offset": 1,
"limit": 1
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,54 @@
# `GET /ledger/history`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Query company-wide ledger history
Queries ledger entries across all articles for the authenticated user's selected company.
Supports pagination, search filter, and date cutoff via `seekMonths`.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `offset` | no | integer | Paging offset |
| `limit` | no | integer | Page size |
| `search` | no | string | Optional search filter across SKU, article name, manifest number, external number, transaction type, and transaction text |
| `seekMonths` | no | integer | Negative integer cutoff months ago (e.g. -1) |
## Response `200`
Paged ledger history list
```json
{
"ledger": [
{
"id": 1,
"date": "2026-08-10T00:00:00.000Z",
"sku": "…",
"name": "…",
"transaction_type": "…",
"transaction_text": "…",
"amount": 1
}
],
"total": 1,
"unfilteredTotal": 1,
"offset": 1,
"limit": 1
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Validation error or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -257,7 +257,155 @@ export function register(app, basePath = '') {
/**
* @openapi
* /article/{sku}:
* /article/history:
* get:
* tags: [Article]
* summary: Query company-wide article history
* description: |
* Queries article change history across all articles for the authenticated user's selected company.
* Supports pagination, search filter, and date cutoff via `seekMonths`.
* security:
* - cookieAuth: []
* x-markdown: article/history-list.md
* parameters:
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Paging offset
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* default: 50
* description: Page size
* - in: query
* name: search
* schema:
* type: string
* description: Optional search filter across SKU, article name, and history text
* - in: query
* name: seekMonths
* schema:
* type: integer
* description: Negative integer cutoff months ago (e.g. -13)
* responses:
* 200:
* description: Paged article history list
* content:
* application/json:
* schema:
* type: object
* required: [history, total, unfilteredTotal, offset, limit]
* properties:
* history:
* type: array
* items:
* type: object
* required: [id, date, sku, historytext]
* properties:
* id:
* type: integer
* date:
* type: string
* format: date-time
* sku:
* type: string
* name:
* type: string
* nullable: true
* historytext:
* type: string
* total:
* type: integer
* unfilteredTotal:
* type: integer
* offset:
* type: integer
* limit:
* type: integer
* 400:
* description: Validation error or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /article/history
app.get(`${basePath}/article/history`, async (req, res) => {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return res.status(400).json({ error: 'No company selected' });
}
const rawOffset = req.query.offset;
const rawLimit = req.query.limit;
let offset = 0;
let limit = 50;
if (rawOffset !== undefined) {
offset = parseInt(rawOffset, 10);
if (!Number.isInteger(offset) || offset < 0) {
return res.status(400).json({ error: 'offset must be an integer >= 0' });
}
}
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' });
}
}
const search = typeof req.query.search === 'string' && req.query.search.trim() !== ''
? req.query.search.trim()
: undefined;
let seekMonths = undefined;
if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') {
const parsedSeek = parseInt(req.query.seekMonths, 10);
if (!Number.isInteger(parsedSeek)) {
return res.status(400).json({ error: 'seekMonths must be an integer' });
}
seekMonths = parsedSeek;
}
try {
const result = await db.listHistory(req.user.id, {
offset,
limit,
search,
seekMonths,
});
res.status(200).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('List article history failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /article/{sku}:
* get:

View File

@@ -43,6 +43,9 @@ export function parseFullHash(value) {
export function articleErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Invalid seekMonths') return 400;
if (error.message === 'Unsupported image format' || error.message?.includes('unsupported image format')) return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Parent not found') return 404;

View File

@@ -12,6 +12,9 @@ function requireSelectedCompany(req) {
function ledgerErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Invalid offset') return 400;
if (error.message === 'Invalid limit') return 400;
if (error.message === 'Invalid seekMonths') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Manifest article not found') return 404;
@@ -281,4 +284,160 @@ export function register(app, basePath = '') {
});
// #endregion
/**
* @openapi
* /ledger/history:
* get:
* tags: [Ledger]
* summary: Query company-wide ledger history
* description: |
* Queries ledger entries across all articles for the authenticated user's selected company.
* Supports pagination, search filter, and date cutoff via `seekMonths`.
* security:
* - cookieAuth: []
* x-markdown: ledger/history.md
* parameters:
* - in: query
* name: offset
* schema:
* type: integer
* minimum: 0
* default: 0
* description: Paging offset
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* default: 50
* description: Page size
* - in: query
* name: search
* schema:
* type: string
* description: Optional search filter across SKU, article name, manifest number, external number, transaction type, and transaction text
* - in: query
* name: seekMonths
* schema:
* type: integer
* description: Negative integer cutoff months ago (e.g. -1)
* responses:
* 200:
* description: Paged ledger history list
* content:
* application/json:
* schema:
* type: object
* required: [ledger, total, unfilteredTotal, offset, limit]
* properties:
* ledger:
* type: array
* items:
* type: object
* required: [id, date, transaction_type, transaction_text]
* properties:
* id:
* type: integer
* date:
* type: string
* format: date-time
* sku:
* type: string
* nullable: true
* name:
* type: string
* nullable: true
* transaction_type:
* type: string
* transaction_text:
* type: string
* amount:
* type: number
* total:
* type: integer
* unfilteredTotal:
* type: integer
* offset:
* type: integer
* limit:
* type: integer
* 400:
* description: Validation error or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /ledger/history
app.get(`${basePath}/ledger/history`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawOffset = req.query.offset;
const rawLimit = req.query.limit;
let offset = 0;
let limit = 50;
if (rawOffset !== undefined) {
offset = parseInt(rawOffset, 10);
if (!Number.isInteger(offset) || offset < 0) {
return res.status(400).json({ error: 'offset must be an integer >= 0' });
}
}
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' });
}
}
const search = typeof req.query.search === 'string' && req.query.search.trim() !== ''
? req.query.search.trim()
: undefined;
let seekMonths = undefined;
if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') {
const parsedSeek = parseInt(req.query.seekMonths, 10);
if (!Number.isInteger(parsedSeek)) {
return res.status(400).json({ error: 'seekMonths must be an integer' });
}
seekMonths = parsedSeek;
}
try {
const result = await db.listHistory(req.user.id, {
offset,
limit,
search,
seekMonths,
});
res.status(200).json(result);
} catch (error) {
const status = ledgerErrorStatus(error);
if (status === 500) console.error('List ledger history failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -15,6 +15,11 @@ export async function getHistoryBySku(googleAuthId, sku, opts) {
return store.article.getHistoryBySku(googleAuthId, sku, opts);
}
export async function listHistory(googleAuthId, opts) {
const store = await getStore();
return store.article.listHistory(googleAuthId, opts);
}
export async function updateBySku(googleAuthId, sku, data) {
const store = await getStore();
return store.article.updateBySku(googleAuthId, sku, data);

View File

@@ -26,3 +26,16 @@ export async function listBySku(googleAuthId, sku, transactionType) {
const store = await getStore();
return store.ledger.listBySku(googleAuthId, sku, transactionType);
}
/**
* Lists ledger entries across all articles with pagination, seekMonths, and search.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [opts] - Query options.
* @returns {Promise<object>} Paged ledger history.
*/
export async function listHistory(googleAuthId, opts) {
const store = await getStore();
return store.ledger.listHistory(googleAuthId, opts);
}