feat: link and unlink document to resource model

This commit is contained in:
Ahmed Bouhuolia
2024-05-24 19:50:06 +02:00
parent c8f31f33be
commit 8f904fae3a
14 changed files with 347 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
import mime from 'mime-types'; import mime from 'mime-types';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import { param } from 'express-validator'; import { body, param } from 'express-validator';
import BaseController from '@/api/controllers/BaseController'; import BaseController from '@/api/controllers/BaseController';
import { Request } from 'express-validator/src/base'; import { Request } from 'express-validator/src/base';
import { AttachmentsApplication } from '@/services/Attachments/AttachmentsApplication'; import { AttachmentsApplication } from '@/services/Attachments/AttachmentsApplication';
@@ -34,6 +34,23 @@ export class AttachmentsController extends BaseController {
this.validationResult, this.validationResult,
this.getAttachment.bind(this) this.getAttachment.bind(this)
); );
router.post(
'/:id/link',
[body('modelRef').exists(), body('modelId').exists()],
this.validationResult
);
router.post(
'/:id/link',
[body('modelRef').exists(), body('modelId').exists()],
this.validationResult,
this.linkDocument.bind(this)
);
router.post(
'/:id/unlink',
[body('modelRef').exists(), body('modelId').exists()],
this.validationResult,
this.unlinkDocument.bind(this)
);
return router; return router;
} }
@@ -50,11 +67,12 @@ export class AttachmentsController extends BaseController {
const file = req.file; const file = req.file;
try { try {
await this.attachmentsApplication.upload(tenantId, file); const data = await this.attachmentsApplication.upload(tenantId, file);
return res.status(200).send({ return res.status(200).send({
status: 200, status: 200,
message: 'The document has uploaded successfully.', message: 'The document has uploaded successfully.',
data,
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -90,10 +108,10 @@ export class AttachmentsController extends BaseController {
} }
/** /**
* Delete * Deletes the given document key.
* @param req * @param {Request} req
* @param res * @param {Response} res
* @param next * @param {NextFunction} next
* @returns * @returns
*/ */
private async deleteAttachment(req: Request, res: Response, next: Function) { private async deleteAttachment(req: Request, res: Response, next: Function) {
@@ -111,4 +129,60 @@ export class AttachmentsController extends BaseController {
next(error); next(error);
} }
} }
/**
* Links the given document key.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns
*/
private async linkDocument(req: Request, res: Response, next: Function) {
const { tenantId } = req;
const { id: documentId } = req.params;
const { modelRef, modelId } = this.matchedBodyData(req);
try {
await this.attachmentsApplication.link(
tenantId,
documentId,
modelRef,
modelId
);
return res.status(200).send({
status: 200,
message: 'The document has been linked successfully.',
});
} catch (error) {
next(error);
}
}
/**
* Links the given document key.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns
*/
private async unlinkDocument(req: Request, res: Response, next: Function) {
const { tenantId } = req;
const { id: documentId } = req.params;
const { modelRef, modelId } = this.matchedBodyData(req);
try {
await this.attachmentsApplication.link(
tenantId,
documentId,
modelRef,
modelId
);
return res.status(200).send({
status: 200,
message: 'The document has been linked successfully.',
});
} catch (error) {
next(error);
}
}
} }

View File

@@ -62,7 +62,7 @@ import { ImportController } from './controllers/Import/ImportController';
import { BankingController } from './controllers/Banking/BankingController'; import { BankingController } from './controllers/Banking/BankingController';
import { Webhooks } from './controllers/Webhooks/Webhooks'; import { Webhooks } from './controllers/Webhooks/Webhooks';
import { ExportController } from './controllers/Export/ExportController'; import { ExportController } from './controllers/Export/ExportController';
import { AttachmentsController } from './controllers/AttachmentsController'; import { AttachmentsController } from './controllers/Attachments/AttachmentsController';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -71,7 +71,7 @@ export default () => {
// --------------------------- // ---------------------------
app.use(asyncRenderMiddleware); app.use(asyncRenderMiddleware);
app.use(I18nMiddleware); app.use(I18nMiddleware);
app.use('/auth', Container.get(Authentication).router()); app.use('/auth', Container.get(Authentication).router());
app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter());
app.use('/subscription', Container.get(SubscriptionController).router()); app.use('/subscription', Container.get(SubscriptionController).router());
@@ -80,7 +80,6 @@ export default () => {
app.use('/jobs', Container.get(Jobs).router()); app.use('/jobs', Container.get(Jobs).router());
app.use('/account', Container.get(Account).router()); app.use('/account', Container.get(Account).router());
app.use('/webhooks', Container.get(Webhooks).router()); app.use('/webhooks', Container.get(Webhooks).router());
app.use('/attachments', Container.get(AttachmentsController).router());
// - Dashboard routes. // - Dashboard routes.
// --------------------------- // ---------------------------
@@ -145,6 +144,7 @@ export default () => {
dashboard.use('/tax-rates', Container.get(TaxRatesController).router()); dashboard.use('/tax-rates', Container.get(TaxRatesController).router());
dashboard.use('/import', Container.get(ImportController).router()); dashboard.use('/import', Container.get(ImportController).router());
dashboard.use('/export', Container.get(ExportController).router()); dashboard.use('/export', Container.get(ExportController).router());
dashboard.use('/attachments', Container.get(AttachmentsController).router());
dashboard.use('/', Container.get(ProjectTasksController).router()); dashboard.use('/', Container.get(ProjectTasksController).router());
dashboard.use('/', Container.get(ProjectTimesController).router()); dashboard.use('/', Container.get(ProjectTimesController).router());

View File

@@ -0,0 +1,13 @@
exports.up = function (knex) {
return knex.schema.createTable('document_links', (table) => {
table.increments('id').primary();
table.string('model_ref').notNullable();
table.string('model_id').notNullable();
table.integer('document_id').unsigned();
table.timestamps();
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('document_links');
};

View File

@@ -60,9 +60,10 @@ import Time from 'models/Time';
import Task from 'models/Task'; import Task from 'models/Task';
import TaxRate from 'models/TaxRate'; import TaxRate from 'models/TaxRate';
import TaxRateTransaction from 'models/TaxRateTransaction'; import TaxRateTransaction from 'models/TaxRateTransaction';
import Attachment from 'models/Attachment';
import PlaidItem from 'models/PlaidItem'; import PlaidItem from 'models/PlaidItem';
import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction'; import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction';
import Document from '@/models/Document';
import DocumentLink from '@/models/DocumentLink';
export default (knex) => { export default (knex) => {
const models = { const models = {
@@ -126,9 +127,10 @@ export default (knex) => {
Task, Task,
TaxRate, TaxRate,
TaxRateTransaction, TaxRateTransaction,
Attachment, Document,
DocumentLink,
PlaidItem, PlaidItem,
UncategorizedCashflowTransaction UncategorizedCashflowTransaction,
}; };
return mapValues(models, (model) => model.bindKnex(knex)); return mapValues(models, (model) => model.bindKnex(knex));
}; };

View File

@@ -3,7 +3,7 @@ import TenantModel from 'models/TenantModel';
import ModelSetting from './ModelSetting'; import ModelSetting from './ModelSetting';
import ModelSearchable from './ModelSearchable'; import ModelSearchable from './ModelSearchable';
export default class Attachment extends mixin(TenantModel, [ export default class Document extends mixin(TenantModel, [
ModelSetting, ModelSetting,
ModelSearchable, ModelSearchable,
]) { ]) {

View File

@@ -0,0 +1,44 @@
import { Model, mixin } from 'objection';
import TenantModel from 'models/TenantModel';
import ModelSetting from './ModelSetting';
import ModelSearchable from './ModelSearchable';
export default class DocumentLink extends mixin(TenantModel, [
ModelSetting,
ModelSearchable,
]) {
/**
* Table name
*/
static get tableName() {
return 'document_links';
}
/**
* Model timestamps.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Document = require('models/Document');
return {
/**
* Sale invoice associated entries.
*/
document: {
relation: Model.HasOneRelation,
modelClass: Document.default,
join: {
from: 'document_links.documentId',
to: 'documents.id',
},
},
};
}
}

View File

@@ -410,6 +410,7 @@ export default class SaleInvoice extends mixin(TenantModel, [
const Branch = require('models/Branch'); const Branch = require('models/Branch');
const Account = require('models/Account'); const Account = require('models/Account');
const TaxRateTransaction = require('models/TaxRateTransaction'); const TaxRateTransaction = require('models/TaxRateTransaction');
const DocumentLink = require('models/DocumentLink');
return { return {
/** /**
@@ -523,6 +524,21 @@ export default class SaleInvoice extends mixin(TenantModel, [
builder.where('reference_type', 'SaleInvoice'); builder.where('reference_type', 'SaleInvoice');
}, },
}, },
/**
* Invoice may has many attachments.
*/
attachments: {
relation: Model.HasManyRelation,
modelClass: DocumentLink.default,
join: {
from: 'sales_invoices.id',
to: 'document_links.modelId',
},
filter: (builder) => {
builder.where('modelRef', 'SaleInvoice');
},
},
}; };
} }

View File

@@ -3,6 +3,8 @@ import { UploadDocument } from './UploadDocument';
import { DeleteAttachment } from './DeleteAttachment'; import { DeleteAttachment } from './DeleteAttachment';
import { GetAttachment } from './GetAttachment'; import { GetAttachment } from './GetAttachment';
import { AttachmentUploadPipeline } from './S3UploadPipeline'; import { AttachmentUploadPipeline } from './S3UploadPipeline';
import { LinkAttachment } from './LinkAttachment';
import { UnlinkAttachment } from './UnlinkAttachment';
@Service() @Service()
export class AttachmentsApplication { export class AttachmentsApplication {
@@ -15,18 +17,25 @@ export class AttachmentsApplication {
@Inject() @Inject()
private getDocumentService: GetAttachment; private getDocumentService: GetAttachment;
@Inject()
private uploadPipelineService: AttachmentUploadPipeline; private uploadPipelineService: AttachmentUploadPipeline;
@Inject()
private linkDocumentService: LinkAttachment;
@Inject()
private unlinkDocumentService: UnlinkAttachment;
/** /**
* *
* @returns * @returns
*/ */
get uploadPipeline() { get uploadPipeline() {
return this.uploadPipelineService.uploadPipeline(); return this.uploadPipelineService.uploadPipeline();
} }
/** /**
* * Uploads
* @param {number} tenantId * @param {number} tenantId
* @param {} file * @param {} file
* @returns * @returns
@@ -53,4 +62,43 @@ export class AttachmentsApplication {
public get(tenantId: number, documentKey: string) { public get(tenantId: number, documentKey: string) {
return this.getDocumentService.getAttachment(tenantId, documentKey); return this.getDocumentService.getAttachment(tenantId, documentKey);
} }
/**
* Links the given document to resource model.
* @param {number} tenantId
* @param {string} filekey
* @param {string} modelRef
* @param {number} modelId
* @returns
*/
public link(
tenantId: number,
filekey: string,
modelRef: string,
modelId: number
) {
return this.linkDocumentService.link(tenantId, filekey, modelRef, modelId);
}
/**
* Unlinks the given document from resource model.
* @param {number} tenantId
* @param {string} filekey
* @param {string} modelRef
* @param {number} modelId
* @returns
*/
public unlink(
tenantId: number,
filekey: string,
modelRef: string,
modelId: number
) {
return this.unlinkDocumentService.unlink(
tenantId,
filekey,
modelRef,
modelId
);
}
} }

View File

@@ -1,19 +1,35 @@
import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { s3 } from '@/lib/S3/S3'; import { s3 } from '@/lib/S3/S3';
import { Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
@Service() @Service()
export class DeleteAttachment { export class DeleteAttachment {
@Inject()
private tenancy: HasTenancyService;
/** /**
* Deletes the give file attachment file key. * Deletes the give file attachment file key.
* @param {number} tenantId * @param {number} tenantId
* @param {string} filekey * @param {string} filekey
*/ */
async delete(tenantId: number, filekey: string): Promise<void> { async delete(tenantId: number, filekey: string): Promise<void> {
const { Document, DocumentLink } = this.tenancy.models(tenantId);
const params = { const params = {
Bucket: process.env.AWS_BUCKET, Bucket: process.env.AWS_BUCKET,
Key: filekey, Key: filekey,
}; };
await s3.send(new DeleteObjectCommand(params)); await s3.send(new DeleteObjectCommand(params));
const foundDocument = await Document.query()
.findOne('key', filekey)
.throwIfNotFound();
// Delete all document links
await DocumentLink.query().where('documentId', foundDocument.id).delete();
// Delete thedocument.
await Document.query().findById(foundDocument.id).delete();
} }
} }

View File

@@ -0,0 +1,49 @@
import { Inject, Service } from 'typedi';
import {
validateLinkModelEntryExists,
validateLinkModelExists,
} from './_utils';
import HasTenancyService from '../Tenancy/TenancyService';
import { ServiceError } from '@/exceptions';
@Service()
export class LinkAttachment {
@Inject()
private tenancy: HasTenancyService;
/**
*
* @param {number} tenantId
* @param {string} filekey
* @param {string} modelRef
* @param {number} modelId
*/
async link(
tenantId: number,
filekey: string,
modelRef: string,
modelId: number
) {
const { DocumentLink, Document, ...models } = this.tenancy.models(tenantId);
const LinkModel = models[modelRef];
validateLinkModelExists(LinkModel);
const foundLinkModel = await LinkModel.query().findById(modelId);
validateLinkModelEntryExists(foundLinkModel);
const foundLinks = await DocumentLink.query()
.where('modelRef', modelRef)
.where('modelId', modelId);
if (foundLinks.length > 0) {
throw new ServiceError('');
}
const foundFile = await Document.query().findOne('key', filekey);
await DocumentLink.query().insert({
modelRef,
modelId,
documentId: foundFile.id,
});
}
}

View File

@@ -0,0 +1,39 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import {
validateLinkModelEntryExists,
validateLinkModelExists,
} from './_utils';
@Service()
export class UnlinkAttachment {
@Inject()
private tenancy: HasTenancyService;
/**
*
* @param {number} tenantId
* @param {string} filekey
* @param {string} modelRef
* @param {number} modelId
*/
async unlink(
tenantId: number,
filekey: string,
modelRef: string,
modelId: number
) {
const { DocumentLink, ...models } = this.tenancy.models(tenantId);
const LinkModel = models[modelRef];
validateLinkModelExists(LinkModel);
const foundLinkModel = await LinkModel.query().findById(modelId);
validateLinkModelEntryExists(foundLinkModel);
// Delete the document link.
await DocumentLink.query()
.where('modelRef', modelRef)
.where('modelId', modelId)
.delete();
}
}

View File

@@ -1,10 +1,18 @@
import { Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import HasTenancyService from '../Tenancy/TenancyService';
@Service() @Service()
export class UploadDocument { export class UploadDocument {
@Inject()
private tenancy: HasTenancyService;
async upload(tenantId: number, file: any) { async upload(tenantId: number, file: any) {
const { Document } = this.tenancy.models(tenantId);
const insertedDocument = await Document.query().insert({
key: file.key,
extension: file.mimetype,
});
return insertedDocument;
} }
} }

View File

@@ -0,0 +1,14 @@
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
export const validateLinkModelExists = (LinkModel) => {
if (!LinkModel) {
throw new ServiceError(ERRORS.DOCUMENT_LINK_REF_INVALID);
}
};
export const validateLinkModelEntryExists = (foundLinkModel) => {
if (!foundLinkModel) {
throw new ServiceError(ERRORS.DOCUMENT_LINK_ID_INVALID);
}
};

View File

@@ -0,0 +1,4 @@
export enum ERRORS {
DOCUMENT_LINK_REF_INVALID = 'DOCUMENT_LINK_REF_INVALID',
DOCUMENT_LINK_ID_INVALID = 'DOCUMENT_LINK_ID_INVALID',
}