feat: manage files via manifestattachment with auth and cascade trigger, format API.md method signatures
This commit is contained in:
11
src/index.js
11
src/index.js
@@ -110,6 +110,10 @@ import numbersList from './store/numbers/list.js';
|
||||
import numbersUpdate from './store/numbers/update.js';
|
||||
import storePurge from './store/purge.js';
|
||||
import storeInitialize from './store/initialize.js';
|
||||
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';
|
||||
|
||||
export default class LibDashStore {
|
||||
constructor(pool) {
|
||||
@@ -288,6 +292,13 @@ export default class LibDashStore {
|
||||
update: numbersUpdate(pool),
|
||||
};
|
||||
|
||||
this.manifestattachment = {
|
||||
add: manifestattachmentAdd(pool),
|
||||
remove: manifestattachmentRemove(pool),
|
||||
listByManifestId: manifestattachmentListByManifestId(pool),
|
||||
getById: manifestattachmentGetById(pool),
|
||||
};
|
||||
|
||||
this.purge = storePurge(pool);
|
||||
this.initialize = storeInitialize(pool);
|
||||
|
||||
|
||||
@@ -765,6 +765,52 @@ BEGIN
|
||||
END
|
||||
`;
|
||||
|
||||
export const INIT_FILE = `
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'file')
|
||||
BEGIN
|
||||
CREATE TABLE [file] (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
mimetype NVARCHAR(255) NOT NULL,
|
||||
name NVARCHAR(255) NOT NULL,
|
||||
content VARBINARY(MAX) NOT NULL
|
||||
);
|
||||
END
|
||||
`;
|
||||
|
||||
export const INIT_MANIFESTATTACHMENT = `
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'manifestattachment')
|
||||
BEGIN
|
||||
CREATE TABLE manifestattachment (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
manifest_id INT NOT NULL,
|
||||
file_id INT NOT NULL,
|
||||
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
|
||||
);
|
||||
END
|
||||
`;
|
||||
|
||||
export const INIT_MANIFESTATTACHMENT_TRIGGER = `
|
||||
IF OBJECT_ID(N'dbo.TR_manifestattachment_delete_file', N'TR') IS NOT NULL
|
||||
DROP TRIGGER dbo.TR_manifestattachment_delete_file;
|
||||
|
||||
EXEC('
|
||||
CREATE TRIGGER dbo.TR_manifestattachment_delete_file
|
||||
ON manifestattachment
|
||||
AFTER DELETE
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
DELETE f
|
||||
FROM [file] f
|
||||
INNER JOIN deleted d ON f.id = d.file_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM manifestattachment ma WHERE ma.file_id = f.id
|
||||
);
|
||||
END
|
||||
');
|
||||
`;
|
||||
|
||||
/** DDL scripts in dependency order for schema init. */
|
||||
export const SCHEMA_SCRIPTS = [
|
||||
INIT_META,
|
||||
@@ -810,4 +856,8 @@ export const SCHEMA_SCRIPTS = [
|
||||
INIT_COMPANYSETTING,
|
||||
INIT_POSTERMINAL,
|
||||
INIT_POSTERMINALCATEGORY,
|
||||
INIT_FILE,
|
||||
INIT_MANIFESTATTACHMENT,
|
||||
INIT_MANIFESTATTACHMENT_TRIGGER,
|
||||
];
|
||||
|
||||
|
||||
62
src/sql/manifestattachment.js
Normal file
62
src/sql/manifestattachment.js
Normal file
@@ -0,0 +1,62 @@
|
||||
export const CHECK_MANIFEST_ACCESS = `
|
||||
SELECT id, company_id AS companyId
|
||||
FROM manifest
|
||||
WHERE id = @manifestId AND company_id = @companyId
|
||||
`;
|
||||
|
||||
export const CREATE_FILE = `
|
||||
INSERT INTO [file] (mimetype, name, content)
|
||||
OUTPUT INSERTED.id
|
||||
VALUES (@mimetype, @name, @content)
|
||||
`;
|
||||
|
||||
export const CREATE_ATTACHMENT = `
|
||||
INSERT INTO manifestattachment (manifest_id, file_id)
|
||||
OUTPUT INSERTED.id
|
||||
VALUES (@manifestId, @fileId)
|
||||
`;
|
||||
|
||||
export const LIST_BY_MANIFEST = `
|
||||
SELECT
|
||||
ma.id,
|
||||
ma.manifest_id AS manifestId,
|
||||
ma.file_id AS fileId,
|
||||
f.mimetype,
|
||||
f.name,
|
||||
DATALENGTH(f.content) AS size
|
||||
FROM manifestattachment ma
|
||||
INNER JOIN [file] f ON f.id = ma.file_id
|
||||
WHERE ma.manifest_id = @manifestId
|
||||
ORDER BY ma.id ASC
|
||||
`;
|
||||
|
||||
export const GET_BY_ID = `
|
||||
SELECT
|
||||
ma.id,
|
||||
ma.manifest_id AS manifestId,
|
||||
ma.file_id AS fileId,
|
||||
f.mimetype,
|
||||
f.name,
|
||||
f.content
|
||||
FROM manifestattachment ma
|
||||
INNER JOIN [file] f ON f.id = ma.file_id
|
||||
WHERE ma.manifest_id = @manifestId
|
||||
AND (ma.id = @id OR ma.file_id = @id)
|
||||
`;
|
||||
|
||||
export const FIND_ATTACHMENT = `
|
||||
SELECT ma.id, ma.file_id AS fileId
|
||||
FROM manifestattachment ma
|
||||
WHERE ma.manifest_id = @manifestId
|
||||
AND (ma.id = @id OR ma.file_id = @id)
|
||||
`;
|
||||
|
||||
export const DELETE_ATTACHMENT = `
|
||||
DELETE FROM manifestattachment
|
||||
WHERE id = @id
|
||||
`;
|
||||
|
||||
export const DELETE_FILE = `
|
||||
DELETE FROM [file]
|
||||
WHERE id = @fileId
|
||||
`;
|
||||
99
src/store/manifestattachment/add.js
Normal file
99
src/store/manifestattachment/add.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
50
src/store/manifestattachment/getById.js
Normal file
50
src/store/manifestattachment/getById.js
Normal 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
43
src/store/manifestattachment/listByManifestId.js
Normal file
43
src/store/manifestattachment/listByManifestId.js
Normal 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);
|
||||
};
|
||||
}
|
||||
44
src/store/manifestattachment/remove.js
Normal file
44
src/store/manifestattachment/remove.js
Normal 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);
|
||||
};
|
||||
}
|
||||
22
src/store/manifestattachment/resolveManifestAccess.js
Normal file
22
src/store/manifestattachment/resolveManifestAccess.js
Normal 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user