From 8f904fae3ae71fa04ccfba759aa7fb351773ffc9 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 24 May 2024 19:50:06 +0200 Subject: [PATCH] feat: link and unlink document to resource model --- .../AttachmentsController.ts | 86 +++++++++++++++++-- packages/server/src/api/index.ts | 6 +- .../20231108170207_create_documents_links.ts | 13 +++ packages/server/src/loaders/tenantModels.ts | 8 +- .../src/models/{Attachment.ts => Document.ts} | 2 +- packages/server/src/models/DocumentLink.ts | 44 ++++++++++ packages/server/src/models/SaleInvoice.ts | 16 ++++ .../Attachments/AttachmentsApplication.ts | 54 +++++++++++- .../services/Attachments/DeleteAttachment.ts | 18 +++- .../services/Attachments/LinkAttachment.ts | 49 +++++++++++ .../services/Attachments/UnlinkAttachment.ts | 39 +++++++++ .../services/Attachments/UploadDocument.ts | 14 ++- .../server/src/services/Attachments/_utils.ts | 14 +++ .../src/services/Attachments/constants.ts | 4 + 14 files changed, 347 insertions(+), 20 deletions(-) rename packages/server/src/api/controllers/{ => Attachments}/AttachmentsController.ts (55%) create mode 100644 packages/server/src/database/migrations/20231108170207_create_documents_links.ts rename packages/server/src/models/{Attachment.ts => Document.ts} (86%) create mode 100644 packages/server/src/models/DocumentLink.ts create mode 100644 packages/server/src/services/Attachments/LinkAttachment.ts create mode 100644 packages/server/src/services/Attachments/UnlinkAttachment.ts create mode 100644 packages/server/src/services/Attachments/_utils.ts create mode 100644 packages/server/src/services/Attachments/constants.ts diff --git a/packages/server/src/api/controllers/AttachmentsController.ts b/packages/server/src/api/controllers/Attachments/AttachmentsController.ts similarity index 55% rename from packages/server/src/api/controllers/AttachmentsController.ts rename to packages/server/src/api/controllers/Attachments/AttachmentsController.ts index 0c6d5a851..55150945d 100644 --- a/packages/server/src/api/controllers/AttachmentsController.ts +++ b/packages/server/src/api/controllers/Attachments/AttachmentsController.ts @@ -1,7 +1,7 @@ import mime from 'mime-types'; import { Service, Inject } from 'typedi'; import { Router, Response } from 'express'; -import { param } from 'express-validator'; +import { body, param } from 'express-validator'; import BaseController from '@/api/controllers/BaseController'; import { Request } from 'express-validator/src/base'; import { AttachmentsApplication } from '@/services/Attachments/AttachmentsApplication'; @@ -34,6 +34,23 @@ export class AttachmentsController extends BaseController { this.validationResult, 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; } @@ -50,11 +67,12 @@ export class AttachmentsController extends BaseController { const file = req.file; try { - await this.attachmentsApplication.upload(tenantId, file); + const data = await this.attachmentsApplication.upload(tenantId, file); return res.status(200).send({ status: 200, message: 'The document has uploaded successfully.', + data, }); } catch (error) { next(error); @@ -90,10 +108,10 @@ export class AttachmentsController extends BaseController { } /** - * Delete - * @param req - * @param res - * @param next + * Deletes the given document key. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next * @returns */ private async deleteAttachment(req: Request, res: Response, next: Function) { @@ -111,4 +129,60 @@ export class AttachmentsController extends BaseController { 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); + } + } } diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 6970a8831..ac8b2fda7 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -62,7 +62,7 @@ import { ImportController } from './controllers/Import/ImportController'; import { BankingController } from './controllers/Banking/BankingController'; import { Webhooks } from './controllers/Webhooks/Webhooks'; import { ExportController } from './controllers/Export/ExportController'; -import { AttachmentsController } from './controllers/AttachmentsController'; +import { AttachmentsController } from './controllers/Attachments/AttachmentsController'; export default () => { const app = Router(); @@ -71,7 +71,7 @@ export default () => { // --------------------------- app.use(asyncRenderMiddleware); app.use(I18nMiddleware); - + app.use('/auth', Container.get(Authentication).router()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); app.use('/subscription', Container.get(SubscriptionController).router()); @@ -80,7 +80,6 @@ export default () => { app.use('/jobs', Container.get(Jobs).router()); app.use('/account', Container.get(Account).router()); app.use('/webhooks', Container.get(Webhooks).router()); - app.use('/attachments', Container.get(AttachmentsController).router()); // - Dashboard routes. // --------------------------- @@ -145,6 +144,7 @@ export default () => { dashboard.use('/tax-rates', Container.get(TaxRatesController).router()); dashboard.use('/import', Container.get(ImportController).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(ProjectTimesController).router()); diff --git a/packages/server/src/database/migrations/20231108170207_create_documents_links.ts b/packages/server/src/database/migrations/20231108170207_create_documents_links.ts new file mode 100644 index 000000000..5fa27ad63 --- /dev/null +++ b/packages/server/src/database/migrations/20231108170207_create_documents_links.ts @@ -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'); +}; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index c3d08ab6f..5183e85ae 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -60,9 +60,10 @@ import Time from 'models/Time'; import Task from 'models/Task'; import TaxRate from 'models/TaxRate'; import TaxRateTransaction from 'models/TaxRateTransaction'; -import Attachment from 'models/Attachment'; import PlaidItem from 'models/PlaidItem'; import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction'; +import Document from '@/models/Document'; +import DocumentLink from '@/models/DocumentLink'; export default (knex) => { const models = { @@ -126,9 +127,10 @@ export default (knex) => { Task, TaxRate, TaxRateTransaction, - Attachment, + Document, + DocumentLink, PlaidItem, - UncategorizedCashflowTransaction + UncategorizedCashflowTransaction, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/Attachment.ts b/packages/server/src/models/Document.ts similarity index 86% rename from packages/server/src/models/Attachment.ts rename to packages/server/src/models/Document.ts index 389afcfaf..5e99c1be6 100644 --- a/packages/server/src/models/Attachment.ts +++ b/packages/server/src/models/Document.ts @@ -3,7 +3,7 @@ import TenantModel from 'models/TenantModel'; import ModelSetting from './ModelSetting'; import ModelSearchable from './ModelSearchable'; -export default class Attachment extends mixin(TenantModel, [ +export default class Document extends mixin(TenantModel, [ ModelSetting, ModelSearchable, ]) { diff --git a/packages/server/src/models/DocumentLink.ts b/packages/server/src/models/DocumentLink.ts new file mode 100644 index 000000000..4e7ac10fe --- /dev/null +++ b/packages/server/src/models/DocumentLink.ts @@ -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', + }, + }, + }; + } +} diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 0e64fd7ee..3ec2ba85b 100644 --- a/packages/server/src/models/SaleInvoice.ts +++ b/packages/server/src/models/SaleInvoice.ts @@ -410,6 +410,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ const Branch = require('models/Branch'); const Account = require('models/Account'); const TaxRateTransaction = require('models/TaxRateTransaction'); + const DocumentLink = require('models/DocumentLink'); return { /** @@ -523,6 +524,21 @@ export default class SaleInvoice extends mixin(TenantModel, [ 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'); + }, + }, }; } diff --git a/packages/server/src/services/Attachments/AttachmentsApplication.ts b/packages/server/src/services/Attachments/AttachmentsApplication.ts index 9f50225e9..515c07d09 100644 --- a/packages/server/src/services/Attachments/AttachmentsApplication.ts +++ b/packages/server/src/services/Attachments/AttachmentsApplication.ts @@ -3,6 +3,8 @@ import { UploadDocument } from './UploadDocument'; import { DeleteAttachment } from './DeleteAttachment'; import { GetAttachment } from './GetAttachment'; import { AttachmentUploadPipeline } from './S3UploadPipeline'; +import { LinkAttachment } from './LinkAttachment'; +import { UnlinkAttachment } from './UnlinkAttachment'; @Service() export class AttachmentsApplication { @@ -15,18 +17,25 @@ export class AttachmentsApplication { @Inject() private getDocumentService: GetAttachment; + @Inject() private uploadPipelineService: AttachmentUploadPipeline; + @Inject() + private linkDocumentService: LinkAttachment; + + @Inject() + private unlinkDocumentService: UnlinkAttachment; + /** - * - * @returns + * + * @returns */ get uploadPipeline() { return this.uploadPipelineService.uploadPipeline(); } /** - * + * Uploads * @param {number} tenantId * @param {} file * @returns @@ -53,4 +62,43 @@ export class AttachmentsApplication { public get(tenantId: number, documentKey: string) { 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 + ); + } } diff --git a/packages/server/src/services/Attachments/DeleteAttachment.ts b/packages/server/src/services/Attachments/DeleteAttachment.ts index c69c90d67..e77a6fc7a 100644 --- a/packages/server/src/services/Attachments/DeleteAttachment.ts +++ b/packages/server/src/services/Attachments/DeleteAttachment.ts @@ -1,19 +1,35 @@ import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { s3 } from '@/lib/S3/S3'; -import { Service } from 'typedi'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; @Service() export class DeleteAttachment { + @Inject() + private tenancy: HasTenancyService; + /** * Deletes the give file attachment file key. * @param {number} tenantId * @param {string} filekey */ async delete(tenantId: number, filekey: string): Promise { + const { Document, DocumentLink } = this.tenancy.models(tenantId); + const params = { Bucket: process.env.AWS_BUCKET, Key: filekey, }; 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(); } } diff --git a/packages/server/src/services/Attachments/LinkAttachment.ts b/packages/server/src/services/Attachments/LinkAttachment.ts new file mode 100644 index 000000000..1233e265a --- /dev/null +++ b/packages/server/src/services/Attachments/LinkAttachment.ts @@ -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, + }); + } +} diff --git a/packages/server/src/services/Attachments/UnlinkAttachment.ts b/packages/server/src/services/Attachments/UnlinkAttachment.ts new file mode 100644 index 000000000..9aa24b34f --- /dev/null +++ b/packages/server/src/services/Attachments/UnlinkAttachment.ts @@ -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(); + } +} diff --git a/packages/server/src/services/Attachments/UploadDocument.ts b/packages/server/src/services/Attachments/UploadDocument.ts index f5ce62a06..c8203fb9e 100644 --- a/packages/server/src/services/Attachments/UploadDocument.ts +++ b/packages/server/src/services/Attachments/UploadDocument.ts @@ -1,10 +1,18 @@ -import { Service } from 'typedi'; -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; @Service() export class UploadDocument { + @Inject() + private tenancy: HasTenancyService; 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; } } diff --git a/packages/server/src/services/Attachments/_utils.ts b/packages/server/src/services/Attachments/_utils.ts new file mode 100644 index 000000000..26f0f59c9 --- /dev/null +++ b/packages/server/src/services/Attachments/_utils.ts @@ -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); + } +}; diff --git a/packages/server/src/services/Attachments/constants.ts b/packages/server/src/services/Attachments/constants.ts new file mode 100644 index 000000000..0b9338b8d --- /dev/null +++ b/packages/server/src/services/Attachments/constants.ts @@ -0,0 +1,4 @@ +export enum ERRORS { + DOCUMENT_LINK_REF_INVALID = 'DOCUMENT_LINK_REF_INVALID', + DOCUMENT_LINK_ID_INVALID = 'DOCUMENT_LINK_ID_INVALID', +}