feat: add hash and thumbnail columns to file, sort column and order method to manifestattachment

This commit is contained in:
seb
2026-09-18 01:21:54 +02:00
parent 980eda629b
commit cfb7f86f0e
11 changed files with 378 additions and 104 deletions

View File

@@ -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 {

View File

@@ -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,
};
};
}

View File

@@ -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<Array<{id: number, manifestId: number, fileId: number, mimetype: string, name: string, size: number}>>} A list of attachment metadata objects.
* @returns {Promise<Array<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>>} 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);

View File

@@ -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<number>} input - The reorder input or array of IDs.
* @param {Array<number>} [input.ids] - The attachment ids in their new order.
* @returns {Promise<{ids: Array<number>}>} 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;
}
};
}