feat: manage files via manifestattachment with auth and cascade trigger, format API.md method signatures

This commit is contained in:
seb
2026-09-16 16:49:59 +02:00
parent 8b97948a75
commit 8f23dd8c75
14 changed files with 2058 additions and 1362 deletions

View File

@@ -0,0 +1,99 @@
import { CREATE_FILE, CREATE_ATTACHMENT } 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.
* @returns {Promise<object>} 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, mimetype, name }
*/
return async (googleAuthId, manifestId, input, ...rest) => {
let rawMimetype, rawName, rawContent;
if (typeof input === 'object' && input !== null && !Buffer.isBuffer(input)) {
rawMimetype = input.mimetype;
rawName = input.name;
rawContent = input.content;
} 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);
const { companyId } = await access(googleAuthId);
await manifestAccess(companyId, manifestId);
const tx = pool.transaction();
await tx.begin();
try {
const fileRes = await request(pool, tx)
.input('mimetype', mimetype)
.input('name', name)
.input('content', content)
.query(CREATE_FILE);
const fileId = fileRes.recordset[0].id;
const attachmentRes = await request(pool, tx)
.input('manifestId', manifestId)
.input('fileId', fileId)
.query(CREATE_ATTACHMENT);
const attachmentId = attachmentRes.recordset[0].id;
await tx.commit();
return {
id: attachmentId,
manifestId,
fileId,
mimetype,
name,
};
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
};
}

View File

@@ -0,0 +1,50 @@
import { GET_BY_ID } from '../../sql/manifestattachment.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveManifestAccess from './resolveManifestAccess.js';
import request from '../request.js';
export default function getById(pool) {
const access = resolveSelectedCompanyAccess(pool);
const manifestAccess = resolveManifestAccess(pool);
/**
* Gets an attachment for a manifest by its attachment ID or file ID, including full file content.
*
* @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<object>} 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 }
*/
return async (googleAuthId, manifestId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Attachment not found');
}
const { companyId } = await access(googleAuthId);
await manifestAccess(companyId, manifestId);
const res = await request(pool)
.input('manifestId', manifestId)
.input('id', id)
.query(GET_BY_ID);
const row = res.recordset[0];
if (!row) {
throw new Error('Attachment not found');
}
return {
id: row.id,
manifestId: row.manifestId,
fileId: row.fileId,
mimetype: row.mimetype,
name: row.name,
content: row.content,
};
};
}

View File

@@ -0,0 +1,43 @@
import { LIST_BY_MANIFEST } from '../../sql/manifestattachment.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveManifestAccess from './resolveManifestAccess.js';
import request from '../request.js';
function shape(row) {
return {
id: row.id,
manifestId: row.manifestId,
fileId: row.fileId,
mimetype: row.mimetype,
name: row.name,
size: row.size,
};
}
export default function listByManifestId(pool) {
const access = resolveSelectedCompanyAccess(pool);
const manifestAccess = resolveManifestAccess(pool);
/**
* Lists all attachments for a given manifest, without loading their full binary content.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} manifestId - The manifest ID.
* @returns {Promise<Array<object>>} 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 }]
*/
return async (googleAuthId, manifestId) => {
const { companyId } = await access(googleAuthId);
await manifestAccess(companyId, manifestId);
const res = await request(pool)
.input('manifestId', manifestId)
.query(LIST_BY_MANIFEST);
return res.recordset.map(shape);
};
}

View File

@@ -0,0 +1,44 @@
import { FIND_ATTACHMENT, DELETE_FILE } from '../../sql/manifestattachment.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import resolveManifestAccess from './resolveManifestAccess.js';
import request from '../request.js';
export default function remove(pool) {
const access = resolveSelectedCompanyAccess(pool);
const manifestAccess = resolveManifestAccess(pool);
/**
* Removes a file attachment from a manifest and deletes the underlying file.
*
* @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 to remove.
* @returns {Promise<void>}
* @throws {Error} If the manifest or attachment is not found, or user is not authorized.
*
* @example
* await store.manifestattachment.remove(42, 1, 5);
*/
return async (googleAuthId, manifestId, id) => {
if (!Number.isFinite(id)) {
throw new Error('Attachment not found');
}
const { companyId } = await access(googleAuthId);
await manifestAccess(companyId, manifestId);
const findRes = await request(pool)
.input('manifestId', manifestId)
.input('id', id)
.query(FIND_ATTACHMENT);
const row = findRes.recordset[0];
if (!row) {
throw new Error('Attachment not found');
}
await request(pool)
.input('fileId', row.fileId)
.query(DELETE_FILE);
};
}

View File

@@ -0,0 +1,22 @@
import { CHECK_MANIFEST_ACCESS } from '../../sql/manifestattachment.js';
import request from '../request.js';
/** Ensure manifest belongs to company. Throws Manifest not found. */
export default function resolveManifestAccess(pool) {
return async (companyId, manifestId, tx) => {
if (!Number.isFinite(manifestId)) {
throw new Error('Manifest not found');
}
const result = await request(pool, tx)
.input('manifestId', manifestId)
.input('companyId', companyId)
.query(CHECK_MANIFEST_ACCESS);
const row = result.recordset[0];
if (!row) {
throw new Error('Manifest not found');
}
return row;
};
}