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'; export default function add(pool) { const access = resolveSelectedCompanyAccess(pool); const manifestAccess = resolveManifestAccess(pool); /** * Attaches a file to an existing manifest. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} manifestId - The id of the manifest. * @param {object} input - The file data. * @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. * @param {number} [input.sort] - Optional display sort order. * @param {number} [input.size] - Optional file size in bytes (defaults to content length). * @param {Date|string} [input.created] - Optional creation timestamp (defaults to current date). * @param {Date|string} [input.modified] - Optional modified timestamp (defaults to created date). * @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. * @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 * const attachment = await store.manifestattachment.add(42, 1, { * mimetype: 'application/pdf', * name: 'invoice.pdf', * content: fileBuffer, * }); * // => { 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, rawMeta; 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; rawSize = input.size; rawCreated = input.created ?? input.createdAt; rawModified = input.modified ?? input.modifiedAt ?? input.lastModified; rawMeta = input.meta; } else { rawMimetype = input; rawName = rest[0]; rawContent = rest[1]; } const mimetype = typeof rawMimetype === 'string' ? rawMimetype.trim() : null; if (!mimetype) { throw new Error('Mimetype required'); } const name = typeof rawName === 'string' ? rawName.trim() : null; if (!name) { throw new Error('File name required'); } if (rawContent == null) { throw new Error('Content required'); } const content = Buffer.isBuffer(rawContent) ? rawContent : Buffer.from(rawContent); if (rawSort !== undefined && rawSort !== null) { if (!Number.isFinite(rawSort)) { throw new Error('Invalid sort'); } } const size = Number.isFinite(rawSize) && rawSize >= 0 ? Number(rawSize) : content.length; const parsedCreated = rawCreated instanceof Date && !isNaN(rawCreated) ? rawCreated : typeof rawCreated === 'string' || typeof rawCreated === 'number' ? new Date(rawCreated) : null; const created = parsedCreated && !isNaN(parsedCreated) ? parsedCreated : new Date(); const parsedModified = rawModified instanceof Date && !isNaN(rawModified) ? rawModified : typeof rawModified === 'string' || typeof rawModified === 'number' ? new Date(rawModified) : 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() : 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); const tx = pool.transaction(); 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('size', size) .input('created', created) .input('modified', modified) .input('content', content) .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; const attachmentRes = await request(pool, tx) .input('manifestId', manifestId) .input('fileId', fileId) .input('sort', sort) .query(CREATE_ATTACHMENT); const attachmentId = attachmentRes.recordset[0].id; await tx.commit(); return { id: attachmentId, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta, }; } catch (err) { try { await tx.rollback(); } catch { // ignore } throw err; } }; }