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

@@ -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);

View File

@@ -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
`;

View File

@@ -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;
`;

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