From cfb7f86f0e785a9c2ddfa68fb87dee07412da540 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 18 Sep 2026 01:21:54 +0200 Subject: [PATCH] feat: add hash and thumbnail columns to file, sort column and order method to manifestattachment --- API.manifestattachment.md | 47 ++++- API.md | 7 +- schema-graph.dot | 6 +- schema-graph.svg | 170 ++++++++++-------- src/index.js | 2 + src/schema.js | 10 +- src/sql/manifestattachment.js | 49 ++++- src/store/manifestattachment/add.js | 85 ++++++++- src/store/manifestattachment/getById.js | 9 +- .../manifestattachment/listByManifestId.js | 9 +- src/store/manifestattachment/order.js | 88 +++++++++ 11 files changed, 378 insertions(+), 104 deletions(-) create mode 100644 src/store/manifestattachment/order.js diff --git a/API.manifestattachment.md b/API.manifestattachment.md index 8c81864..ffcc058 100644 --- a/API.manifestattachment.md +++ b/API.manifestattachment.md @@ -18,10 +18,15 @@ Attaches a file to an existing manifest. | `input.mimetype` | `string` | The MIME type of the file (e.g. `'application/pdf'`). | | `input.name` | `string` | The file name. | | `input.content` | `Buffer` | The file content. | +| `[input.sort]` | `number` | Optional display sort order. | +| `[input.hash]` | `string` | The file hash (auto-computed SHA-256 if omitted). | +| `[input.thumbavif]` | `Buffer` | Optional AVIF thumbnail buffer. | +| `[input.thumbx]` | `number` | Optional thumbnail width. | +| `[input.thumby]` | `number` | Optional thumbnail height. | **Returns** -`Promise<{id: number, manifestId: number, fileId: number, mimetype: string, name: string}>` — The created attachment record. +`Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>` — The created attachment record. **Throws** @@ -35,7 +40,7 @@ const attachment = await store.manifestattachment.add(42, 1, { name: 'invoice.pdf', content: fileBuffer, }); -// => { id, manifestId, fileId, mimetype, name } +// => { id, manifestId, fileId, sort, hash, mimetype, name, thumbavif, thumbx, thumby } ``` ## `manifestattachment.getById` @@ -54,7 +59,7 @@ Gets an attachment for a manifest by its attachment ID or file ID, including ful **Returns** -`Promise<{id: number, manifestId: number, fileId: number, mimetype: string, name: string, content: Buffer}>` — The attachment record with binary content. +`Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>` — The attachment record with binary content. **Throws** @@ -64,7 +69,7 @@ Gets an attachment for a manifest by its attachment ID or file ID, including ful ```js const attachment = await store.manifestattachment.getById(42, 1, 5); -// => { id, manifestId, fileId, mimetype, name, content } +// => { id, manifestId, fileId, sort, hash, mimetype, name, content, thumbavif, thumbx, thumby } ``` ## `manifestattachment.listByManifestId` @@ -82,7 +87,7 @@ Lists all attachments for a given manifest, without loading their full binary co **Returns** -`Promise>` — A list of attachment metadata objects. +`Promise>` — A list of attachment metadata objects. **Throws** @@ -92,7 +97,37 @@ Lists all attachments for a given manifest, without loading their full binary co ```js const attachments = await store.manifestattachment.listByManifestId(42, 1); -// => [{ id, manifestId, fileId, mimetype, name, size }] +// => [{ id, manifestId, fileId, sort, hash, mimetype, name, size, thumbavif, thumbx, thumby }] +``` + +## `manifestattachment.order` + +*Source: `src/store/manifestattachment/order.js`* + +Reorders the attachments of a manifest. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `manifestId` | `number` | The manifest ID. | +| `input` | `object|Array` | The reorder input or array of IDs. | +| `[input.ids]` | `Array` | The attachment ids in their new order. | + +**Returns** + +`Promise<{ids: Array}>` — The applied order. + +**Throws** + +`Error` — If the order is invalid. + +**Example** + +```js +const result = await store.manifestattachment.order(42, 1, { ids: [12, 10, 15] }); +// => { ids: [12, 10, 15] } ``` ## `manifestattachment.remove` diff --git a/API.md b/API.md index cd92a91..e0aac71 100644 --- a/API.md +++ b/API.md @@ -123,9 +123,10 @@ 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}): Promise<{id: number, manifestId: number, fileId: number, mimetype: string, name: string}>` - - `getById(googleAuthId: number, manifestId: number, id: number): Promise<{id: number, manifestId: number, fileId: number, mimetype: string, name: string, content: Buffer}>` - - `listByManifestId(googleAuthId: number, manifestId: number): Promise>` + - `add(googleAuthId: number, manifestId: number, input: {mimetype: string, name: string, content: Buffer, sort?: number, hash?: string, thumbavif?: Buffer, thumbx?: number, thumby?: number}): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, 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, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>` + - `listByManifestId(googleAuthId: number, manifestId: number): Promise>` + - `order(googleAuthId: number, manifestId: number, input: {ids?: Array}): Promise<{ids: Array}>` - `remove(googleAuthId: number, manifestId: number, id: number): Promise` - [posterminal](API.posterminal.md) - `delete(googleAuthId: number, id: number): Promise` diff --git a/schema-graph.dot b/schema-graph.dot index 5379078..a755cc1 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
mimetypeNVARCHAR(255)
nameNVARCHAR(255)
contentVARBINARY(MAX)
>]; + file [label=<
file
idINT PK
hashNVARCHAR(64)
mimetypeNVARCHAR(255)
nameNVARCHAR(255)
contentVARBINARY(MAX)
thumbavif?VARBINARY(MAX)
thumbx?INT
thumby?INT
>]; } subgraph cluster_manifests { label="Documents"; labeljust="l"; fontsize=15; @@ -64,7 +64,7 @@ digraph schema { manifestarticle [label=<
manifestarticle
idINT PK
manifest_idINT FK
sku?NVARCHAR(100)
name?NVARCHAR(255)
puFLOAT
amountFLOAT
netFLOAT
comment?NVARCHAR(MAX)
rebateFLOAT
taxrateFLOAT
>]; ledger [label=<
ledger
idINT PK
manifestarticle_idINT FK
transaction_typeNVARCHAR(100)
amountFLOAT
transaction_textNVARCHAR(MAX)
hash_versionINT
prev_hash?NVARCHAR(128)
>]; manifest [label=<
manifest
idINT PK
entity_id?INT FK
company_idINT FK
numberNVARCHAR(100)
typeNVARCHAR(100)
external_number?NVARCHAR(100)
dateDATETIME2
>]; - manifestattachment [label=<
manifestattachment
idINT PK
manifest_idINT FK
file_idINT FK
>]; + manifestattachment [label=<
manifestattachment
idINT PK
manifest_idINT FK
file_idINT FK
sortINT
>]; } subgraph cluster_commerce { label="Pricing"; labeljust="l"; fontsize=15; @@ -139,7 +139,7 @@ digraph schema { articlesupplier:price_id:e -> price:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_articlesupplier_price", constraint=false, weight=1]; manifest:company_id:e -> company:in1:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_manifest_company", weight=3]; manifest:entity_id:e -> entity:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_manifest_entity", constraint=false, weight=1]; - manifestarticle:manifest_id:ne -> manifest:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_manifestarticle_manifest", weight=3]; + manifestarticle:manifest_id:se -> manifest:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_manifestarticle_manifest", weight=3]; ledger:manifestarticle_id:sw -> manifestarticle:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_ledger_manifestarticle", weight=3]; numbers:company_id:e -> company:in3:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_numbers_company", weight=3]; companyuser:google_auth_id:e -> google_auth:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_companyuser_google_auth", constraint=false, weight=1]; diff --git a/schema-graph.svg b/schema-graph.svg index 5d99c2e..908dc0e 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -4,11 +4,11 @@ - + schema - + cluster_identity @@ -26,7 +26,7 @@ cluster_media - + Media @@ -1597,27 +1597,43 @@ file - - - -file - - -id -INT -PK - - -mimetype -NVARCHAR(255) - - -name -NVARCHAR(255) - - -content -VARBINARY(MAX) + + + +file + + +id +INT +PK + + +hash +NVARCHAR(64) + + +mimetype +NVARCHAR(255) + + +name +NVARCHAR(255) + + +content +VARBINARY(MAX) + + +thumbavif? +VARBINARY(MAX) + + +thumbx? +INT + + +thumby? +INT @@ -1712,10 +1728,10 @@ -manifestarticle:ne->manifest:n +manifestarticle:se->manifest:n - - + + @@ -1786,31 +1802,35 @@ manifestattachment - - -manifestattachment - - -id -INT -PK - - -manifest_id -INT -FK - - -file_id -INT -FK + + +manifestattachment + + +id +INT +PK + + +manifest_id +INT +FK + + +file_id +INT +FK + + +sort +INT manifestattachment:e->file:n - - + + @@ -1818,8 +1838,8 @@ manifestattachment:sw->manifest:n - - + + @@ -1924,7 +1944,7 @@ posterminalcategory:e->category:n - + @@ -1941,30 +1961,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/index.js b/src/index.js index 3a7c106..fec6598 100644 --- a/src/index.js +++ b/src/index.js @@ -119,6 +119,7 @@ import manifestattachmentAdd from './store/manifestattachment/add.js'; import manifestattachmentRemove from './store/manifestattachment/remove.js'; import manifestattachmentListByManifestId from './store/manifestattachment/listByManifestId.js'; import manifestattachmentGetById from './store/manifestattachment/getById.js'; +import manifestattachmentOrder from './store/manifestattachment/order.js'; export default class LibDashStore { constructor(pool) { @@ -310,6 +311,7 @@ export default class LibDashStore { remove: manifestattachmentRemove(pool), listByManifestId: manifestattachmentListByManifestId(pool), getById: manifestattachmentGetById(pool), + order: manifestattachmentOrder(pool), }; this.purge = storePurge(pool); diff --git a/src/schema.js b/src/schema.js index 2d3bb7e..b51f134 100644 --- a/src/schema.js +++ b/src/schema.js @@ -773,9 +773,13 @@ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'file') BEGIN CREATE TABLE [file] ( id INT IDENTITY(1,1) PRIMARY KEY, + hash NVARCHAR(64) NOT NULL, mimetype NVARCHAR(255) NOT NULL, name NVARCHAR(255) NOT NULL, - content VARBINARY(MAX) NOT NULL + content VARBINARY(MAX) NOT NULL, + thumbavif VARBINARY(MAX) NULL, + thumbx INT NULL, + thumby INT NULL ); END `; @@ -787,8 +791,10 @@ BEGIN id INT IDENTITY(1,1) PRIMARY KEY, manifest_id INT NOT NULL, file_id INT NOT NULL, + sort INT NOT NULL CONSTRAINT DF_manifestattachment_sort DEFAULT 0, CONSTRAINT FK_manifestattachment_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, - CONSTRAINT FK_manifestattachment_file FOREIGN KEY (file_id) REFERENCES [file](id) ON DELETE CASCADE + CONSTRAINT FK_manifestattachment_file FOREIGN KEY (file_id) REFERENCES [file](id) ON DELETE CASCADE, + CONSTRAINT UQ_manifestattachment_manifest_sort UNIQUE (manifest_id, sort) ); END `; diff --git a/src/sql/manifestattachment.js b/src/sql/manifestattachment.js index 12ea537..892c1cd 100644 --- a/src/sql/manifestattachment.js +++ b/src/sql/manifestattachment.js @@ -5,15 +5,21 @@ WHERE id = @manifestId AND company_id = @companyId `; export const CREATE_FILE = ` -INSERT INTO [file] (mimetype, name, content) +INSERT INTO [file] (hash, mimetype, name, content, thumbavif, thumbx, thumby) OUTPUT INSERTED.id -VALUES (@mimetype, @name, @content) +VALUES (@hash, @mimetype, @name, @content, CONVERT(VARBINARY(MAX), @thumbavif), @thumbx, @thumby) +`; + +export const GET_NEXT_SORT = ` +SELECT ISNULL(MAX(sort), -1) + 1 AS sort +FROM manifestattachment +WHERE manifest_id = @manifestId; `; export const CREATE_ATTACHMENT = ` -INSERT INTO manifestattachment (manifest_id, file_id) +INSERT INTO manifestattachment (manifest_id, file_id, sort) OUTPUT INSERTED.id -VALUES (@manifestId, @fileId) +VALUES (@manifestId, @fileId, @sort) `; export const LIST_BY_MANIFEST = ` @@ -21,13 +27,18 @@ SELECT ma.id, ma.manifest_id AS manifestId, ma.file_id AS fileId, + ma.sort, + f.hash, f.mimetype, f.name, - DATALENGTH(f.content) AS size + DATALENGTH(f.content) AS size, + f.thumbavif, + f.thumbx, + f.thumby FROM manifestattachment ma INNER JOIN [file] f ON f.id = ma.file_id WHERE ma.manifest_id = @manifestId -ORDER BY ma.id ASC +ORDER BY ma.sort ASC, ma.id ASC `; export const GET_BY_ID = ` @@ -35,9 +46,14 @@ SELECT ma.id, ma.manifest_id AS manifestId, ma.file_id AS fileId, + ma.sort, + f.hash, f.mimetype, f.name, - f.content + f.content, + f.thumbavif, + f.thumbx, + f.thumby FROM manifestattachment ma INNER JOIN [file] f ON f.id = ma.file_id WHERE ma.manifest_id = @manifestId @@ -60,3 +76,22 @@ export const DELETE_FILE = ` DELETE FROM [file] WHERE id = @fileId `; + +export const LIST_IDS_BY_MANIFEST = ` +SELECT id +FROM manifestattachment +WHERE manifest_id = @manifestId +ORDER BY sort ASC, id ASC; +`; + +export const SET_TEMP_SORTS = ` +UPDATE manifestattachment +SET sort = -id +WHERE manifest_id = @manifestId; +`; + +export const UPDATE_SORT = ` +UPDATE manifestattachment +SET sort = @sort +WHERE id = @id AND manifest_id = @manifestId; +`; diff --git a/src/store/manifestattachment/add.js b/src/store/manifestattachment/add.js index 88b76ee..d703857 100644 --- a/src/store/manifestattachment/add.js +++ b/src/store/manifestattachment/add.js @@ -1,4 +1,10 @@ -import { CREATE_FILE, CREATE_ATTACHMENT } from '../../sql/manifestattachment.js'; +import { createHash } from 'node:crypto'; +import sharp from 'sharp'; +import { + CREATE_FILE, + CREATE_ATTACHMENT, + GET_NEXT_SORT, +} from '../../sql/manifestattachment.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from './resolveManifestAccess.js'; import request from '../request.js'; @@ -16,7 +22,12 @@ export default function add(pool) { * @param {string} input.mimetype - The MIME type of the file (e.g. `'application/pdf'`). * @param {string} input.name - The file name. * @param {Buffer} input.content - The file content. - * @returns {Promise<{id: number, manifestId: number, fileId: number, mimetype: string, name: string}>} The created attachment record. + * @param {number} [input.sort] - Optional display sort order. + * @param {string} [input.hash] - The file hash (auto-computed SHA-256 if omitted). + * @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, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>} The created attachment record. * @throws {Error} If the manifest is not found or file input is invalid. * * @example @@ -25,14 +36,19 @@ export default function add(pool) { * name: 'invoice.pdf', * content: fileBuffer, * }); - * // => { id, manifestId, fileId, mimetype, name } + * // => { id, manifestId, fileId, sort, hash, mimetype, name, thumbavif, thumbx, thumby } */ return async (googleAuthId, manifestId, input, ...rest) => { - let rawMimetype, rawName, rawContent; + let rawMimetype, rawName, rawContent, rawHash, rawThumbavif, rawThumbx, rawThumby, rawSort; if (typeof input === 'object' && input !== null && !Buffer.isBuffer(input)) { rawMimetype = input.mimetype; rawName = input.name; rawContent = input.content; + rawHash = input.hash; + rawThumbavif = input.thumbavif; + rawThumbx = input.thumbx ?? input.thumbw; + rawThumby = input.thumby ?? input.thumbh; + rawSort = input.sort; } else { rawMimetype = input; rawName = rest[0]; @@ -56,6 +72,49 @@ export default function add(pool) { ? rawContent : Buffer.from(rawContent); + if (rawSort !== undefined && rawSort !== null) { + if (!Number.isFinite(rawSort)) { + throw new Error('Invalid sort'); + } + } + + const hash = + typeof rawHash === 'string' && rawHash.trim() + ? rawHash.trim() + : createHash('sha256').update(content).digest('hex'); + + let thumbavif = rawThumbavif + ? (Buffer.isBuffer(rawThumbavif) ? rawThumbavif : Buffer.from(rawThumbavif)) + : null; + let thumbx = Number.isFinite(rawThumbx) ? Number(rawThumbx) : null; + let thumby = Number.isFinite(rawThumby) ? Number(rawThumby) : null; + + if (thumbavif && (!thumbx || !thumby)) { + try { + const meta = await sharp(thumbavif).metadata(); + thumbx = thumbx ?? meta.width ?? null; + thumby = thumby ?? meta.height ?? null; + } catch { + // ignore + } + } + + if (!thumbavif) { + try { + const thumbResult = await sharp(content) + .resize(200, 200, { fit: 'inside', withoutEnlargement: true }) + .avif({ effort: 4 }) + .toBuffer({ resolveWithObject: true }); + thumbavif = thumbResult.data; + thumbx = thumbResult.info.width; + thumby = thumbResult.info.height; + } catch { + thumbavif = null; + thumbx = null; + thumby = null; + } + } + const { companyId } = await access(googleAuthId); await manifestAccess(companyId, manifestId); @@ -63,10 +122,22 @@ export default function add(pool) { await tx.begin(); try { + let sort = rawSort; + if (sort === undefined || sort === null) { + const sortRes = await request(pool, tx) + .input('manifestId', manifestId) + .query(GET_NEXT_SORT); + sort = sortRes.recordset[0].sort; + } + const fileRes = await request(pool, tx) + .input('hash', hash) .input('mimetype', mimetype) .input('name', name) .input('content', content) + .input('thumbavif', thumbavif) + .input('thumbx', thumbx) + .input('thumby', thumby) .query(CREATE_FILE); const fileId = fileRes.recordset[0].id; @@ -74,6 +145,7 @@ export default function add(pool) { const attachmentRes = await request(pool, tx) .input('manifestId', manifestId) .input('fileId', fileId) + .input('sort', sort) .query(CREATE_ATTACHMENT); const attachmentId = attachmentRes.recordset[0].id; @@ -84,8 +156,13 @@ export default function add(pool) { id: attachmentId, manifestId, fileId, + sort, + hash, mimetype, name, + thumbavif, + thumbx, + thumby, }; } catch (err) { try { diff --git a/src/store/manifestattachment/getById.js b/src/store/manifestattachment/getById.js index 635ad8d..de31007 100644 --- a/src/store/manifestattachment/getById.js +++ b/src/store/manifestattachment/getById.js @@ -13,12 +13,12 @@ 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, mimetype: string, name: string, content: Buffer}>} The attachment record with binary content. + * @returns {Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>} The attachment record with binary content. * @throws {Error} If the manifest or attachment is not found, or user is not authorized. * * @example * const attachment = await store.manifestattachment.getById(42, 1, 5); - * // => { id, manifestId, fileId, mimetype, name, content } + * // => { id, manifestId, fileId, sort, hash, mimetype, name, content, thumbavif, thumbx, thumby } */ return async (googleAuthId, manifestId, id) => { if (!Number.isFinite(id)) { @@ -42,9 +42,14 @@ export default function getById(pool) { id: row.id, manifestId: row.manifestId, fileId: row.fileId, + sort: row.sort, + hash: row.hash, mimetype: row.mimetype, name: row.name, content: row.content, + thumbavif: row.thumbavif ?? null, + thumbx: row.thumbx ?? null, + thumby: row.thumby ?? null, }; }; } diff --git a/src/store/manifestattachment/listByManifestId.js b/src/store/manifestattachment/listByManifestId.js index a9cfd44..f8dbeb9 100644 --- a/src/store/manifestattachment/listByManifestId.js +++ b/src/store/manifestattachment/listByManifestId.js @@ -8,9 +8,14 @@ function shape(row) { id: row.id, manifestId: row.manifestId, fileId: row.fileId, + sort: row.sort, + hash: row.hash, mimetype: row.mimetype, name: row.name, size: row.size, + thumbavif: row.thumbavif ?? null, + thumbx: row.thumbx ?? null, + thumby: row.thumby ?? null, }; } @@ -23,12 +28,12 @@ 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 * const attachments = await store.manifestattachment.listByManifestId(42, 1); - * // => [{ id, manifestId, fileId, mimetype, name, size }] + * // => [{ id, manifestId, fileId, sort, hash, mimetype, name, size, thumbavif, thumbx, thumby }] */ return async (googleAuthId, manifestId) => { const { companyId } = await access(googleAuthId); diff --git a/src/store/manifestattachment/order.js b/src/store/manifestattachment/order.js new file mode 100644 index 0000000..c5cd2fb --- /dev/null +++ b/src/store/manifestattachment/order.js @@ -0,0 +1,88 @@ +import { + LIST_IDS_BY_MANIFEST, + SET_TEMP_SORTS, + UPDATE_SORT, +} from '../../sql/manifestattachment.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import resolveManifestAccess from './resolveManifestAccess.js'; +import request from '../request.js'; + +export default function order(pool) { + const access = resolveSelectedCompanyAccess(pool); + const manifestAccess = resolveManifestAccess(pool); + + /** + * Reorders the attachments of a manifest. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} manifestId - The manifest ID. + * @param {object|Array} input - The reorder input or array of IDs. + * @param {Array} [input.ids] - The attachment ids in their new order. + * @returns {Promise<{ids: Array}>} The applied order. + * @throws {Error} If the order is invalid. + * + * @example + * const result = await store.manifestattachment.order(42, 1, { ids: [12, 10, 15] }); + * // => { ids: [12, 10, 15] } + */ + return async (googleAuthId, manifestId, input) => { + const rawIds = Array.isArray(input) ? input : input?.ids; + if (!Array.isArray(rawIds)) { + throw new Error('Invalid attachment order'); + } + + const { companyId } = await access(googleAuthId); + await manifestAccess(companyId, manifestId); + + const current = await pool + .request() + .input('manifestId', manifestId) + .query(LIST_IDS_BY_MANIFEST); + + const existing = new Set(current.recordset.map((r) => r.id)); + + if (rawIds.length !== existing.size) { + throw new Error('Invalid attachment order'); + } + + const seen = new Set(); + for (const id of rawIds) { + if (!Number.isFinite(id) || !existing.has(id) || seen.has(id)) { + throw new Error('Invalid attachment order'); + } + seen.add(id); + } + + const tx = pool.transaction(); + await tx.begin(); + + try { + await request(pool, tx) + .input('manifestId', manifestId) + .query(SET_TEMP_SORTS); + + for (let sort = 0; sort < rawIds.length; sort++) { + const updateRes = await request(pool, tx) + .input('id', rawIds[sort]) + .input('manifestId', manifestId) + .input('sort', sort) + .query(UPDATE_SORT); + + if (!updateRes.rowsAffected[0]) { + throw new Error('Invalid attachment order'); + } + } + + await tx.commit(); + + return { ids: rawIds }; + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + }; +}