diff --git a/API.labeltemplate.md b/API.labeltemplate.md new file mode 100644 index 0000000..76aaa47 --- /dev/null +++ b/API.labeltemplate.md @@ -0,0 +1,146 @@ +# labeltemplate + +This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. + +## `labeltemplate.create` + +*Source: `src/store/labeltemplate/create.js`* + +Creates a new label template for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `input` | `object` | Label template input. | +| `input.name` | `string` | Template name. | +| `input.width` | `number` | Canvas width in pixels. | +| `input.height` | `number` | Canvas height in pixels. | +| `input.dpi` | `number` | Printer DPI (203 or 300). | +| `input.type` | `string` | Template type (`"zpl"`). | +| `input.content` | `object` | Template content (JSON — label editor objects). | +| `[input.thumb]` | `Buffer\|null` | Optional pre-rendered 50×50 1-bit PNG thumbnail. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>` + +**Throws** + +`Error` — If name, content or dimensions are missing, or dpi/type is invalid. + +**Example** + +```js +const template = await store.labeltemplate.create(googleAuthId, { + name: 'Product Label', + width: 448, + height: 200, + dpi: 203, + type: 'zpl', + content: { objects: [] }, +}); +``` + +## `labeltemplate.delete` + +*Source: `src/store/labeltemplate/delete.js`* + +Deletes a label template for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The label template ID to delete. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise` + +**Throws** + +`Error` — If template is not found. + +## `labeltemplate.getById` + +*Source: `src/store/labeltemplate/getById.js`* + +Retrieves a label template by its ID for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The label template ID. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}|undefined>` + +**Example** + +```js +const template = await store.labeltemplate.getById(googleAuthId, 1); +// => { id: 1, company_id: 1, name: 'Product Label', width: 448, height: 200, dpi: 203, type: 'zpl', ... } +``` + +## `labeltemplate.list` + +*Source: `src/store/labeltemplate/list.js`* + +Lists all label templates for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise>` + +**Example** + +```js +const templates = await store.labeltemplate.list(42); +// => [{ id: 1, company_id: 1, name: 'Product Label', ... }] +``` + +## `labeltemplate.update` + +*Source: `src/store/labeltemplate/update.js`* + +Updates a label template for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The label template ID to update. | +| `input` | `object` | Update payload (all fields optional). | +| `[input.name]` | `string` | Template name. | +| `[input.width]` | `number` | Canvas width in pixels. | +| `[input.height]` | `number` | Canvas height in pixels. | +| `[input.dpi]` | `number` | Printer DPI (203 or 300). | +| `[input.type]` | `string` | Template type (`"zpl"`). | +| `[input.content]` | `object` | Template content (JSON). | +| `[input.thumb]` | `Buffer\|null` | 50×50 1-bit PNG thumbnail. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>` + +**Throws** + +`Error` — If template not found, name is empty, or dpi/type is invalid. diff --git a/src/index.js b/src/index.js index f6a7dda..4b6aff2 100644 --- a/src/index.js +++ b/src/index.js @@ -139,6 +139,11 @@ import printerList from './store/printer/list.js'; import printerUpdate from './store/printer/update.js'; import printerDelete from './store/printer/delete.js'; import printerOrder from './store/printer/order.js'; +import labeltemplateCreate from './store/labeltemplate/create.js'; +import labeltemplateGetById from './store/labeltemplate/getById.js'; +import labeltemplateList from './store/labeltemplate/list.js'; +import labeltemplateUpdate from './store/labeltemplate/update.js'; +import labeltemplateDelete from './store/labeltemplate/delete.js'; import stockAdjust from './store/stock/adjust.js'; export default class LibDashStore { @@ -377,6 +382,14 @@ export default class LibDashStore { order: printerOrder(provider), }; + this.labeltemplate = { + create: labeltemplateCreate(provider), + getById: labeltemplateGetById(provider), + list: labeltemplateList(provider), + update: labeltemplateUpdate(provider), + delete: labeltemplateDelete(provider), + }; + this.stock = { adjust: stockAdjust(provider), }; diff --git a/src/providers/mssql/purgeSql.js b/src/providers/mssql/purgeSql.js index c1e7bb5..f882166 100644 --- a/src/providers/mssql/purgeSql.js +++ b/src/providers/mssql/purgeSql.js @@ -1,6 +1,7 @@ /** Drop order = reverse of schema create dependency order. */ export const TABLES = [ 'manifestattachment', + 'labeltemplate', 'file', 'companyuser', 'syncpos', diff --git a/src/providers/mssql/queries/labeltemplate.js b/src/providers/mssql/queries/labeltemplate.js new file mode 100644 index 0000000..bccf3dd --- /dev/null +++ b/src/providers/mssql/queries/labeltemplate.js @@ -0,0 +1,87 @@ +export const LABELTEMPLATE_COLUMNS = ` + id, + company_id, + name, + width, + height, + dpi, + type, + content, + thumb +`; + +export const LIST_BY_COMPANY = ` +SELECT +${LABELTEMPLATE_COLUMNS} +FROM labeltemplate +WHERE company_id = @companyId +ORDER BY id ASC +`; + +export const GET_BY_COMPANY_AND_ID = ` +SELECT +${LABELTEMPLATE_COLUMNS} +FROM labeltemplate +WHERE id = @id AND company_id = @companyId +`; + +export const CREATE = ` +INSERT INTO labeltemplate ( + company_id, + name, + width, + height, + dpi, + type, + content, + thumb +) +OUTPUT + INSERTED.id, + INSERTED.company_id, + INSERTED.name, + INSERTED.width, + INSERTED.height, + INSERTED.dpi, + INSERTED.type, + INSERTED.content, + INSERTED.thumb +VALUES ( + @companyId, + @name, + @width, + @height, + @dpi, + @type, + @content, + @thumb +) +`; + +export const UPDATE = ` +UPDATE labeltemplate +SET + name = @name, + width = @width, + height = @height, + dpi = @dpi, + type = @type, + content = @content, + thumb = @thumb +OUTPUT + INSERTED.id, + INSERTED.company_id, + INSERTED.name, + INSERTED.width, + INSERTED.height, + INSERTED.dpi, + INSERTED.type, + INSERTED.content, + INSERTED.thumb +WHERE id = @id AND company_id = @companyId +`; + +export const DELETE_BY_COMPANY_AND_ID = ` +DELETE FROM labeltemplate +WHERE id = @id AND company_id = @companyId +`; diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index 354e963..34b6c6c 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -816,6 +816,26 @@ END `; +export const INIT_LABELTEMPLATE = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'labeltemplate') +BEGIN + CREATE TABLE labeltemplate ( + id INT IDENTITY(1,1) PRIMARY KEY, + company_id INT NOT NULL, + name NVARCHAR(255) NOT NULL, + width INT NOT NULL, + height INT NOT NULL, + dpi INT NOT NULL, + type NVARCHAR(50) NOT NULL, + content NVARCHAR(MAX) NOT NULL, + thumb VARBINARY(MAX) NULL, + CONSTRAINT FK_labeltemplate_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE, + CONSTRAINT CK_labeltemplate_dpi CHECK (dpi IN (203, 300)), + CONSTRAINT CK_labeltemplate_type CHECK (type IN ('zpl')) + ); +END +`; + export const INIT_FILE = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'file') BEGIN @@ -919,6 +939,7 @@ export const SCHEMA_SCRIPTS = [ INIT_POSTERMINAL, INIT_POSTERMINALCATEGORY, INIT_PRINTER, + INIT_LABELTEMPLATE, INIT_FILE, INIT_MANIFESTATTACHMENT, INIT_MANIFESTATTACHMENT_TRIGGER, diff --git a/src/providers/sqlite3/queries/labeltemplate.js b/src/providers/sqlite3/queries/labeltemplate.js new file mode 100644 index 0000000..acc8276 --- /dev/null +++ b/src/providers/sqlite3/queries/labeltemplate.js @@ -0,0 +1,12 @@ +import * as source from '../../mssql/queries/labeltemplate.js'; +import { translateQueries } from './translate.js'; + +const queries = translateQueries(source, 'labeltemplate'); +export const { + CREATE, + DELETE_BY_COMPANY_AND_ID, + GET_BY_COMPANY_AND_ID, + LABELTEMPLATE_COLUMNS, + LIST_BY_COMPANY, + UPDATE, +} = queries; diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 93e8b94..aefc7e4 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -565,6 +565,21 @@ CREATE TABLE IF NOT EXISTS printer ( `; +export const INIT_LABELTEMPLATE = ` +CREATE TABLE IF NOT EXISTS labeltemplate ( + id INTEGER PRIMARY KEY, + company_id INTEGER NOT NULL, + name TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + dpi INTEGER NOT NULL CHECK (dpi IN (203, 300)), + type TEXT NOT NULL CHECK (type IN ('zpl')), + content TEXT NOT NULL, + thumb BLOB, + FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE +); +`; + export const INIT_FILE = ` CREATE TABLE IF NOT EXISTS "file" ( id INTEGER PRIMARY KEY, @@ -648,6 +663,7 @@ export const SCHEMA_SCRIPTS = [ INIT_POSTERMINAL, INIT_POSTERMINALCATEGORY, INIT_PRINTER, + INIT_LABELTEMPLATE, INIT_FILE, INIT_MANIFESTATTACHMENT, INIT_MANIFESTATTACHMENT_TRIGGER, diff --git a/src/store/labeltemplate/create.js b/src/store/labeltemplate/create.js new file mode 100644 index 0000000..a0760ff --- /dev/null +++ b/src/store/labeltemplate/create.js @@ -0,0 +1,121 @@ +import { + CREATE as MSSQL_CREATE, +} from '../../providers/mssql/queries/labeltemplate.js'; +import { + CREATE as SQLITE_CREATE, +} from '../../providers/sqlite3/queries/labeltemplate.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +const VALID_TYPES = new Set(['zpl']); +const VALID_DPIS = new Set([203, 300]); + +function shape(row) { + return { + id: row.id, + company_id: row.company_id ?? row.companyId, + name: row.name, + width: row.width, + height: row.height, + dpi: row.dpi, + type: row.type, + content: typeof row.content === 'string' ? JSON.parse(row.content) : row.content, + thumb: row.thumb ?? null, + }; +} + +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + + /** + * Creates a new label template for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} input - Label template input. + * @param {string} input.name - Template name. + * @param {number} input.width - Canvas width in pixels. + * @param {number} input.height - Canvas height in pixels. + * @param {number} input.dpi - Printer DPI (203 or 300). + * @param {string} input.type - Template type ("zpl"). + * @param {object} input.content - Template content (JSON). + * @param {Buffer|null} [input.thumb] - 50×50 1-bit PNG thumbnail buffer. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>} + * @throws {Error} If required fields are missing or invalid. + * + * @example + * const template = await store.labeltemplate.create(googleAuthId, { + * name: 'Product Label', + * width: 448, + * height: 200, + * dpi: 203, + * type: 'zpl', + * content: { objects: [] }, + * }); + */ + return async (googleAuthId, input = {}, tx) => { + const name = typeof input.name === 'string' ? input.name.trim() : ''; + if (!name) { + throw new Error('Name required'); + } + + const width = Number(input.width); + if (!Number.isInteger(width) || width <= 0) { + throw new Error('Invalid width'); + } + + const height = Number(input.height); + if (!Number.isInteger(height) || height <= 0) { + throw new Error('Invalid height'); + } + + const dpi = Number(input.dpi); + if (!VALID_DPIS.has(dpi)) { + throw new Error('Invalid dpi: must be 203 or 300'); + } + + const type = typeof input.type === 'string' ? input.type.trim() : ''; + if (!type || !VALID_TYPES.has(type)) { + throw new Error('Invalid type: must be "zpl"'); + } + + if (input.content == null) { + throw new Error('Content required'); + } + const content = typeof input.content === 'string' + ? input.content + : JSON.stringify(input.content); + + const thumb = input.thumb instanceof Buffer ? input.thumb : null; + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const result = await runner.query(createSql, { + companyId, + name, + width, + height, + dpi, + type, + content, + thumb, + }); + + const row = result.rows[0] ?? { + id: result.insertId, + company_id: companyId, + name, + width, + height, + dpi, + type, + content, + thumb, + }; + + return shape(row); + }; +} diff --git a/src/store/labeltemplate/delete.js b/src/store/labeltemplate/delete.js new file mode 100644 index 0000000..5606ea8 --- /dev/null +++ b/src/store/labeltemplate/delete.js @@ -0,0 +1,35 @@ +import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/labeltemplate.js'; +import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/labeltemplate.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; + + /** + * Deletes a label template for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The label template ID to delete. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise} + * @throws {Error} If template is not found. + */ + return async (googleAuthId, id, tx) => { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + throw new Error('Label template not found'); + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const result = await runner.query(sql, { id: numericId, companyId }); + + if (!result.rowsAffected) { + throw new Error('Label template not found'); + } + }; +} diff --git a/src/store/labeltemplate/getById.js b/src/store/labeltemplate/getById.js new file mode 100644 index 0000000..f11df90 --- /dev/null +++ b/src/store/labeltemplate/getById.js @@ -0,0 +1,49 @@ +import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/labeltemplate.js'; +import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/labeltemplate.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +function shape(row) { + return { + id: row.id, + company_id: row.company_id ?? row.companyId, + name: row.name, + width: row.width, + height: row.height, + dpi: row.dpi, + type: row.type, + content: typeof row.content === 'string' ? JSON.parse(row.content) : row.content, + thumb: row.thumb ?? null, + }; +} + +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + + /** + * Retrieves a label template by its ID for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The label template ID. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}|undefined>} + * + * @example + * const template = await store.labeltemplate.getById(googleAuthId, 1); + */ + return async (googleAuthId, id, tx) => { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + return undefined; + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const result = await runner.query(sql, { id: numericId, companyId }); + const row = result.rows[0]; + return row ? shape(row) : undefined; + }; +} diff --git a/src/store/labeltemplate/list.js b/src/store/labeltemplate/list.js new file mode 100644 index 0000000..0173a11 --- /dev/null +++ b/src/store/labeltemplate/list.js @@ -0,0 +1,44 @@ +import { LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY } from '../../providers/mssql/queries/labeltemplate.js'; +import { LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY } from '../../providers/sqlite3/queries/labeltemplate.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +function shape(row) { + return { + id: row.id, + company_id: row.company_id ?? row.companyId, + name: row.name, + width: row.width, + height: row.height, + dpi: row.dpi, + type: row.type, + content: typeof row.content === 'string' ? JSON.parse(row.content) : row.content, + thumb: row.thumb ?? null, + }; +} + +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' + ? SQLITE_LIST_BY_COMPANY + : MSSQL_LIST_BY_COMPANY; + + /** + * Lists all label templates for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise>} + * + * @example + * const templates = await store.labeltemplate.list(42); + */ + return async (googleAuthId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const result = await runner.query(sql, { companyId }); + return result.rows.map(shape); + }; +} diff --git a/src/store/labeltemplate/update.js b/src/store/labeltemplate/update.js new file mode 100644 index 0000000..94c6cd4 --- /dev/null +++ b/src/store/labeltemplate/update.js @@ -0,0 +1,141 @@ +import { + GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID, + UPDATE as MSSQL_UPDATE, +} from '../../providers/mssql/queries/labeltemplate.js'; +import { + GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID, + UPDATE as SQLITE_UPDATE, +} from '../../providers/sqlite3/queries/labeltemplate.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +const VALID_TYPES = new Set(['zpl']); +const VALID_DPIS = new Set([203, 300]); + +function shape(row) { + return { + id: row.id, + company_id: row.company_id ?? row.companyId, + name: row.name, + width: row.width, + height: row.height, + dpi: row.dpi, + type: row.type, + content: typeof row.content === 'string' ? JSON.parse(row.content) : row.content, + thumb: row.thumb ?? null, + }; +} + +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; + + /** + * Updates a label template for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The label template ID to update. + * @param {object} input - Update payload. + * @param {string} [input.name] - Template name. + * @param {number} [input.width] - Canvas width in pixels. + * @param {number} [input.height] - Canvas height in pixels. + * @param {number} [input.dpi] - Printer DPI (203 or 300). + * @param {string} [input.type] - Template type ("zpl"). + * @param {object} [input.content] - Template content (JSON). + * @param {Buffer|null} [input.thumb] - 50×50 1-bit PNG thumbnail buffer. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, company_id: number, name: string, width: number, height: number, dpi: number, type: string, content: object, thumb: Buffer|null}>} + * @throws {Error} If template not found or input invalid. + */ + return async (googleAuthId, id, input = {}, tx) => { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + throw new Error('Label template not found'); + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const existing = await runner.query(getSql, { id: numericId, companyId }); + const current = existing.rows[0]; + if (!current) { + throw new Error('Label template not found'); + } + + let name = current.name; + if (input.name !== undefined) { + const cleaned = typeof input.name === 'string' ? input.name.trim() : ''; + if (!cleaned) throw new Error('Name required'); + name = cleaned; + } + + let width = current.width; + if (input.width !== undefined) { + const w = Number(input.width); + if (!Number.isInteger(w) || w <= 0) throw new Error('Invalid width'); + width = w; + } + + let height = current.height; + if (input.height !== undefined) { + const h = Number(input.height); + if (!Number.isInteger(h) || h <= 0) throw new Error('Invalid height'); + height = h; + } + + let dpi = current.dpi; + if (input.dpi !== undefined) { + const d = Number(input.dpi); + if (!VALID_DPIS.has(d)) throw new Error('Invalid dpi: must be 203 or 300'); + dpi = d; + } + + let type = current.type; + if (input.type !== undefined) { + const cleaned = typeof input.type === 'string' ? input.type.trim() : ''; + if (!cleaned || !VALID_TYPES.has(cleaned)) throw new Error('Invalid type: must be "zpl"'); + type = cleaned; + } + + let content = current.content; + if (input.content !== undefined) { + if (input.content == null) throw new Error('Content required'); + content = typeof input.content === 'string' + ? input.content + : JSON.stringify(input.content); + } + + let thumb = current.thumb ?? null; + if (input.thumb !== undefined) { + thumb = input.thumb instanceof Buffer ? input.thumb : null; + } + + const result = await runner.query(updateSql, { + id: numericId, + companyId, + name, + width, + height, + dpi, + type, + content, + thumb, + }); + + const row = result.rows[0] ?? { + id: numericId, + company_id: companyId, + name, + width, + height, + dpi, + type, + content, + thumb, + }; + + return shape(row); + }; +}