From 9e9d3154cabbf8cb17ad95a9f929dfd6a62e68c7 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 18 Sep 2026 05:19:47 +0200 Subject: [PATCH] file meta --- API.delivery.md | 6 + API.manifestattachment.md | 29 +- API.md | 9 +- generate-api-doc.js | 2 +- schema-graph.dot | 6 +- schema-graph.svg | 1110 +++++++++-------- src/schema.js | 3 +- src/sql/delivery.js | 64 +- src/sql/manifestattachment.js | 10 +- src/store/delivery/attachments.js | 172 +++ src/store/delivery/create.js | 21 +- src/store/delivery/helpers.js | 4 + src/store/delivery/list.js | 22 +- src/store/delivery/update.js | 21 +- src/store/manifestattachment/add.js | 21 +- src/store/manifestattachment/getById.js | 4 +- .../manifestattachment/listByManifestId.js | 15 +- 17 files changed, 896 insertions(+), 623 deletions(-) create mode 100644 src/store/delivery/attachments.js diff --git a/API.delivery.md b/API.delivery.md index fd4d274..fdc1fd5 100644 --- a/API.delivery.md +++ b/API.delivery.md @@ -2,6 +2,12 @@ This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. +## `delivery.attachments` + +*Source: `src/store/delivery/attachments.js`* + +Loads attachment metadata (without binary content) for a manifest. + ## `delivery.create` *Source: `src/store/delivery/create.js`* diff --git a/API.manifestattachment.md b/API.manifestattachment.md index e154463..ce26670 100644 --- a/API.manifestattachment.md +++ b/API.manifestattachment.md @@ -26,10 +26,11 @@ Attaches a file to an existing manifest. | `[input.thumbavif]` | `Buffer` | Optional AVIF thumbnail buffer. | | `[input.thumbx]` | `number` | Optional thumbnail width. | | `[input.thumby]` | `number` | Optional thumbnail height. | +| `[input.meta]` | `object` | Optional string key/value metadata stored on the file row. | **Returns** -`Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>` — The created attachment record. +`Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>` — The created attachment record. **Throws** @@ -62,7 +63,7 @@ Gets an attachment for a manifest by its attachment ID or file ID, including ful **Returns** -`Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>` — The attachment record with binary content. +`Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>` — The attachment record with binary content. **Throws** @@ -79,29 +80,7 @@ const attachment = await store.manifestattachment.getById(42, 1, 5); *Source: `src/store/manifestattachment/listByManifestId.js`* -Lists all attachments for a given manifest, without loading their full binary content. - -**Parameters** - -| Name | Type | Description | -| --- | --- | --- | -| `googleAuthId` | `number` | The id of the Google auth user. | -| `manifestId` | `number` | The manifest ID. | - -**Returns** - -`Promise>` — A list of attachment metadata objects. - -**Throws** - -`Error` — If the manifest is not found or the user is not authorized. - -**Example** - -```js -const attachments = await store.manifestattachment.listByManifestId(42, 1); -// => [{ id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby }] -``` +Parse the file.meta JSON text column into an object (string key/value). ## `manifestattachment.order` diff --git a/API.md b/API.md index 34fe1e1..4238462 100644 --- a/API.md +++ b/API.md @@ -30,7 +30,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - **entityaddress**: `id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0` - **entitycontact**: `id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0` - **entityhistory**: `id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt` -- **file**: `id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i` +- **file**: `id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt` - **google_auth**: `id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company` - **language**: `id PK, iso639:s(10) UQ` - **ledger**: `id PK, manifestarticle_id->manifestarticle, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]` @@ -152,6 +152,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `order(googleAuthId: number, sku: string, input: {ids: Array}): Promise<{ids: Array}>` - `update(googleAuthId: number, sku: string, id: number, patch: {sku?: string|null, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}): Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>` - [delivery](API.delivery.md) + - `attachments(): void` - `create(googleAuthId: number, body: {date: string, supplierId: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - `delete(googleAuthId: number, id: number): Promise` - `getById(googleAuthId: number, id: number): Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` @@ -170,9 +171,9 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `list(googleAuthId: number): Promise>` - `update(googleAuthId: number, id: number, patch: {number?: string, date?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - [manifestattachment](API.manifestattachment.md) - - `add(googleAuthId: number, manifestId: number, input: {mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string, thumbavif?: Buffer, thumbx?: number, thumby?: number}): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>` - - `getById(googleAuthId: number, manifestId: number, id: number): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>` - - `listByManifestId(googleAuthId: number, manifestId: number): Promise>` + - `add(googleAuthId: number, manifestId: number, input: {mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string, thumbavif?: Buffer, thumbx?: number, thumby?: number, meta?: object}): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>` + - `getById(googleAuthId: number, manifestId: number, id: number): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>` + - `listByManifestId(): void` - `order(googleAuthId: number, manifestId: number, input: {ids?: Array}): Promise<{ids: Array}>` - `remove(googleAuthId: number, manifestId: number, id: number): Promise` - [posterminal](API.posterminal.md) diff --git a/generate-api-doc.js b/generate-api-doc.js index 9803954..c357e44 100644 --- a/generate-api-doc.js +++ b/generate-api-doc.js @@ -331,7 +331,7 @@ const SCHEMA_TABLES = [ ['entityaddress', 'id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0'], ['entitycontact', 'id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0'], ['entityhistory', 'id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt'], - ['file', 'id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i'], + ['file', 'id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt'], ['google_auth', 'id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company'], ['language', 'id PK, iso639:s(10) UQ'], ['ledger', 'id PK, manifestarticle_id->manifestarticle, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]'], diff --git a/schema-graph.dot b/schema-graph.dot index 11cdd68..2d610ab 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -55,7 +55,7 @@ digraph schema { pic [label=<
pic
idINT PK
wINT
hINT
sizeBIGINT
hashNVARCHAR(64)
thumbwINT
thumbhINT
thumbhashNVARCHAR(64)
sourceNVARCHAR(MAX)
fullbinaryVARBINARY(MAX)
thumbbinaryVARBINARY(MAX)
>]; brandpic [label=<
brandpic
idINT PK
brand_idINT FK
pic_idINT FK
sortINT
>]; articlepic [label=<
articlepic
idINT PK
article_idINT FK
pic_idINT FK
sortINT
>]; - file [label=<
file
idINT PK
hashNVARCHAR(64)
mimetypeNVARCHAR(255)
nameNVARCHAR(255)
sizeBIGINT
createdDATETIME2
modifiedDATETIME2
contentVARBINARY(MAX)
thumbavif?VARBINARY(MAX)
thumbx?INT
thumby?INT
>]; + file [label=<
file
idINT PK
hashNVARCHAR(64)
mimetypeNVARCHAR(255)
nameNVARCHAR(255)
sizeBIGINT
createdDATETIME2
modifiedDATETIME2
contentVARBINARY(MAX)
thumbavif?VARBINARY(MAX)
thumbx?INT
thumby?INT
meta?NVARCHAR(MAX)
>]; } subgraph cluster_manifests { label="Documents"; labeljust="l"; fontsize=15; @@ -93,7 +93,7 @@ digraph schema { category:parent_id:e -> category:id:e [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_category_parent", constraint=false]; company:root_category_id:w -> category:in1:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_company_root_category", weight=3]; company:default_language_id:se -> language:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_company_default_language", constraint=false, weight=1]; - company:taxrate:e -> tax:in3:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_company_taxrate", constraint=false, weight=1]; + company:taxrate:e -> tax:in1:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_company_taxrate", constraint=false, weight=1]; company:reducedtaxrate:e -> tax:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_company_reducedtaxrate", constraint=false, weight=1]; company:taxfreerate:e -> tax:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_company_taxfreerate", constraint=false, weight=1]; customergroup:company_id:w -> company:in2:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_customergroup_company", weight=3]; @@ -131,7 +131,7 @@ digraph schema { categorytext:language_id:nw -> language:in3:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_categorytext_language", constraint=false, weight=1]; articletext:article_id:w -> article:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_articletext_article", weight=3]; articletext:language_id:w -> language:in2:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_articletext_language", constraint=false, weight=1]; - price:tax_id:w -> tax:in1:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_price_tax", constraint=false, weight=1]; + price:tax_id:w -> tax:in3:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_price_tax", constraint=false, weight=1]; price:article_id:e -> article:in7:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_price_article", weight=3]; price:customergroup_id:e -> customergroup:in2:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_price_customergroup", constraint=false, weight=1]; articlesupplier:article_id:sw -> article:in5:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_articlesupplier_article", weight=3]; diff --git a/schema-graph.svg b/schema-graph.svg index 948a7b3..e9de242 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -4,40 +4,40 @@ - - + + schema - + cluster_identity - -Identity & Company + +Identity & Company cluster_i18n - -Localization + +Localization cluster_catalog - -Catalog + +Catalog cluster_media - -Media + +Media cluster_manifests - -Documents + +Documents cluster_commerce - -Pricing + +Pricing cluster_units @@ -52,17 +52,17 @@ meta - - -meta - - -key -NVARCHAR(255) - - -value -NVARCHAR(MAX) + + +meta + + +key +NVARCHAR(255) + + +value +NVARCHAR(MAX) @@ -259,61 +259,61 @@ entityaddress - - -entityaddress - - -id -INT -PK - - -entity_id -INT -FK - - -first_name? -NVARCHAR(255) - - -name? -NVARCHAR(255) - - -street? -NVARCHAR(255) - - -streetnumber? -NVARCHAR(50) - - -zip? -NVARCHAR(50) - - -city? -NVARCHAR(255) - - -county? -NVARCHAR(255) - - -addresstype -NVARCHAR(50) - - -sort -INT + + +entityaddress + + +id +INT +PK + + +entity_id +INT +FK + + +first_name? +NVARCHAR(255) + + +name? +NVARCHAR(255) + + +street? +NVARCHAR(255) + + +streetnumber? +NVARCHAR(50) + + +zip? +NVARCHAR(50) + + +city? +NVARCHAR(255) + + +county? +NVARCHAR(255) + + +addresstype +NVARCHAR(50) + + +sort +INT entityaddress:se->entity:n - + @@ -402,38 +402,38 @@ entitycontact - - -entitycontact - - -id -INT -PK - - -entity_id -INT -FK - - -contacttype -NVARCHAR(50) - - -contact -NVARCHAR(255) - - -sort -INT + + +entitycontact + + +id +INT +PK + + +entity_id +INT +FK + + +contacttype +NVARCHAR(50) + + +contact +NVARCHAR(255) + + +sort +INT entitycontact:nw->entity:n - - + + @@ -449,29 +449,29 @@ language - - - - - - -language - - -id -INT -PK - - -iso639 -NVARCHAR(10) + + + + + + +language + + +id +INT +PK + + +iso639 +NVARCHAR(10) company:se->language:n - - + + @@ -515,29 +515,29 @@ tax - - - - - - -tax - - -id -INT -PK - - -rate -DECIMAL(18,4) + + + + + + +tax + + +id +INT +PK + + +rate +DECIMAL(18,4) company:e->tax:n - - + + @@ -545,8 +545,8 @@ company:e->tax:n - - + + @@ -554,8 +554,8 @@ company:e->tax:n - - + + @@ -596,39 +596,39 @@ entityhistory - - -entityhistory - - -id -INT -PK - - -entity_id -INT -FK - - -companyuser_id -? -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) + + +entityhistory + + +id +INT +PK + + +entity_id +INT +FK + + +companyuser_id +? +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) entityhistory:w->entity:n - + @@ -637,7 +637,7 @@ entityhistory:sw->companyuser:n - + @@ -727,8 +727,8 @@ unittext:nw->language:n - - + + @@ -814,8 +814,8 @@ categorytext:nw->language:n - - + + @@ -831,47 +831,47 @@ articletext - - -articletext - - -id -INT -PK - - -language_id -INT -FK - - -article_id -INT -FK - - -name -NVARCHAR(255) - - -slug -NVARCHAR(255) - - -seo? -NVARCHAR(MAX) - - -desc? -NVARCHAR(MAX) + + +articletext + + +id +INT +PK + + +language_id +INT +FK + + +article_id +INT +FK + + +name +NVARCHAR(255) + + +slug +NVARCHAR(255) + + +seo? +NVARCHAR(MAX) + + +desc? +NVARCHAR(MAX) articletext:w->language:n - - + + @@ -958,7 +958,7 @@ articletext:w->article:n - + @@ -1020,43 +1020,43 @@ optionslistarticle - - -optionslistarticle - - -id -INT -PK - - -optionslist_id -INT -FK - - -article_id -INT -FK - - -count -INT - - -sort -INT - - -mandated -BIT + + +optionslistarticle + + +id +INT +PK + + +optionslist_id +INT +FK + + +article_id +INT +FK + + +count +INT + + +sort +INT + + +mandated +BIT optionslistarticle:sw->optionslist:n - - + + @@ -1064,7 +1064,7 @@ optionslistarticle:se->article:n - + @@ -1081,39 +1081,39 @@ articlehistory - - -articlehistory - - -id -INT -PK - - -companyuser_id -? -INT -FK - - -article_id -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) + + +articlehistory + + +id +INT +PK + + +companyuser_id +? +INT +FK + + +article_id +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) articlehistory:w->companyuser:n - + @@ -1122,8 +1122,8 @@ articlehistory:ne->article:n - - + + @@ -1216,30 +1216,30 @@ articleean - - -articleean - - -id -INT -PK - - -article_id -INT -FK - - -ean -NVARCHAR(50) + + +articleean + + +id +INT +PK + + +article_id +INT +FK + + +ean +NVARCHAR(50) articleean:sw->article:n - - + + @@ -1303,52 +1303,52 @@ articlesupplier - - -articlesupplier - - -id -INT -PK - - -entity_id -INT -FK - - -article_id -INT -FK - - -price_id -? -INT -FK - - -sku? -NVARCHAR(100) - - -stock? -FLOAT - - -pu -FLOAT - - -sort -INT + + +articlesupplier + + +id +INT +PK + + +entity_id +INT +FK + + +article_id +INT +FK + + +price_id +? +INT +FK + + +sku? +NVARCHAR(100) + + +stock? +FLOAT + + +pu +FLOAT + + +sort +INT articlesupplier:w->entity:n - + @@ -1357,53 +1357,53 @@ articlesupplier:sw->article:n - - + + price - - - -price - - -id -INT -PK - - -customergroup_id -INT -FK - - -article_id -INT -FK - - -tax_id -INT -FK - - -minmultiplier -DECIMAL(18,4) - - -net -FLOAT + + + +price + + +id +INT +PK + + +customergroup_id +INT +FK + + +article_id +INT +FK + + +tax_id +INT +FK + + +minmultiplier +DECIMAL(18,4) + + +net +FLOAT articlesupplier:e->price:n - - + + @@ -1445,64 +1445,64 @@ pic - - - - - -pic - - -id -INT -PK - - -w -INT - - -h -INT - - -size -BIGINT - - -hash -NVARCHAR(64) - - -thumbw -INT - - -thumbh -INT - - -thumbhash -NVARCHAR(64) - - -source -NVARCHAR(MAX) - - -fullbinary -VARBINARY(MAX) - - -thumbbinary -VARBINARY(MAX) + + + + + +pic + + +id +INT +PK + + +w +INT + + +h +INT + + +size +BIGINT + + +hash +NVARCHAR(64) + + +thumbw +INT + + +thumbh +INT + + +thumbhash +NVARCHAR(64) + + +source +NVARCHAR(MAX) + + +fullbinary +VARBINARY(MAX) + + +thumbbinary +VARBINARY(MAX) categorypic:nw->pic:n - - + + @@ -1545,43 +1545,43 @@ brandpic:ne->pic:n - - + + articlepic - - -articlepic - - -id -INT -PK - - -article_id -INT -FK - - -pic_id -INT -FK - - -sort -INT + + +articlepic + + +id +INT +PK + + +article_id +INT +FK + + +pic_id +INT +FK + + +sort +INT articlepic:e->article:n - - + + @@ -1589,63 +1589,67 @@ articlepic:w->pic:n - - + + file - - - -file - + + + +file + + +id +INT +PK + -id -INT -PK - +hash +NVARCHAR(64) + -hash -NVARCHAR(64) - +mimetype +NVARCHAR(255) + -mimetype +name NVARCHAR(255) - + -name -NVARCHAR(255) - +size +BIGINT + -size -BIGINT - +created +DATETIME2 + -created +modified DATETIME2 - + -modified -DATETIME2 - +content +VARBINARY(MAX) + -content +thumbavif? VARBINARY(MAX) - + -thumbavif? -VARBINARY(MAX) - +thumbx? +INT + -thumbx? +thumby? INT - + -thumby? -INT +meta? +NVARCHAR(MAX) @@ -1750,46 +1754,46 @@ ledger - - -ledger - - -id -INT -PK - - -manifestarticle_id -INT -FK - - -transaction_type -NVARCHAR(100) - - -amount -FLOAT - - -transaction_text -NVARCHAR(MAX) - - -hash_version -INT - - -prev_hash? -NVARCHAR(128) + + +ledger + + +id +INT +PK + + +manifestarticle_id +INT +FK + + +transaction_type +NVARCHAR(100) + + +amount +FLOAT + + +transaction_text +NVARCHAR(MAX) + + +hash_version +INT + + +prev_hash? +NVARCHAR(128) ledger:sw->manifestarticle:n - - + + @@ -1841,8 +1845,8 @@ manifestattachment:e->file:n - - + + @@ -1859,7 +1863,7 @@ price:e->customergroup:n - + @@ -1868,7 +1872,7 @@ price:e->article:n - + @@ -1877,8 +1881,8 @@ price:w->tax:n - - + + @@ -1973,30 +1977,30 @@ __legend - - - -Legend - -PK - -primary key - -FK - -foreign key - -? - -nullable column - -— - -FOREIGN KEY constraint - -- - - -naming convention only, no constraint + + + +Legend + +PK + +primary key + +FK + +foreign key + +? + +nullable column + +— + +FOREIGN KEY constraint + +- - + +naming convention only, no constraint diff --git a/src/schema.js b/src/schema.js index 9a32242..d854c58 100644 --- a/src/schema.js +++ b/src/schema.js @@ -782,7 +782,8 @@ BEGIN content VARBINARY(MAX) NOT NULL, thumbavif VARBINARY(MAX) NULL, thumbx INT NULL, - thumby INT NULL + thumby INT NULL, + meta NVARCHAR(MAX) NULL ); END `; diff --git a/src/sql/delivery.js b/src/sql/delivery.js index feb00e8..31f67df 100644 --- a/src/sql/delivery.js +++ b/src/sql/delivery.js @@ -74,28 +74,44 @@ DELETE FROM manifestarticle WHERE manifest_id = @manifestId `; -export const CREATE_LINE = ` -INSERT INTO manifestarticle ( - manifest_id, - sku, - name, - pu, - amount, - net, - comment, - rebate, - taxrate -) -OUTPUT INSERTED.id -VALUES ( - @manifestId, - @sku, - @name, - @pu, - @amount, - @net, - @comment, - @rebate, - @taxrate -) +export const GET_NEXT_ATTACHMENT_SORT = ` +SELECT ISNULL(MAX(ma.sort), -1) + 1 AS sort +FROM manifestattachment ma +WHERE ma.manifest_id = @manifestId +`; + +export const CREATE_FILE = ` +INSERT INTO [file] (hash, mimetype, name, size, created, modified, content, thumbavif, thumbx, thumby, meta) +OUTPUT INSERTED.id +VALUES (@hash, @mimetype, @name, @size, @created, @modified, @content, CONVERT(VARBINARY(MAX), @thumbavif), @thumbx, @thumby, @meta) +`; + +export const CREATE_ATTACHMENT = ` +INSERT INTO manifestattachment (manifest_id, file_id, sort) +OUTPUT INSERTED.id +VALUES (@manifestId, @fileId, @sort) +`; + +export const LIST_DELIVERY_FILES = ` +SELECT + ma.id, + ma.manifest_id AS manifestId, + ma.file_id AS fileId, + ma.sort, + f.hash, + f.mimetype, + f.name, + f.size, + f.created, + f.modified, + f.thumbavif, + f.thumbx, + f.thumby, + f.meta +FROM manifestattachment ma +INNER JOIN [file] f ON f.id = ma.file_id +INNER JOIN manifest m ON m.id = ma.manifest_id +WHERE m.company_id = @companyId + AND m.[type] = @type +ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC `; diff --git a/src/sql/manifestattachment.js b/src/sql/manifestattachment.js index f1b0845..d8aa5b4 100644 --- a/src/sql/manifestattachment.js +++ b/src/sql/manifestattachment.js @@ -5,9 +5,9 @@ WHERE id = @manifestId AND company_id = @companyId `; export const CREATE_FILE = ` -INSERT INTO [file] (hash, mimetype, name, size, created, modified, content, thumbavif, thumbx, thumby) +INSERT INTO [file] (hash, mimetype, name, size, created, modified, content, thumbavif, thumbx, thumby, meta) OUTPUT INSERTED.id -VALUES (@hash, @mimetype, @name, @size, @created, @modified, @content, CONVERT(VARBINARY(MAX), @thumbavif), @thumbx, @thumby) +VALUES (@hash, @mimetype, @name, @size, @created, @modified, @content, CONVERT(VARBINARY(MAX), @thumbavif), @thumbx, @thumby, @meta) `; export const GET_NEXT_SORT = ` @@ -36,7 +36,8 @@ SELECT f.modified, f.thumbavif, f.thumbx, - f.thumby + f.thumby, + f.meta FROM manifestattachment ma INNER JOIN [file] f ON f.id = ma.file_id WHERE ma.manifest_id = @manifestId @@ -58,7 +59,8 @@ SELECT f.content, f.thumbavif, f.thumbx, - f.thumby + f.thumby, + f.meta FROM manifestattachment ma INNER JOIN [file] f ON f.id = ma.file_id WHERE ma.manifest_id = @manifestId diff --git a/src/store/delivery/attachments.js b/src/store/delivery/attachments.js new file mode 100644 index 0000000..07ba30b --- /dev/null +++ b/src/store/delivery/attachments.js @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto'; +import { LIST_BY_MANIFEST } from '../../sql/manifestattachment.js'; +import request from '../request.js'; + +import { parseMeta } from '../manifestattachment/listByManifestId.js'; + +const ALLOWED_MIMETYPES = new Set([ + 'application/pdf', + 'text/plain', +]); + +export function shapeAttachmentMeta(row) { + return { + id: row.id, + manifestId: row.manifestId, + fileId: row.fileId, + sort: row.sort, + hash: row.hash, + mimetype: row.mimetype, + name: row.name, + size: Number(row.size), + created: row.created, + modified: row.modified, + thumbavif: row.thumbavif ?? null, + thumbx: row.thumbx ?? null, + thumby: row.thumby ?? null, + meta: parseMeta(row.meta), + }; +} + +/** + * Loads attachment metadata (without binary content) for a manifest. + */ +export async function loadAttachments(pool, tx, manifestId) { + const result = await request(pool, tx) + .input('manifestId', manifestId) + .query(LIST_BY_MANIFEST); + + return result.recordset.map(shapeAttachmentMeta); +} + +function normalizeAttachment(raw, index) { + if (typeof raw !== 'object' || raw === null) { + throw new Error('Invalid file'); + } + + const mimetype = + typeof raw.mimetype === 'string' ? raw.mimetype.trim().toLowerCase() : ''; + if (!ALLOWED_MIMETYPES.has(mimetype)) { + throw new Error('Invalid file'); + } + + const name = typeof raw.name === 'string' ? raw.name.trim() : ''; + if (!name) { + throw new Error('Invalid file'); + } + + if (raw.content == null) { + throw new Error('Invalid file'); + } + const content = Buffer.isBuffer(raw.content) + ? raw.content + : Buffer.from(raw.content); + + const size = Number.isFinite(raw.size) && raw.size >= 0 + ? Number(raw.size) + : content.length; + + const hash = + typeof raw.hash === 'string' && raw.hash.trim() + ? raw.hash.trim() + : createHash('sha256').update(content).digest('hex'); + + const parseDate = (value) => { + if (value instanceof Date && !isNaN(value)) return value; + if (typeof value === 'string' || typeof value === 'number') { + const parsed = new Date(value); + return isNaN(parsed) ? null : parsed; + } + return null; + }; + + const created = parseDate(raw.created ?? raw.createdAt) ?? new Date(); + const modified = parseDate(raw.modified ?? raw.modifiedAt) ?? created; + + let meta = null; + if (typeof raw.meta === 'object' && raw.meta !== null && !Array.isArray(raw.meta)) { + meta = {}; + for (const [key, value] of Object.entries(raw.meta)) { + if (value === undefined || value === null) continue; + meta[key] = String(value); + } + if (Object.keys(meta).length === 0) { + meta = null; + } + } + + return { + mimetype, + name, + content, + size, + hash, + created, + modified, + meta, + sort: Number.isFinite(raw.sort) ? Number(raw.sort) : null, + }; +} + +/** + * Inserts pdf/txt attachments for a delivery (manifest) inside an existing + * transaction. Sort orders continue after the current maximum. + * + * @param {object} pool + * @param {object} tx - Active transaction. + * @param {object} sql - SQL statements from delivery.js. + * @param {number} manifestId + * @param {Array<{mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string}>} [files] + * @returns {Promise>} Attachment metadata rows. + */ +export async function insertAttachments(pool, tx, sql, manifestId, files) { + const attachments = (files ?? []).map(normalizeAttachment); + + const sortRes = await request(pool, tx) + .input('manifestId', manifestId) + .query(sql.GET_NEXT_ATTACHMENT_SORT); + let nextSort = sortRes.recordset[0].sort; + + const results = []; + for (const a of attachments) { + const sort = a.sort ?? nextSort; + nextSort = Math.max(nextSort, sort) + 1; + + const fileRes = await request(pool, tx) + .input('hash', a.hash) + .input('mimetype', a.mimetype) + .input('name', a.name) + .input('size', a.size) + .input('created', a.created) + .input('modified', a.modified) + .input('content', a.content) + .input('thumbavif', null) + .input('thumbx', null) + .input('thumby', null) + .input('meta', a.meta === null ? null : JSON.stringify(a.meta)) + .query(sql.CREATE_FILE); + + const fileId = fileRes.recordset[0].id; + + await request(pool, tx) + .input('manifestId', manifestId) + .input('fileId', fileId) + .input('sort', sort) + .query(sql.CREATE_ATTACHMENT); + + results.push({ + manifestId, + fileId, + sort, + hash: a.hash, + mimetype: a.mimetype, + name: a.name, + size: a.size, + created: a.created, + modified: a.modified, + meta: a.meta, + }); + } + + return results; +} diff --git a/src/store/delivery/create.js b/src/store/delivery/create.js index 2814c04..dfae24e 100644 --- a/src/store/delivery/create.js +++ b/src/store/delivery/create.js @@ -1,10 +1,17 @@ -import { CREATE_MANIFEST, DELIVERY_TYPE } from '../../sql/delivery.js'; +import { + CREATE_MANIFEST, + CREATE_FILE, + CREATE_ATTACHMENT, + GET_NEXT_ATTACHMENT_SORT, + DELIVERY_TYPE, +} from '../../sql/delivery.js'; import request from '../request.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import nextNumber from '../numbers/nextNumber.js'; import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { loadDelivery } from './helpers.js'; +import { insertAttachments } from './attachments.js'; import { assertSupplier } from './writeLines.js'; export default function create(pool) { @@ -73,6 +80,18 @@ export default function create(pool) { ensureTax, }); + await insertAttachments( + pool, + tx, + { + GET_NEXT_ATTACHMENT_SORT, + CREATE_FILE, + CREATE_ATTACHMENT, + }, + manifestId, + body.files, + ); + const delivery = await loadDelivery(pool, tx, companyId, manifestId); await tx.commit(); return delivery; diff --git a/src/store/delivery/helpers.js b/src/store/delivery/helpers.js index fa6ec1d..6a7cab1 100644 --- a/src/store/delivery/helpers.js +++ b/src/store/delivery/helpers.js @@ -5,6 +5,7 @@ import { } from '../../sql/delivery.js'; import request from '../request.js'; import { shapeLine } from '../manifestLines.js'; +import { loadAttachments } from './attachments.js'; export function shapeSummary(row) { return { @@ -33,8 +34,11 @@ export async function loadDelivery(pool, tx, companyId, id) { .input('manifestId', id) .query(GET_DELIVERY_LINES); + const files = await loadAttachments(pool, tx, id); + return { ...shapeSummary(row), lines: lines.recordset.map(shapeLine), + files, }; } diff --git a/src/store/delivery/list.js b/src/store/delivery/list.js index aa5beac..722e0c9 100644 --- a/src/store/delivery/list.js +++ b/src/store/delivery/list.js @@ -1,6 +1,8 @@ -import { DELIVERY_TYPE, LIST_DELIVERIES } from '../../sql/delivery.js'; +import { DELIVERY_TYPE, LIST_DELIVERIES, LIST_DELIVERY_FILES } from '../../sql/delivery.js'; +import request from '../request.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { shapeSummary } from './helpers.js'; +import { shapeAttachmentMeta } from './attachments.js'; export default function list(pool) { const access = resolveSelectedCompanyAccess(pool); @@ -24,6 +26,22 @@ export default function list(pool) { .input('type', DELIVERY_TYPE) .query(LIST_DELIVERIES); - return result.recordset.map(shapeSummary); + const filesResult = await pool + .request() + .input('companyId', companyId) + .input('type', DELIVERY_TYPE) + .query(LIST_DELIVERY_FILES); + + const filesByManifest = new Map(); + for (const row of filesResult.recordset) { + const list = filesByManifest.get(row.manifestId) ?? []; + list.push(shapeAttachmentMeta(row)); + filesByManifest.set(row.manifestId, list); + } + + return result.recordset.map((row) => ({ + ...shapeSummary(row), + files: filesByManifest.get(row.id) ?? [], + })); }; } diff --git a/src/store/delivery/update.js b/src/store/delivery/update.js index e4a2ab8..27975ee 100644 --- a/src/store/delivery/update.js +++ b/src/store/delivery/update.js @@ -2,6 +2,9 @@ import { DELETE_LINES_BY_MANIFEST, DELIVERY_TYPE, GET_DELIVERY_HEADER, + GET_NEXT_ATTACHMENT_SORT, + CREATE_FILE, + CREATE_ATTACHMENT, UPDATE_MANIFEST, } from '../../sql/delivery.js'; import request from '../request.js'; @@ -9,6 +12,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { loadDelivery, shapeSummary } from './helpers.js'; +import { insertAttachments } from './attachments.js'; import { assertSupplier } from './writeLines.js'; export default function update(pool) { @@ -46,8 +50,9 @@ export default function update(pool) { ); const hasExternalNumber = Object.prototype.hasOwnProperty.call(patch, 'externalNumber'); const hasLines = Object.prototype.hasOwnProperty.call(patch, 'lines'); + const hasFiles = Object.prototype.hasOwnProperty.call(patch, 'files'); - if (!hasNumber && !hasDate && !hasSupplierId && !hasExternalNumber && !hasLines) { + if (!hasNumber && !hasDate && !hasSupplierId && !hasExternalNumber && !hasLines && !hasFiles) { throw new Error('Invalid delivery'); } @@ -130,6 +135,20 @@ export default function update(pool) { }); } + if (hasFiles) { + await insertAttachments( + pool, + tx, + { + GET_NEXT_ATTACHMENT_SORT, + CREATE_FILE, + CREATE_ATTACHMENT, + }, + id, + patch.files, + ); + } + const delivery = await loadDelivery(pool, tx, companyId, id); await tx.commit(); return delivery; diff --git a/src/store/manifestattachment/add.js b/src/store/manifestattachment/add.js index 6417d9e..3bc3987 100644 --- a/src/store/manifestattachment/add.js +++ b/src/store/manifestattachment/add.js @@ -30,7 +30,8 @@ export default function add(pool) { * @param {Buffer} [input.thumbavif] - Optional AVIF thumbnail buffer. * @param {number} [input.thumbx] - Optional thumbnail width. * @param {number} [input.thumby] - Optional thumbnail height. - * @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>} The created attachment record. + * @param {object} [input.meta] - Optional string key/value metadata stored on the file row. + * @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>} The created attachment record. * @throws {Error} If the manifest is not found or file input is invalid. * * @example @@ -42,7 +43,7 @@ export default function add(pool) { * // => { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby } */ return async (googleAuthId, manifestId, input, ...rest) => { - let rawMimetype, rawName, rawContent, rawHash, rawThumbavif, rawThumbx, rawThumby, rawSort, rawSize, rawCreated, rawModified; + let rawMimetype, rawName, rawContent, rawHash, rawThumbavif, rawThumbx, rawThumby, rawSort, rawSize, rawCreated, rawModified, rawMeta; if (typeof input === 'object' && input !== null && !Buffer.isBuffer(input)) { rawMimetype = input.mimetype; rawName = input.name; @@ -55,6 +56,7 @@ export default function add(pool) { rawSize = input.size; rawCreated = input.created ?? input.createdAt; rawModified = input.modified ?? input.modifiedAt ?? input.lastModified; + rawMeta = input.meta; } else { rawMimetype = input; rawName = rest[0]; @@ -105,6 +107,19 @@ export default function add(pool) { : null; const modified = parsedModified && !isNaN(parsedModified) ? parsedModified : created; + // Simple string key/value metadata; only plain string values are kept. + let meta = null; + if (typeof rawMeta === 'object' && rawMeta !== null && !Array.isArray(rawMeta)) { + meta = {}; + for (const [key, value] of Object.entries(rawMeta)) { + if (value === undefined || value === null) continue; + meta[key] = String(value); + } + if (Object.keys(meta).length === 0) { + meta = null; + } + } + const hash = typeof rawHash === 'string' && rawHash.trim() ? rawHash.trim() @@ -168,6 +183,7 @@ export default function add(pool) { .input('thumbavif', thumbavif) .input('thumbx', thumbx) .input('thumby', thumby) + .input('meta', meta === null ? null : JSON.stringify(meta)) .query(CREATE_FILE); const fileId = fileRes.recordset[0].id; @@ -196,6 +212,7 @@ export default function add(pool) { thumbavif, thumbx, thumby, + meta, }; } catch (err) { try { diff --git a/src/store/manifestattachment/getById.js b/src/store/manifestattachment/getById.js index 697fadc..fd3587b 100644 --- a/src/store/manifestattachment/getById.js +++ b/src/store/manifestattachment/getById.js @@ -1,6 +1,7 @@ import { GET_BY_ID } from '../../sql/manifestattachment.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from './resolveManifestAccess.js'; +import { parseMeta } from './listByManifestId.js'; import request from '../request.js'; export default function getById(pool) { @@ -13,7 +14,7 @@ export default function getById(pool) { * @param {number} googleAuthId - The id of the Google auth user. * @param {number} manifestId - The manifest ID. * @param {number} id - The attachment ID or file ID. - * @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>} The attachment record with binary content. + * @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>} The attachment record with binary content. * @throws {Error} If the manifest or attachment is not found, or user is not authorized. * * @example @@ -53,6 +54,7 @@ export default function getById(pool) { thumbavif: row.thumbavif ?? null, thumbx: row.thumbx ?? null, thumby: row.thumby ?? null, + meta: parseMeta(row.meta), }; }; } diff --git a/src/store/manifestattachment/listByManifestId.js b/src/store/manifestattachment/listByManifestId.js index 19d454b..a954b5b 100644 --- a/src/store/manifestattachment/listByManifestId.js +++ b/src/store/manifestattachment/listByManifestId.js @@ -3,6 +3,18 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA import resolveManifestAccess from './resolveManifestAccess.js'; import request from '../request.js'; +/** Parse the file.meta JSON text column into an object (string key/value). */ +export function parseMeta(raw) { + if (raw == null) return null; + if (typeof raw === 'object') return raw; + try { + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? parsed : null; + } catch { + return null; + } +} + function shape(row) { return { id: row.id, @@ -18,6 +30,7 @@ function shape(row) { thumbavif: row.thumbavif ?? null, thumbx: row.thumbx ?? null, thumby: row.thumby ?? null, + meta: parseMeta(row.meta), }; } @@ -30,7 +43,7 @@ export default function listByManifestId(pool) { * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} manifestId - The manifest ID. - * @returns {Promise>} A list of attachment metadata objects. + * @returns {Promise>} A list of attachment metadata objects. * @throws {Error} If the manifest is not found or the user is not authorized. * * @example