diff --git a/.env.example b/.env.example index dc38bf3e3..a9470e912 100644 --- a/.env.example +++ b/.env.example @@ -103,3 +103,10 @@ PLAID_DEVELOPMENT_REDIRECT_URI= LEMONSQUEEZY_API_KEY= LEMONSQUEEZY_STORE_ID= LEMONSQUEEZY_WEBHOOK_SECRET= + +# S3 documents and attachments +S3_REGION= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_ENDPOINT= +S3_BUCKET= \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5a71de09f..d85365f7f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -82,6 +82,9 @@ services: - SIGNUP_ALLOWED_DOMAINS=${SIGNUP_ALLOWED_DOMAINS} - SIGNUP_ALLOWED_EMAILS=${SIGNUP_ALLOWED_EMAILS} + # Sign-up email confirmation + - SIGNUP_EMAIL_CONFIRMATION=${SIGNUP_EMAIL_CONFIRMATION} + # Gotenberg (Pdf generator) - GOTENBERG_URL=${GOTENBERG_URL} - GOTENBERG_DOCS_URL=${GOTENBERG_DOCS_URL} @@ -108,7 +111,8 @@ services: - NEW_RELIC_AI_MONITORING_ENABLED=${NEW_RELIC_AI_MONITORING_ENABLED} - NEW_RELIC_CUSTOM_INSIGHTS_EVENTS_MAX_SAMPLES_STORED=${NEW_RELIC_CUSTOM_INSIGHTS_EVENTS_MAX_SAMPLES_STORED} - NEW_RELIC_SPAN_EVENTS_MAX_SAMPLES_STORED=${NEW_RELIC_SPAN_EVENTS_MAX_SAMPLES_STORED} - + - NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY} + - NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME} database_migration: container_name: bigcapital-database-migration diff --git a/packages/server/package.json b/packages/server/package.json index 45fea4b88..ef6682eca 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,9 +20,12 @@ "bigcapital": "./bin/bigcapital.js" }, "dependencies": { + "@aws-sdk/client-s3": "^3.576.0", + "@aws-sdk/s3-request-presigner": "^3.583.0", "@casl/ability": "^5.4.3", "@hapi/boom": "^7.4.3", "@lemonsqueezy/lemonsqueezy.js": "^2.2.0", + "@types/express": "^4.17.21", "@types/i18n": "^0.8.7", "@types/knex": "^0.16.1", "@types/mathjs": "^6.0.12", @@ -73,12 +76,14 @@ "lru-cache": "^6.0.0", "mathjs": "^9.4.0", "memory-cache": "^0.2.0", + "mime-types": "^2.1.35", "moment": "^2.24.0", "moment-range": "^4.0.2", "moment-timezone": "^0.5.43", "mongodb": "^6.1.0", "mongoose": "^5.10.0", "multer": "1.4.5-lts.1", + "multer-s3": "^3.0.1", "mustache": "^3.0.3", "mysql": "^2.17.1", "mysql2": "^1.6.5", @@ -113,6 +118,7 @@ }, "devDependencies": { "@types/lodash": "^4.14.158", + "@types/multer": "^1.4.11", "@types/ramda": "^0.27.64", "@typescript-eslint/eslint-plugin": "^5.50.0", "@typescript-eslint/parser": "^5.50.0", diff --git a/packages/server/src/api/controllers/Accounts.ts b/packages/server/src/api/controllers/Accounts.ts index 001a5edb7..9c1b12e9f 100644 --- a/packages/server/src/api/controllers/Accounts.ts +++ b/packages/server/src/api/controllers/Accounts.ts @@ -207,7 +207,6 @@ export default class AccountsController extends BaseController { tenantId, accountDTO ); - return res.status(200).send({ id: account.id, message: 'The account has been created successfully.', diff --git a/packages/server/src/api/controllers/Attachments/AttachmentsController.ts b/packages/server/src/api/controllers/Attachments/AttachmentsController.ts new file mode 100644 index 000000000..ba41db541 --- /dev/null +++ b/packages/server/src/api/controllers/Attachments/AttachmentsController.ts @@ -0,0 +1,259 @@ +import mime from 'mime-types'; +import { Service, Inject } from 'typedi'; +import { Router, Response, NextFunction, Request } from 'express'; +import { body, param } from 'express-validator'; +import BaseController from '@/api/controllers/BaseController'; +import { AttachmentsApplication } from '@/services/Attachments/AttachmentsApplication'; + +@Service() +export class AttachmentsController extends BaseController { + @Inject() + private attachmentsApplication: AttachmentsApplication; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/', + this.attachmentsApplication.uploadPipeline.single('file'), + this.validateUploadedFileExistance, + this.uploadAttachment.bind(this) + ); + router.delete( + '/:id', + [param('id').exists()], + this.validationResult, + this.deleteAttachment.bind(this) + ); + router.get( + '/:id', + [param('id').exists()], + 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) + ); + router.get( + '/:id/presigned-url', + [param('id').exists()], + this.validationResult, + this.getAttachmentPresignedUrl.bind(this) + ); + return router; + } + + /** + * Validates the upload file existance. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response|void} + */ + private validateUploadedFileExistance( + req: Request, + res: Response, + next: NextFunction + ) { + if (!req.file) { + return res.boom.badRequest(null, { + errorType: 'FILE_UPLOAD_FAILED', + message: 'Now file uploaded.', + }); + } + next(); + } + + /** + * Uploads the attachments to S3 and store the file metadata to DB. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response|void} + */ + private async uploadAttachment( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const file = req.file; + + try { + 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); + } + } + + /** + * Retrieves the given attachment key. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async getAttachment( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { id } = req.params; + + try { + const data = await this.attachmentsApplication.get(tenantId, id); + + const byte = await data.Body.transformToByteArray(); + const extension = mime.extension(data.ContentType); + const buffer = Buffer.from(byte); + + res.set( + 'Content-Disposition', + `filename="${req.params.id}.${extension}"` + ); + res.set('Content-Type', data.ContentType); + res.send(buffer); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given document key. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async deleteAttachment( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { id: documentId } = req.params; + + try { + await this.attachmentsApplication.delete(tenantId, documentId); + + return res.status(200).send({ + status: 200, + message: 'The document has been delete successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Links the given document key. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async linkDocument( + req: Request, + res: Response, + next: Function + ): Promise { + 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 {Promise} + */ + private async unlinkDocument( + req: Request, + res: Response, + next: NextFunction + ): Promise { + 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); + } + } + + /** + * Retreives the presigned url of the given attachment key. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async getAttachmentPresignedUrl( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { id: documentKey } = req.params; + + try { + const presignedUrl = await this.attachmentsApplication.getPresignedUrl( + documentKey + ); + return res.status(200).send({ presignedUrl }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Expenses/Expenses.ts b/packages/server/src/api/controllers/Expenses/Expenses.ts index fe3746910..b9257a6bd 100644 --- a/packages/server/src/api/controllers/Expenses/Expenses.ts +++ b/packages/server/src/api/controllers/Expenses/Expenses.ts @@ -84,7 +84,7 @@ export class ExpensesController extends BaseController { /** * Expense DTO schema. */ - get expenseDTOSchema() { + private get expenseDTOSchema() { return [ check('reference_no') .optional({ nullable: true }) @@ -130,6 +130,9 @@ export class ExpensesController extends BaseController { .optional({ nullable: true }) .isInt({ max: DATATYPES_LENGTH.INT_10 }) .toInt(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } @@ -183,6 +186,9 @@ export class ExpensesController extends BaseController { .optional({ nullable: true }) .isInt({ max: DATATYPES_LENGTH.INT_10 }) .toInt(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } @@ -269,7 +275,7 @@ export class ExpensesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - async deleteExpense(req: Request, res: Response, next: NextFunction) { + private async deleteExpense(req: Request, res: Response, next: NextFunction) { const { tenantId, user } = req; const { id: expenseId } = req.params; @@ -291,7 +297,11 @@ export class ExpensesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - async publishExpense(req: Request, res: Response, next: NextFunction) { + private async publishExpense( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId, user } = req; const { id: expenseId } = req.params; @@ -313,7 +323,11 @@ export class ExpensesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - async getExpensesList(req: Request, res: Response, next: NextFunction) { + private async getExpensesList( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId } = req; const filter = { sortOrder: 'desc', @@ -343,7 +357,7 @@ export class ExpensesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - async getExpense(req: Request, res: Response, next: NextFunction) { + private async getExpense(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; const { id: expenseId } = req.params; diff --git a/packages/server/src/api/controllers/Export/ExportController.ts b/packages/server/src/api/controllers/Export/ExportController.ts index 5be252dad..0332db879 100644 --- a/packages/server/src/api/controllers/Export/ExportController.ts +++ b/packages/server/src/api/controllers/Export/ExportController.ts @@ -15,7 +15,7 @@ export class ExportController extends BaseController { /** * Router constructor method. */ - router() { + public router() { const router = Router(); router.get( @@ -56,6 +56,7 @@ export class ExportController extends BaseController { query.resource, applicationFormat ); + // Retrieves the csv format. if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); res.setHeader('Content-Type', 'text/csv'); diff --git a/packages/server/src/api/controllers/Import/ImportController.ts b/packages/server/src/api/controllers/Import/ImportController.ts index 5567aca78..17b60bc95 100644 --- a/packages/server/src/api/controllers/Import/ImportController.ts +++ b/packages/server/src/api/controllers/Import/ImportController.ts @@ -16,7 +16,7 @@ export class ImportController extends BaseController { /** * Router constructor method. */ - router() { + public router() { const router = Router(); router.post( @@ -240,11 +240,7 @@ export class ImportController extends BaseController { errors: [{ type: 'IMPORTED_FILE_EXTENSION_INVALID' }], }); } - return res.status(400).send({ - errors: [{ type: error.errorType }], - }); } - next(error); } } diff --git a/packages/server/src/api/controllers/ManualJournals.ts b/packages/server/src/api/controllers/ManualJournals.ts index 8af8c13b5..5ebc57176 100644 --- a/packages/server/src/api/controllers/ManualJournals.ts +++ b/packages/server/src/api/controllers/ManualJournals.ts @@ -77,14 +77,14 @@ export default class ManualJournalsController extends BaseController { /** * Specific manual journal id param validation schema. */ - get manualJournalParamSchema() { + private get manualJournalParamSchema() { return [param('id').exists().isNumeric().toInt()]; } /** * Manual journal DTO schema. */ - get manualJournalValidationSchema() { + private get manualJournalValidationSchema() { return [ check('date').exists().isISO8601(), check('currency_code').optional(), @@ -148,13 +148,16 @@ export default class ManualJournalsController extends BaseController { .optional({ nullable: true }) .isNumeric() .toInt(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } /** * Manual journals list validation schema. */ - get manualJournalsListSchema() { + private get manualJournalsListSchema() { return [ query('page').optional().isNumeric().toInt(), query('page_size').optional().isNumeric().toInt(), @@ -320,7 +323,7 @@ export default class ManualJournalsController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - getManualJournalsList = async ( + private getManualJournalsList = async ( req: Request, res: Response, next: NextFunction diff --git a/packages/server/src/api/controllers/OrganizationDashboard.ts b/packages/server/src/api/controllers/OrganizationDashboard.ts index a374645c7..6b38b8e0b 100644 --- a/packages/server/src/api/controllers/OrganizationDashboard.ts +++ b/packages/server/src/api/controllers/OrganizationDashboard.ts @@ -33,17 +33,17 @@ export default class OrganizationDashboardController extends BaseController { } /** - * - * @param req - * @param res - * @param next - * @returns + * Detarmines whether the current authed organization to able to change its currency/. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response|void} */ private async baseCurrencyMutateAbility( req: Request, res: Response, next: Function - ) { + ): Promise { const { tenantId } = req; try { diff --git a/packages/server/src/api/controllers/Projects/Projects.ts b/packages/server/src/api/controllers/Projects/Projects.ts index 33155fa81..6953e322d 100644 --- a/packages/server/src/api/controllers/Projects/Projects.ts +++ b/packages/server/src/api/controllers/Projects/Projects.ts @@ -29,8 +29,7 @@ export class ProjectsController extends BaseController { check('cost_estimate').exists().isDecimal(), ], this.validationResult, - asyncMiddleware(this.createProject.bind(this)), - this.catchServiceErrors + asyncMiddleware(this.createProject.bind(this)) ); router.post( '/:id', @@ -43,8 +42,7 @@ export class ProjectsController extends BaseController { check('cost_estimate').exists().isDecimal(), ], this.validationResult, - asyncMiddleware(this.editProject.bind(this)), - this.catchServiceErrors + asyncMiddleware(this.editProject.bind(this)) ); router.patch( '/:projectId/status', @@ -56,16 +54,14 @@ export class ProjectsController extends BaseController { .isIn([IProjectStatus.InProgress, IProjectStatus.Closed]), ], this.validationResult, - asyncMiddleware(this.editProject.bind(this)), - this.catchServiceErrors + asyncMiddleware(this.editProject.bind(this)) ); router.get( '/:id', CheckPolicies(ProjectAction.VIEW, AbilitySubject.Project), [param('id').exists().isInt().toInt()], this.validationResult, - asyncMiddleware(this.getProject.bind(this)), - this.catchServiceErrors + asyncMiddleware(this.getProject.bind(this)) ); router.get( '/:projectId/billable/entries', @@ -76,24 +72,21 @@ export class ProjectsController extends BaseController { query('to_date').optional().isISO8601(), ], this.validationResult, - asyncMiddleware(this.projectBillableEntries.bind(this)), - this.catchServiceErrors + asyncMiddleware(this.projectBillableEntries.bind(this)) ); router.get( '/', CheckPolicies(ProjectAction.VIEW, AbilitySubject.Project), [], this.validationResult, - asyncMiddleware(this.getProjects.bind(this)), - this.catchServiceErrors + asyncMiddleware(this.getProjects.bind(this)) ); router.delete( '/:id', CheckPolicies(ProjectAction.DELETE, AbilitySubject.Project), [param('id').exists().isInt().toInt()], this.validationResult, - asyncMiddleware(this.deleteProject.bind(this)), - this.catchServiceErrors + asyncMiddleware(this.deleteProject.bind(this)) ); return router; } @@ -252,22 +245,4 @@ export class ProjectsController extends BaseController { next(error); } }; - - /** - * Transforms service errors to response. - * @param {Error} - * @param {Request} req - * @param {Response} res - * @param {ServiceError} error - */ - private catchServiceErrors( - error, - req: Request, - res: Response, - next: NextFunction - ) { - if (error instanceof ServiceError) { - } - next(error); - } } diff --git a/packages/server/src/api/controllers/Purchases/Bills.ts b/packages/server/src/api/controllers/Purchases/Bills.ts index 4f65eab26..7a013f5cc 100644 --- a/packages/server/src/api/controllers/Purchases/Bills.ts +++ b/packages/server/src/api/controllers/Purchases/Bills.ts @@ -118,7 +118,6 @@ export default class BillsController extends BaseController { check('is_inclusive_tax').default(false).isBoolean().toBoolean(), check('entries').isArray({ min: 1 }), - check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.item_id').exists().isNumeric().toInt(), check('entries.*.rate').exists().isNumeric().toFloat(), @@ -148,6 +147,9 @@ export default class BillsController extends BaseController { .optional({ nullable: true }) .isNumeric() .toInt(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } @@ -190,6 +192,9 @@ export default class BillsController extends BaseController { .optional({ nullable: true }) .isBoolean() .toBoolean(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } diff --git a/packages/server/src/api/controllers/Purchases/BillsPayments.ts b/packages/server/src/api/controllers/Purchases/BillsPayments.ts index 984ab6fba..dfe46cf4d 100644 --- a/packages/server/src/api/controllers/Purchases/BillsPayments.ts +++ b/packages/server/src/api/controllers/Purchases/BillsPayments.ts @@ -122,6 +122,9 @@ export default class BillsPayments extends BaseController { check('entries.*.index').optional().isNumeric().toInt(), check('entries.*.bill_id').exists().isNumeric().toInt(), check('entries.*.payment_amount').exists().isNumeric().toFloat(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } diff --git a/packages/server/src/api/controllers/Purchases/VendorCredit.ts b/packages/server/src/api/controllers/Purchases/VendorCredit.ts index 02f82b2d1..9b3869302 100644 --- a/packages/server/src/api/controllers/Purchases/VendorCredit.ts +++ b/packages/server/src/api/controllers/Purchases/VendorCredit.ts @@ -186,6 +186,9 @@ export default class VendorCreditController extends BaseController { .optional({ nullable: true }) .isNumeric() .toInt(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } @@ -228,6 +231,9 @@ export default class VendorCreditController extends BaseController { .optional({ nullable: true }) .isNumeric() .toInt(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } diff --git a/packages/server/src/api/controllers/Sales/CreditNotes.ts b/packages/server/src/api/controllers/Sales/CreditNotes.ts index 20b002e33..9b9553323 100644 --- a/packages/server/src/api/controllers/Sales/CreditNotes.ts +++ b/packages/server/src/api/controllers/Sales/CreditNotes.ts @@ -236,6 +236,9 @@ export default class PaymentReceivesController extends BaseController { .optional({ nullable: true }) .isNumeric() .toInt(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } diff --git a/packages/server/src/api/controllers/Sales/PaymentReceives.ts b/packages/server/src/api/controllers/Sales/PaymentReceives.ts index 818fcb713..5acd359e4 100644 --- a/packages/server/src/api/controllers/Sales/PaymentReceives.ts +++ b/packages/server/src/api/controllers/Sales/PaymentReceives.ts @@ -164,6 +164,9 @@ export default class PaymentReceivesController extends BaseController { check('entries.*.index').optional().isNumeric().toInt(), check('entries.*.invoice_id').exists().isNumeric().toInt(), check('entries.*.payment_amount').exists().isNumeric().toFloat(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } diff --git a/packages/server/src/api/controllers/Sales/SalesEstimates.ts b/packages/server/src/api/controllers/Sales/SalesEstimates.ts index e26b40c30..4bbcee9bb 100644 --- a/packages/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/packages/server/src/api/controllers/Sales/SalesEstimates.ts @@ -184,6 +184,9 @@ export default class SalesEstimatesController extends BaseController { check('note').optional().trim().escape(), check('terms_conditions').optional().trim().escape(), check('send_to_email').optional().trim().escape(), + + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index b45604e43..70fbdae6b 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -36,6 +36,8 @@ export default class SaleInvoicesController extends BaseController { [ ...this.saleInvoiceValidationSchema, check('from_estimate_id').optional().isNumeric().toInt(), + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ], this.validationResult, asyncMiddleware(this.newSaleInvoice.bind(this)), @@ -98,6 +100,8 @@ export default class SaleInvoicesController extends BaseController { [ ...this.saleInvoiceValidationSchema, ...this.specificSaleInvoiceValidation, + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ], this.validationResult, asyncMiddleware(this.editSaleInvoice.bind(this)), diff --git a/packages/server/src/api/controllers/Sales/SalesReceipts.ts b/packages/server/src/api/controllers/Sales/SalesReceipts.ts index 04edfd9f8..ceeea9fe7 100644 --- a/packages/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/packages/server/src/api/controllers/Sales/SalesReceipts.ts @@ -158,6 +158,8 @@ export default class SalesReceiptsController extends BaseController { .toInt(), check('receipt_message').optional().trim().escape(), check('statement').optional().trim().escape(), + check('attachments').isArray().optional(), + check('attachments.*.key').exists().isString(), ]; } diff --git a/packages/server/src/api/controllers/Users.ts b/packages/server/src/api/controllers/Users.ts index f888b90c0..39f438cdb 100644 --- a/packages/server/src/api/controllers/Users.ts +++ b/packages/server/src/api/controllers/Users.ts @@ -155,6 +155,7 @@ export default class UsersController extends BaseController { try { const user = await this.usersService.getUser(tenantId, userId); + return res.status(200).send({ user }); } catch (error) { next(error); @@ -229,7 +230,7 @@ export default class UsersController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - catchServiceErrors( + private catchServiceErrors( error: Error, req: Request, res: Response, diff --git a/packages/server/src/api/controllers/Warehouses/index.ts b/packages/server/src/api/controllers/Warehouses/index.ts index 0d61f80bc..c704f44e0 100644 --- a/packages/server/src/api/controllers/Warehouses/index.ts +++ b/packages/server/src/api/controllers/Warehouses/index.ts @@ -17,7 +17,7 @@ export class WarehousesController extends BaseController { * * @returns */ - router() { + public router() { const router = Router(); router.post( diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts index 4ff3acc76..acfdc8bd6 100644 --- a/packages/server/src/api/controllers/Webhooks/Webhooks.ts +++ b/packages/server/src/api/controllers/Webhooks/Webhooks.ts @@ -34,14 +34,21 @@ export class Webhooks extends BaseController { * @param {Response} res * @returns {Response} */ - public async lemonWebhooks(req: Request, res: Response) { + public async lemonWebhooks(req: Request, res: Response, next: any) { const data = req.body; const signature = req.headers['x-signature'] ?? ''; const rawBody = req.rawBody; - await this.lemonWebhooksService.handlePostWebhook(rawBody, data, signature); - - return res.status(200).send(); + try { + await this.lemonWebhooksService.handlePostWebhook( + rawBody, + data, + signature + ); + return res.status(200).send(); + } catch (error) { + next(error); + } } /** diff --git a/packages/server/src/api/exceptions/GlobalErrorException.ts b/packages/server/src/api/exceptions/GlobalErrorException.ts new file mode 100644 index 000000000..85f433437 --- /dev/null +++ b/packages/server/src/api/exceptions/GlobalErrorException.ts @@ -0,0 +1,20 @@ +import { Request, Response, NextFunction } from 'express'; + +/** + * Global error handler. + * @param {Error} err + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ +export function GlobalErrorException( + err: Error, + req: Request, + res: Response, + next: NextFunction +) { + console.error(err.stack); + + res.status(500); + res.boom.badImplementation('', { stack: err.stack }); +} diff --git a/packages/server/src/api/middleware/ObjectionErrorHandlerMiddleware.ts b/packages/server/src/api/exceptions/ObjectionErrorException.ts similarity index 92% rename from packages/server/src/api/middleware/ObjectionErrorHandlerMiddleware.ts rename to packages/server/src/api/exceptions/ObjectionErrorException.ts index 424b86501..31ddc64e9 100644 --- a/packages/server/src/api/middleware/ObjectionErrorHandlerMiddleware.ts +++ b/packages/server/src/api/exceptions/ObjectionErrorException.ts @@ -10,8 +10,14 @@ import { DataError, } from 'objection'; -// In this example `res` is an express response object. -export default function ObjectionErrorHandlerMiddleware( +/** + * Handles the Objection error exception. + * @param {Error} err + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ +export function ObjectionErrorException( err: Error, req: Request, res: Response, @@ -108,6 +114,7 @@ export default function ObjectionErrorHandlerMiddleware( type: 'UnknownDatabaseError', data: {}, }); + } else { + next(err); } - next(err); } diff --git a/packages/server/src/api/exceptions/ServiceErrorException.ts b/packages/server/src/api/exceptions/ServiceErrorException.ts new file mode 100644 index 000000000..fd1990e59 --- /dev/null +++ b/packages/server/src/api/exceptions/ServiceErrorException.ts @@ -0,0 +1,25 @@ +import { NextFunction, Request, Response } from 'express'; +import { ServiceError } from '@/exceptions'; + +/** + * Handles service error exception. + * @param {Error | ServiceError} err + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ +export function ServiceErrorException( + err: Error | ServiceError, + req: Request, + res: Response, + next: NextFunction +) { + if (err instanceof ServiceError) { + res.boom.badRequest('', { + errors: [{ type: err.errorType, message: err.message }], + type: 'ServiceError', + }); + } else { + next(err); + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 33b47e616..ac8b2fda7 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -62,6 +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/Attachments/AttachmentsController'; export default () => { const app = Router(); @@ -142,7 +143,8 @@ export default () => { dashboard.use('/projects', Container.get(ProjectsController).router()); dashboard.use('/tax-rates', Container.get(TaxRatesController).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(ProjectTimesController).router()); diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index a48905d41..d4d982c37 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -157,7 +157,10 @@ module.exports = { * Sign-up email confirmation */ signupConfirmation: { - enabled: parseBoolean(process.env.SIGNUP_EMAIL_CONFIRMATION, false), + enabled: parseBoolean( + process.env.SIGNUP_EMAIL_CONFIRMATION, + false + ), }, /** @@ -225,4 +228,15 @@ module.exports = { defaultTo(process.env.HOSTED_ON_BIGCAPITAL_CLOUD, false), false ), + + /** + * S3 for documents. + */ + s3: { + region: process.env.S3_REGION, + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + endpoint: process.env.S3_ENDPOINT, + bucket: process.env.S3_BUCKET, + }, }; 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..7cdb91429 --- /dev/null +++ b/packages/server/src/database/migrations/20231108170207_create_documents_links.ts @@ -0,0 +1,14 @@ +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.datetime('expires_at').nullable(); + table.timestamps(); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('document_links'); +}; diff --git a/packages/server/src/database/migrations/20231108170207_create_documents_table.js b/packages/server/src/database/migrations/20231108170207_create_documents_table.js new file mode 100644 index 000000000..8ae0cc542 --- /dev/null +++ b/packages/server/src/database/migrations/20231108170207_create_documents_table.js @@ -0,0 +1,14 @@ +exports.up = function (knex) { + return knex.schema.createTable('documents', (table) => { + table.increments('id').primary(); + table.string('key').notNullable(); + table.string('mime_type').notNullable(); + table.integer('size').unsigned().notNullable(); + table.string('origin_name'); + table.timestamps(); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('documents'); +}; diff --git a/packages/server/src/database/migrations/20231108170207_create_storage_table.js b/packages/server/src/database/migrations/20231108170207_create_storage_table.js deleted file mode 100644 index 51801bc85..000000000 --- a/packages/server/src/database/migrations/20231108170207_create_storage_table.js +++ /dev/null @@ -1,14 +0,0 @@ -exports.up = function (knex) { - return knex.schema.createTable('storage', (table) => { - table.increments('id').primary(); - table.string('key').notNullable(); - table.string('path').notNullable(); - table.string('extension').notNullable(); - table.integer('expire_in'); - table.timestamps(); - }); -}; - -exports.down = function (knex) { - return knex.schema.dropTableIfExists('storage'); -}; diff --git a/packages/server/src/interfaces/Attachments.ts b/packages/server/src/interfaces/Attachments.ts new file mode 100644 index 000000000..d1699ed12 --- /dev/null +++ b/packages/server/src/interfaces/Attachments.ts @@ -0,0 +1,3 @@ +export interface AttachmentLinkDTO { + key: string; +} diff --git a/packages/server/src/interfaces/Bill.ts b/packages/server/src/interfaces/Bill.ts index 8276a4623..0ccc72723 100644 --- a/packages/server/src/interfaces/Bill.ts +++ b/packages/server/src/interfaces/Bill.ts @@ -2,6 +2,7 @@ import { Knex } from 'knex'; import { IDynamicListFilterDTO } from './DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IBillLandedCost } from './LandedCost'; +import { AttachmentLinkDTO } from './Attachments'; export interface IBillDTO { vendorId: number; @@ -20,6 +21,7 @@ export interface IBillDTO { warehouseId?: number; projectId?: number; isInclusiveTax?: boolean; + attachments?: AttachmentLinkDTO[]; } export interface IBillEditDTO { @@ -38,6 +40,7 @@ export interface IBillEditDTO { branchId?: number; warehouseId?: number; projectId?: number; + attachments?: AttachmentLinkDTO[]; } export interface IBill { @@ -105,6 +108,7 @@ export interface IBillsService { export interface IBillCreatedPayload { tenantId: number; bill: IBill; + billDTO: IBillDTO; billId: number; trx: Knex.Transaction; } @@ -126,6 +130,7 @@ export interface IBillEditedPayload { billId: number; oldBill: IBill; bill: IBill; + billDTO: IBillDTO; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/BillPayment.ts b/packages/server/src/interfaces/BillPayment.ts index f941616fa..002bf867b 100644 --- a/packages/server/src/interfaces/BillPayment.ts +++ b/packages/server/src/interfaces/BillPayment.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { IBill } from './Bill'; +import { AttachmentLinkDTO } from './Attachments'; export interface IBillPaymentEntry { id?: number; @@ -45,6 +46,7 @@ export interface IBillPaymentDTO { reference: string; entries: IBillPaymentEntryDTO[]; branchId?: number; + attachments?: AttachmentLinkDTO[]; } export interface IBillReceivePageEntry { @@ -66,6 +68,7 @@ export interface IBillPaymentsService { export interface IBillPaymentEventCreatedPayload { tenantId: number; billPayment: IBillPayment; + billPaymentDTO: IBillPaymentDTO; billPaymentId: number; trx: Knex.Transaction; } @@ -87,6 +90,7 @@ export interface IBillPaymentEventEditedPayload { billPaymentId: number; billPayment: IBillPayment; oldBillPayment: IBillPayment; + billPaymentDTO: IBillPaymentDTO; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/CreditNote.ts b/packages/server/src/interfaces/CreditNote.ts index 5eea94d34..73f4bbc14 100644 --- a/packages/server/src/interfaces/CreditNote.ts +++ b/packages/server/src/interfaces/CreditNote.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; import { IDynamicListFilter, IItemEntry, IVendorCredit } from '@/interfaces'; import { ILedgerEntry } from './Ledger'; +import { AttachmentLinkDTO } from './Attachments'; export interface ICreditNoteEntryNewDTO { index: number; @@ -21,6 +22,7 @@ export interface ICreditNoteNewDTO { entries: ICreditNoteEntryNewDTO[]; branchId?: number; warehouseId?: number; + attachments?: AttachmentLinkDTO[] } export interface ICreditNoteEditDTO { @@ -33,6 +35,7 @@ export interface ICreditNoteEditDTO { entries: ICreditNoteEntryNewDTO[]; branchId?: number; warehouseId?: number; + attachments?: AttachmentLinkDTO[] } export interface ICreditNoteEntry extends IItemEntry {} diff --git a/packages/server/src/interfaces/Expenses.ts b/packages/server/src/interfaces/Expenses.ts index 6a47bef01..ad779db7c 100644 --- a/packages/server/src/interfaces/Expenses.ts +++ b/packages/server/src/interfaces/Expenses.ts @@ -2,6 +2,7 @@ import { Knex } from 'knex'; import { ISystemUser } from './User'; import { IFilterRole } from './DynamicFilter'; import { IAccount } from './Account'; +import { AttachmentLinkDTO } from './Attachments'; export interface IPaginationMeta { total: number; @@ -81,6 +82,7 @@ export interface IExpenseCommonDTO { categories: IExpenseCategoryDTO[]; branchId?: number; + attachments?: AttachmentLinkDTO[]; } export interface IExpenseCreateDTO extends IExpenseCommonDTO {} @@ -152,6 +154,7 @@ export interface IExpenseCreatedPayload { expenseId: number; authorizedUser: ISystemUser; expense: IExpense; + expenseDTO: IExpenseCreateDTO; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/ManualJournal.ts b/packages/server/src/interfaces/ManualJournal.ts index 863a23bdd..8cdc112f9 100644 --- a/packages/server/src/interfaces/ManualJournal.ts +++ b/packages/server/src/interfaces/ManualJournal.ts @@ -2,6 +2,7 @@ import { Knex } from 'knex'; import { IDynamicListFilterDTO } from './DynamicFilter'; import { ISystemUser } from './User'; import { IAccount } from './Account'; +import { AttachmentLinkDTO } from './Attachments'; export interface IManualJournal { id?: number; @@ -56,6 +57,7 @@ export interface IManualJournalDTO { publish?: boolean; branchId?: number; entries: IManualJournalEntryDTO[]; + attachments?: AttachmentLinkDTO[]; } export interface IManualJournalsFilter extends IDynamicListFilterDTO { @@ -142,6 +144,7 @@ export interface IManualJournalEventEditedPayload { tenantId: number; manualJournal: IManualJournal; oldManualJournal: IManualJournal; + manualJournalDTO: IManualJournalDTO; trx: Knex.Transaction; } export interface IManualJournalEditingPayload { @@ -161,6 +164,7 @@ export interface IManualJournalEventCreatedPayload { tenantId: number; manualJournal: IManualJournal; manualJournalId: number; + manualJournalDTO: IManualJournalDTO; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/PaymentReceive.ts b/packages/server/src/interfaces/PaymentReceive.ts index 329d0d944..68ecb0eb6 100644 --- a/packages/server/src/interfaces/PaymentReceive.ts +++ b/packages/server/src/interfaces/PaymentReceive.ts @@ -6,6 +6,7 @@ import { } from '@/interfaces'; import { ILedgerEntry } from './Ledger'; import { ISaleInvoice } from './SaleInvoice'; +import { AttachmentLinkDTO } from './Attachments'; export interface IPaymentReceive { id?: number; @@ -37,6 +38,7 @@ export interface IPaymentReceiveCreateDTO { entries: IPaymentReceiveEntryDTO[]; branchId?: number; + attachments?: AttachmentLinkDTO[]; } export interface IPaymentReceiveEditDTO { @@ -50,6 +52,7 @@ export interface IPaymentReceiveEditDTO { statement: string; entries: IPaymentReceiveEntryDTO[]; branchId?: number; + attachments?: AttachmentLinkDTO[]; } export interface IPaymentReceiveEntry { @@ -114,6 +117,7 @@ export interface IPaymentReceiveCreatedPayload { paymentReceive: IPaymentReceive; paymentReceiveId: number; authorizedUser: ISystemUser; + paymentReceiveDTO: IPaymentReceiveCreateDTO; trx: Knex.Transaction; } @@ -122,6 +126,7 @@ export interface IPaymentReceiveEditedPayload { paymentReceiveId: number; paymentReceive: IPaymentReceive; oldPaymentReceive: IPaymentReceive; + paymentReceiveDTO: IPaymentReceiveEditDTO; authorizedUser: ISystemUser; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index 9ac17295c..9db21c029 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -2,6 +2,7 @@ import { Knex } from 'knex'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; +import { AttachmentLinkDTO } from './Attachments'; export interface ISaleEstimate { id?: number; @@ -38,6 +39,7 @@ export interface ISaleEstimateDTO { branchId?: number; warehouseId?: number; + attachments?: AttachmentLinkDTO[]; } export interface ISalesEstimatesFilter extends IDynamicListFilterDTO { @@ -70,6 +72,7 @@ export interface ISaleEstimateEditedPayload { estimateId: number; saleEstimate: ISaleEstimate; oldSaleEstimate: ISaleEstimate; + estimateDTO: ISaleEstimateDTO; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 61594306a..6fd5e753b 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -3,6 +3,7 @@ import { ISystemUser, IAccount, ITaxTransaction } from '@/interfaces'; import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; import { IDynamicListFilter } from '@/interfaces/DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; +import { AttachmentLinkDTO } from './Attachments'; export interface ISaleInvoice { id: number; @@ -64,6 +65,8 @@ export interface ISaleInvoiceDTO { branchId?: number | null; isInclusiveTax?: boolean; + + attachments?: AttachmentLinkDTO[]; } export interface ISaleInvoiceCreateDTO extends ISaleInvoiceDTO { diff --git a/packages/server/src/interfaces/SaleReceipt.ts b/packages/server/src/interfaces/SaleReceipt.ts index 8904767c6..36e84e0db 100644 --- a/packages/server/src/interfaces/SaleReceipt.ts +++ b/packages/server/src/interfaces/SaleReceipt.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; import { IItemEntry } from './ItemEntry'; import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; +import { AttachmentLinkDTO } from './Attachments'; export interface ISaleReceipt { id?: number; @@ -43,6 +44,7 @@ export interface ISaleReceiptDTO { closed: boolean; entries: any[]; branchId?: number; + attachments?: AttachmentLinkDTO[]; } export interface ISalesReceiptsService { @@ -85,6 +87,7 @@ export interface ISaleReceiptCreatedPayload { tenantId: number; saleReceipt: ISaleReceipt; saleReceiptId: number; + saleReceiptDTO: ISaleReceiptDTO; trx: Knex.Transaction; } @@ -93,6 +96,7 @@ export interface ISaleReceiptEditedPayload { oldSaleReceipt: number; saleReceipt: ISaleReceipt; saleReceiptId: number; + saleReceiptDTO: ISaleReceiptDTO; trx: Knex.Transaction; } diff --git a/packages/server/src/interfaces/User.ts b/packages/server/src/interfaces/User.ts index fb1e1c186..4b4077eca 100644 --- a/packages/server/src/interfaces/User.ts +++ b/packages/server/src/interfaces/User.ts @@ -11,6 +11,9 @@ export interface ISystemUser extends Model { password: string; email: string; + verifyToken: string; + verified: boolean; + roleId: number; tenantId: number; diff --git a/packages/server/src/interfaces/VendorCredit.ts b/packages/server/src/interfaces/VendorCredit.ts index dd8f6b6e4..d7c8f14d3 100644 --- a/packages/server/src/interfaces/VendorCredit.ts +++ b/packages/server/src/interfaces/VendorCredit.ts @@ -1,5 +1,6 @@ import { IDynamicListFilter, IItemEntry, IItemEntryDTO } from '@/interfaces'; import { Knex } from 'knex'; +import { AttachmentLinkDTO } from './Attachments'; export enum VendorCreditAction { Create = 'Create', @@ -61,6 +62,7 @@ export interface IVendorCreditDTO { branchId?: number; warehouseId?: number; + attachments?: AttachmentLinkDTO[]; } export interface IVendorCreditCreateDTO extends IVendorCreditDTO {} @@ -118,6 +120,7 @@ export interface IVendorCreditEditedPayload { oldVendorCredit: IVendorCredit; vendorCredit: IVendorCredit; vendorCreditId: number; + vendorCreditDTO: IVendorCreditEditDTO; trx: Knex.Transaction; } diff --git a/packages/server/src/lib/S3/S3.ts b/packages/server/src/lib/S3/S3.ts new file mode 100644 index 000000000..2b81a50df --- /dev/null +++ b/packages/server/src/lib/S3/S3.ts @@ -0,0 +1,11 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import config from '@/config'; + +export const s3 = new S3Client({ + region: config.s3.region, + credentials: { + accessKeyId: config.s3.accessKeyId, + secretAccessKey: config.s3.secretAccessKey, + }, + endpoint: config.s3.endpoint, +}); diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 9da52fd86..ad83f6dec 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -92,7 +92,16 @@ import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/sub import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete'; import { SubscribeFreeOnSignupCommunity } from '@/services/Subscription/events/SubscribeFreeOnSignupCommunity'; import { SendVerfiyMailOnSignUp } from '@/services/Authentication/events/SendVerfiyMailOnSignUp'; - +import { AttachmentsOnSaleInvoiceCreated } from '@/services/Attachments/events/AttachmentsOnSaleInvoice'; +import { AttachmentsOnSaleReceipt } from '@/services/Attachments/events/AttachmentsOnSaleReceipts'; +import { AttachmentsOnManualJournals } from '@/services/Attachments/events/AttachmentsOnManualJournals'; +import { AttachmentsOnExpenses } from '@/services/Attachments/events/AttachmentsOnExpenses'; +import { AttachmentsOnBills } from '@/services/Attachments/events/AttachmentsOnBills'; +import { AttachmentsOnPaymentsReceived } from '@/services/Attachments/events/AttachmentsOnPaymentsReceived'; +import { AttachmentsOnVendorCredits } from '@/services/Attachments/events/AttachmentsOnVendorCredits'; +import { AttachmentsOnCreditNote } from '@/services/Attachments/events/AttachmentsOnCreditNote'; +import { AttachmentsOnBillPayments } from '@/services/Attachments/events/AttachmentsOnPaymentsMade'; +import { AttachmentsOnSaleEstimates } from '@/services/Attachments/events/AttachmentsOnSaleEstimates'; export default () => { return new EventPublisher(); @@ -224,6 +233,18 @@ export const susbcribers = () => { PreventDeleteTransactionOnDelete, SubscribeFreeOnSignupCommunity, - SendVerfiyMailOnSignUp + SendVerfiyMailOnSignUp, + + // Attachments + AttachmentsOnSaleInvoiceCreated, + AttachmentsOnSaleEstimates, + AttachmentsOnSaleReceipt, + AttachmentsOnPaymentsReceived, + AttachmentsOnCreditNote, + AttachmentsOnVendorCredits, + AttachmentsOnBills, + AttachmentsOnBillPayments, + AttachmentsOnManualJournals, + AttachmentsOnExpenses, ]; }; diff --git a/packages/server/src/loaders/express.ts b/packages/server/src/loaders/express.ts index 92589f70f..e3afad411 100644 --- a/packages/server/src/loaders/express.ts +++ b/packages/server/src/loaders/express.ts @@ -17,7 +17,9 @@ import { } from '@/api/middleware/JSONResponseTransformer'; import config from '@/config'; import path from 'path'; -import ObjectionErrorHandlerMiddleware from '@/api/middleware/ObjectionErrorHandlerMiddleware'; +import { ObjectionErrorException } from '@/api/exceptions/ObjectionErrorException'; +import { ServiceErrorException } from '@/api/exceptions/ServiceErrorException'; +import { GlobalErrorException } from '@/api/exceptions/GlobalErrorException'; export default ({ app }) => { // Express configuration. @@ -30,9 +32,6 @@ export default ({ app }) => { // Helmet helps you secure your Express apps by setting various HTTP headers. app.use(helmet()); - // Allow to full error stack traces and internal details - app.use(errorHandler()); - // Boom response objects. app.use(boom()); @@ -65,8 +64,10 @@ export default ({ app }) => { // Agendash application load. app.use('/agendash', AgendashController.router()); - // Handles objectionjs errors. - app.use(ObjectionErrorHandlerMiddleware); + // Handles errors. + app.use(ObjectionErrorException); + app.use(ServiceErrorException); + app.use(GlobalErrorException); // catch 404 and forward to error handler app.use((req: Request, res: Response, next: NextFunction) => { 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/Bill.ts b/packages/server/src/models/Bill.ts index 17dd07fa4..439669ad5 100644 --- a/packages/server/src/models/Bill.ts +++ b/packages/server/src/models/Bill.ts @@ -403,6 +403,7 @@ export default class Bill extends mixin(TenantModel, [ const BillLandedCost = require('models/BillLandedCost'); const Branch = require('models/Branch'); const TaxRateTransaction = require('models/TaxRateTransaction'); + const Document = require('models/Document'); return { vendor: { @@ -465,6 +466,25 @@ export default class Bill extends mixin(TenantModel, [ builder.where('reference_type', 'Bill'); }, }, + + /** + * Bill may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'bills.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'Bill'); + }, + }, }; } diff --git a/packages/server/src/models/BillPayment.ts b/packages/server/src/models/BillPayment.ts index 488f32793..d3f0dfe21 100644 --- a/packages/server/src/models/BillPayment.ts +++ b/packages/server/src/models/BillPayment.ts @@ -56,6 +56,7 @@ export default class BillPayment extends mixin(TenantModel, [ const Vendor = require('models/Vendor'); const Account = require('models/Account'); const Branch = require('models/Branch'); + const Document = require('models/Document'); return { entries: { @@ -114,6 +115,25 @@ export default class BillPayment extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * Bill payment may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'bills_payments.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'BillPayment'); + }, + }, }; } diff --git a/packages/server/src/models/CreditNote.ts b/packages/server/src/models/CreditNote.ts index f9b901397..a682753c4 100644 --- a/packages/server/src/models/CreditNote.ts +++ b/packages/server/src/models/CreditNote.ts @@ -174,6 +174,7 @@ export default class CreditNote extends mixin(TenantModel, [ const ItemEntry = require('models/ItemEntry'); const Customer = require('models/Customer'); const Branch = require('models/Branch'); + const Document = require('models/Document'); return { /** @@ -233,6 +234,25 @@ export default class CreditNote extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * Credit note may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'credit_notes.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'CreditNote'); + }, + }, }; } diff --git a/packages/server/src/models/Attachment.ts b/packages/server/src/models/Document.ts similarity index 81% rename from packages/server/src/models/Attachment.ts rename to packages/server/src/models/Document.ts index 9f3412d5e..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, ]) { @@ -11,7 +11,7 @@ export default class Attachment extends mixin(TenantModel, [ * Table name */ static get tableName() { - return 'storage'; + return 'documents'; } /** 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/Expense.ts b/packages/server/src/models/Expense.ts index b2fce9a65..21fa0ac9c 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -180,7 +180,7 @@ export default class Expense extends mixin(TenantModel, [ static get relationMappings() { const Account = require('models/Account'); const ExpenseCategory = require('models/ExpenseCategory'); - const Media = require('models/Media'); + const Document = require('models/Document'); const Branch = require('models/Branch'); return { @@ -217,21 +217,21 @@ export default class Expense extends mixin(TenantModel, [ }, /** - * + * Expense transaction may has many attached attachments. */ - media: { + attachments: { relation: Model.ManyToManyRelation, - modelClass: Media.default, + modelClass: Document.default, join: { from: 'expenses_transactions.id', through: { - from: 'media_links.model_id', - to: 'media_links.media_id', + from: 'document_links.modelId', + to: 'document_links.documentId', }, - to: 'media.id', + to: 'documents.id', }, filter(query) { - query.where('model_name', 'Expense'); + query.where('model_ref', 'Expense'); }, }, }; diff --git a/packages/server/src/models/ManualJournal.ts b/packages/server/src/models/ManualJournal.ts index ab605b51e..3d25b377f 100644 --- a/packages/server/src/models/ManualJournal.ts +++ b/packages/server/src/models/ManualJournal.ts @@ -94,9 +94,9 @@ export default class ManualJournal extends mixin(TenantModel, [ * Relationship mapping. */ static get relationMappings() { - const Media = require('models/Media'); const AccountTransaction = require('models/AccountTransaction'); const ManualJournalEntry = require('models/ManualJournalEntry'); + const Document = require('models/Document'); return { entries: { @@ -121,19 +121,23 @@ export default class ManualJournal extends mixin(TenantModel, [ query.where('referenceType', 'Journal'); }, }, - media: { + + /** + * Manual journal may has many attached attachments. + */ + attachments: { relation: Model.ManyToManyRelation, - modelClass: Media.default, + modelClass: Document.default, join: { from: 'manual_journals.id', through: { - from: 'media_links.model_id', - to: 'media_links.media_id', + from: 'document_links.modelId', + to: 'document_links.documentId', }, - to: 'media.id', + to: 'documents.id', }, filter(query) { - query.where('model_name', 'ManualJournal'); + query.where('model_ref', 'ManualJournal'); }, }, }; diff --git a/packages/server/src/models/PaymentReceive.ts b/packages/server/src/models/PaymentReceive.ts index e27559dbd..b9c2b87bf 100644 --- a/packages/server/src/models/PaymentReceive.ts +++ b/packages/server/src/models/PaymentReceive.ts @@ -56,6 +56,7 @@ export default class PaymentReceive extends mixin(TenantModel, [ const Customer = require('models/Customer'); const Account = require('models/Account'); const Branch = require('models/Branch'); + const Document = require('models/Document'); return { customer: { @@ -111,6 +112,25 @@ export default class PaymentReceive extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * Payment transaction may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'payment_receives.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'PaymentReceive'); + }, + }, }; } diff --git a/packages/server/src/models/SaleEstimate.ts b/packages/server/src/models/SaleEstimate.ts index 31d40eb82..35fa723ec 100644 --- a/packages/server/src/models/SaleEstimate.ts +++ b/packages/server/src/models/SaleEstimate.ts @@ -182,6 +182,7 @@ export default class SaleEstimate extends mixin(TenantModel, [ const ItemEntry = require('models/ItemEntry'); const Customer = require('models/Customer'); const Branch = require('models/Branch'); + const Document = require('models/Document'); return { customer: { @@ -219,6 +220,25 @@ export default class SaleEstimate extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * Sale estimate transaction may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'sales_estimates.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'SaleEstimate'); + }, + }, }; } diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 0e64fd7ee..76a3b437e 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 Document = require('models/Document'); return { /** @@ -523,6 +524,25 @@ export default class SaleInvoice extends mixin(TenantModel, [ builder.where('reference_type', 'SaleInvoice'); }, }, + + /** + * Sale invoice transaction may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'sales_invoices.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'SaleInvoice'); + }, + }, }; } diff --git a/packages/server/src/models/SaleReceipt.ts b/packages/server/src/models/SaleReceipt.ts index 4b20ce78f..ba39af02e 100644 --- a/packages/server/src/models/SaleReceipt.ts +++ b/packages/server/src/models/SaleReceipt.ts @@ -108,6 +108,7 @@ export default class SaleReceipt extends mixin(TenantModel, [ const AccountTransaction = require('models/AccountTransaction'); const ItemEntry = require('models/ItemEntry'); const Branch = require('models/Branch'); + const Document = require('models/Document'); return { customer: { @@ -167,6 +168,25 @@ export default class SaleReceipt extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * Sale receipt transaction may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'sales_receipts.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'SaleReceipt'); + }, + }, }; } diff --git a/packages/server/src/models/VendorCredit.ts b/packages/server/src/models/VendorCredit.ts index 61f7e8241..1dd949205 100644 --- a/packages/server/src/models/VendorCredit.ts +++ b/packages/server/src/models/VendorCredit.ts @@ -177,6 +177,7 @@ export default class VendorCredit extends mixin(TenantModel, [ const Vendor = require('models/Vendor'); const ItemEntry = require('models/ItemEntry'); const Branch = require('models/Branch'); + const Document = require('models/Document'); return { vendor: { @@ -215,6 +216,25 @@ export default class VendorCredit extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * Vendor credit may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document.default, + join: { + from: 'vendor_credits.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'VendorCredit'); + }, + }, }; } diff --git a/packages/server/src/services/Attachments/AttachmentTransformer.ts b/packages/server/src/services/Attachments/AttachmentTransformer.ts new file mode 100644 index 000000000..e7cf70f91 --- /dev/null +++ b/packages/server/src/services/Attachments/AttachmentTransformer.ts @@ -0,0 +1,19 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class AttachmentTransformer extends Transformer { + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['id', 'createdAt']; + }; + + /** + * Includeded attributes. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return []; + }; +} diff --git a/packages/server/src/services/Attachments/AttachmentsApplication.ts b/packages/server/src/services/Attachments/AttachmentsApplication.ts new file mode 100644 index 000000000..ce297d3ba --- /dev/null +++ b/packages/server/src/services/Attachments/AttachmentsApplication.ts @@ -0,0 +1,118 @@ +import { Inject, Service } from 'typedi'; +import { UploadDocument } from './UploadDocument'; +import { DeleteAttachment } from './DeleteAttachment'; +import { GetAttachment } from './GetAttachment'; +import { AttachmentUploadPipeline } from './S3UploadPipeline'; +import { LinkAttachment } from './LinkAttachment'; +import { UnlinkAttachment } from './UnlinkAttachment'; +import { getAttachmentPresignedUrl } from './GetAttachmentPresignedUrl'; +import type { Multer } from 'multer'; + +@Service() +export class AttachmentsApplication { + @Inject() + private uploadDocumentService: UploadDocument; + + @Inject() + private deleteDocumentService: DeleteAttachment; + + @Inject() + private getDocumentService: GetAttachment; + + @Inject() + private uploadPipelineService: AttachmentUploadPipeline; + + @Inject() + private linkDocumentService: LinkAttachment; + + @Inject() + private unlinkDocumentService: UnlinkAttachment; + + @Inject() + private getPresignedUrlService: getAttachmentPresignedUrl; + + /** + * Express middleware for uploading attachments to an S3 bucket. + * @returns {Multer} + */ + get uploadPipeline(): Multer { + return this.uploadPipelineService.uploadPipeline(); + } + + /** + * Saves the metadata of uploaded document to S3 on database. + * @param {number} tenantId + * @param {} file + * @returns {Promise} + */ + public upload(tenantId: number, file: any) { + return this.uploadDocumentService.upload(tenantId, file); + } + + /** + * Deletes the give file attachment file key. + * @param {number} tenantId + * @param {string} documentKey + * @returns {Promise} + */ + public delete(tenantId: number, documentKey: string) { + return this.deleteDocumentService.delete(tenantId, documentKey); + } + + /** + * Retrieves the document data. + * @param {number} tenantId + * @param {string} documentKey + */ + 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 + ); + } + + /** + * Retrieves the presigned url of the given attachment key. + * @param {string} key + * @returns {Promise} + */ + public getPresignedUrl(key: string): Promise { + return this.getPresignedUrlService.getPresignedUrl(key); + } +} diff --git a/packages/server/src/services/Attachments/DeleteAttachment.ts b/packages/server/src/services/Attachments/DeleteAttachment.ts new file mode 100644 index 000000000..78db4b3d0 --- /dev/null +++ b/packages/server/src/services/Attachments/DeleteAttachment.ts @@ -0,0 +1,45 @@ +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { Inject, Service } from 'typedi'; +import { s3 } from '@/lib/S3/S3'; +import HasTenancyService from '../Tenancy/TenancyService'; +import config from '@/config'; +import UnitOfWork from '../UnitOfWork'; +import { Knex } from 'knex'; + +@Service() +export class DeleteAttachment { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + /** + * 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: config.s3.bucket, + Key: filekey, + }; + await s3.send(new DeleteObjectCommand(params)); + + const foundDocument = await Document.query() + .findOne('key', filekey) + .throwIfNotFound(); + + await this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Delete all document links + await DocumentLink.query(trx) + .where('documentId', foundDocument.id) + .delete(); + + // Delete thedocument. + await Document.query(trx).findById(foundDocument.id).delete(); + }); + } +} diff --git a/packages/server/src/services/Attachments/GetAttachment.ts b/packages/server/src/services/Attachments/GetAttachment.ts new file mode 100644 index 000000000..0f70cc9f9 --- /dev/null +++ b/packages/server/src/services/Attachments/GetAttachment.ts @@ -0,0 +1,22 @@ +import { Service } from 'typedi'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { s3 } from '@/lib/S3/S3'; +import config from '@/config'; + +@Service() +export class GetAttachment { + /** + * Retrieves data of the given document key. + * @param {number} tenantId + * @param {string} filekey + */ + async getAttachment(tenantId: number, filekey: string) { + const params = { + Bucket: config.s3.bucket, + Key: filekey, + }; + const data = await s3.send(new GetObjectCommand(params)); + + return data; + } +} diff --git a/packages/server/src/services/Attachments/GetAttachmentPresignedUrl.ts b/packages/server/src/services/Attachments/GetAttachmentPresignedUrl.ts new file mode 100644 index 000000000..62ca3f91c --- /dev/null +++ b/packages/server/src/services/Attachments/GetAttachmentPresignedUrl.ts @@ -0,0 +1,23 @@ +import { Service } from 'typedi'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { s3 } from '@/lib/S3/S3'; +import config from '@/config'; + +@Service() +export class getAttachmentPresignedUrl { + /** + * Retrieves the presigned url of the given attachment key. + * @param {string} key + * @returns {Promise} + */ + async getPresignedUrl(key: string) { + const command = new GetObjectCommand({ + Bucket: config.s3.bucket, + Key: key, + }); + const signedUrl = await getSignedUrl(s3, command, { expiresIn: 300 }); + + return signedUrl; + } +} diff --git a/packages/server/src/services/Attachments/LinkAttachment.ts b/packages/server/src/services/Attachments/LinkAttachment.ts new file mode 100644 index 000000000..b96a763c9 --- /dev/null +++ b/packages/server/src/services/Attachments/LinkAttachment.ts @@ -0,0 +1,82 @@ +import { Inject, Service } from 'typedi'; +import bluebird from 'bluebird'; +import { Knex } from 'knex'; +import { + validateLinkModelEntryExists, + validateLinkModelExists, +} from './_utils'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from './constants'; + +@Service() +export class LinkAttachment { + @Inject() + private tenancy: HasTenancyService; + + /** + * Links the given file key to the given model type and id. + * @param {number} tenantId + * @param {string} filekey + * @param {string} modelRef + * @param {number} modelId + * @returns {Promise} + */ + async link( + tenantId: number, + filekey: string, + modelRef: string, + modelId: number, + trx?: Knex.Transaction + ) { + const { DocumentLink, Document, ...models } = this.tenancy.models(tenantId); + const LinkModel = models[modelRef]; + validateLinkModelExists(LinkModel); + + const foundFile = await Document.query(trx) + .findOne('key', filekey) + .throwIfNotFound(); + + const foundLinkModel = await LinkModel.query(trx).findById(modelId); + validateLinkModelEntryExists(foundLinkModel); + + const foundLinks = await DocumentLink.query(trx) + .where('modelRef', modelRef) + .where('modelId', modelId) + .where('documentId', foundFile.id); + + if (foundLinks.length > 0) { + throw new ServiceError(ERRORS.DOCUMENT_LINK_ALREADY_LINKED); + } + await DocumentLink.query(trx).insert({ + modelRef, + modelId, + documentId: foundFile.id, + }); + } + + /** + * Links the given file keys to the given model type and id. + * @param {number} tenantId + * @param {string[]} filekeys + * @param {string} modelRef + * @param {number} modelId + * @param {Knex.Transaction} trx + * @returns {Promise} + */ + async bulkLink( + tenantId: number, + filekeys: string[], + modelRef: string, + modelId: number, + trx?: Knex.Transaction + ) { + return bluebird.each(filekeys, async (fieldKey: string) => { + try { + await this.link(tenantId, fieldKey, modelRef, modelId, trx); + } catch { + // Ignore catching exceptions in bulk action. + } + }); + } +} diff --git a/packages/server/src/services/Attachments/S3UploadPipeline.ts b/packages/server/src/services/Attachments/S3UploadPipeline.ts new file mode 100644 index 000000000..c79d2855f --- /dev/null +++ b/packages/server/src/services/Attachments/S3UploadPipeline.ts @@ -0,0 +1,29 @@ +import multer from 'multer'; +import type { Multer } from 'multer' +import multerS3 from 'multer-s3'; +import { s3 } from '@/lib/S3/S3'; +import { Service } from 'typedi'; +import config from '@/config'; + +@Service() +export class AttachmentUploadPipeline { + /** + * Express middleware for uploading attachments to an S3 bucket. + * It utilizes the multer middleware for handling multipart/form-data, specifically for file uploads. + */ + public uploadPipeline(): Multer { + return multer({ + storage: multerS3({ + s3, + bucket: config.s3.bucket, + contentType: multerS3.AUTO_CONTENT_TYPE, + metadata: function (req, file, cb) { + cb(null, { fieldName: file.fieldname }); + }, + key: function (req, file, cb) { + cb(null, Date.now().toString()); + }, + }), + }); + } +} diff --git a/packages/server/src/services/Attachments/UnlinkAttachment.ts b/packages/server/src/services/Attachments/UnlinkAttachment.ts new file mode 100644 index 000000000..0633740bb --- /dev/null +++ b/packages/server/src/services/Attachments/UnlinkAttachment.ts @@ -0,0 +1,125 @@ +import { Inject, Service } from 'typedi'; +import bluebird from 'bluebird'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { + validateLinkModelEntryExists, + validateLinkModelExists, +} from './_utils'; +import { Knex } from 'knex'; +import { difference } from 'lodash'; + +@Service() +export class UnlinkAttachment { + @Inject() + private tenancy: HasTenancyService; + + /** + * Unlink the attachments from the model entry. + * @param {number} tenantId + * @param {string} filekey + * @param {string} modelRef + * @param {number} modelId + */ + async unlink( + tenantId: number, + filekey: string, + modelRef: string, + modelId: number, + trx?: Knex.Transaction + ): Promise { + const { DocumentLink, Document, ...models } = this.tenancy.models(tenantId); + const LinkModel = models[modelRef]; + validateLinkModelExists(LinkModel); + + const foundLinkModel = await LinkModel.query(trx).findById(modelId); + validateLinkModelEntryExists(foundLinkModel); + + const document = await Document.query(trx).findOne('key', filekey); + + // Delete the document link. + await DocumentLink.query(trx) + .where('modelRef', modelRef) + .where('modelId', modelId) + .where('documentId', document.id) + .delete(); + } + + /** + * Bulk unlink the attachments from the model entry. + * @param {number} tenantId + * @param {string} fieldkey + * @param {string} modelRef + * @param {number} modelId + * @returns {Promise} + */ + async bulkUnlink( + tenantId: number, + filekeys: string[], + modelRef: string, + modelId: number, + trx?: Knex.Transaction + ): Promise { + await bluebird.each(filekeys, (fieldKey: string) => { + try { + this.unlink(tenantId, fieldKey, modelRef, modelId, trx); + } catch { + // Ignore catching exceptions on bulk action. + } + }); + } + + /** + * Unlink all the unpresented keys of the given model type and id. + * @param {number} tenantId + * @param {string[]} presentedKeys + * @param {string} modelRef + * @param {number} modelId + * @param {Knex.Transaction} trx + */ + async unlinkUnpresentedKeys( + tenantId: number, + presentedKeys: string[], + modelRef: string, + modelId: number, + trx?: Knex.Transaction + ): Promise { + const { DocumentLink } = this.tenancy.models(tenantId); + + const modelLinks = await DocumentLink.query(trx) + .where('modelRef', modelRef) + .where('modelId', modelId) + .withGraphFetched('document'); + + const modelLinkKeys = modelLinks.map((link) => link.document.key); + const unpresentedKeys = difference(modelLinkKeys, presentedKeys); + + await this.bulkUnlink(tenantId, unpresentedKeys, modelRef, modelId, trx); + } + + /** + * Unlink all attachments of the given model type and id. + * @param {number} tenantId + * @param {string} modelRef + * @param {number} modelId + * @param {Knex.Transaction} trx + * @returns {Promise} + */ + async unlinkAllModelKeys( + tenantId: number, + modelRef: string, + modelId: number, + trx?: Knex.Transaction + ): Promise { + const { DocumentLink } = this.tenancy.models(tenantId); + + // Get all the keys of the modelRef and modelId. + const modelLinks = await DocumentLink.query(trx) + .where('modelRef', modelRef) + .where('modelId', modelId) + .withGraphFetched('document'); + + const modelLinkKeys = modelLinks.map((link) => link.document.key); + + await this.bulkUnlink(tenantId, modelLinkKeys, modelRef, modelId, trx); + } +} diff --git a/packages/server/src/services/Attachments/UploadDocument.ts b/packages/server/src/services/Attachments/UploadDocument.ts new file mode 100644 index 000000000..7d63938ef --- /dev/null +++ b/packages/server/src/services/Attachments/UploadDocument.ts @@ -0,0 +1,26 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; + +@Service() +export class UploadDocument { + @Inject() + private tenancy: HasTenancyService; + + /** + * Inserts the document metadata. + * @param {number} tenantId + * @param {} file + * @returns {} + */ + async upload(tenantId: number, file: any) { + const { Document } = this.tenancy.models(tenantId); + + const insertedDocument = await Document.query().insert({ + key: file.key, + mimeType: file.mimetype, + size: file.size, + originName: file.originalname, + }); + return insertedDocument; + } +} diff --git a/packages/server/src/services/Attachments/ValidateAttachments.ts b/packages/server/src/services/Attachments/ValidateAttachments.ts new file mode 100644 index 000000000..885833f40 --- /dev/null +++ b/packages/server/src/services/Attachments/ValidateAttachments.ts @@ -0,0 +1,29 @@ +import { castArray, difference } from 'lodash'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { ServiceError } from '@/exceptions'; +import { Inject, Service } from 'typedi'; + +@Service() +export class ValidateAttachments { + @Inject() + tenancy: HasTenancyService; + + /** + * Validates the given file keys existance. + * @param {number} tenantId + * @param {string|string[]} key + */ + async validate(tenantId: number, key: string | string[]) { + const { Document } = this.tenancy.models(tenantId); + + const keys = castArray(key); + const documents = await Document.query().whereIn('key', key); + const documentKeys = documents.map((document) => document.key); + + const notFoundKeys = difference(keys, documentKeys); + + if (notFoundKeys.length > 0) { + throw new ServiceError('DOCUMENT_KEYS_INVALID'); + } + } +} 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..b046e9ca7 --- /dev/null +++ b/packages/server/src/services/Attachments/constants.ts @@ -0,0 +1,5 @@ +export enum ERRORS { + DOCUMENT_LINK_REF_INVALID = 'DOCUMENT_LINK_REF_INVALID', + DOCUMENT_LINK_ID_INVALID = 'DOCUMENT_LINK_ID_INVALID', + DOCUMENT_LINK_ALREADY_LINKED = 'DOCUMENT_LINK_ALREADY_LINKED' +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnBills.ts b/packages/server/src/services/Attachments/events/AttachmentsOnBills.ts new file mode 100644 index 000000000..6909626ec --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnBills.ts @@ -0,0 +1,151 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IBIllEventDeletedPayload, + IBillCreatedPayload, + IBillCreatingPayload, + IBillEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnBills { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.bill.onCreating, + this.validateAttachmentsOnBillCreate.bind(this) + ); + bus.subscribe( + events.bill.onCreated, + this.handleAttachmentsOnBillCreated.bind(this) + ); + bus.subscribe( + events.bill.onEdited, + this.handleUnlinkUnpresentedKeysOnBillEdited.bind(this) + ); + bus.subscribe( + events.bill.onEdited, + this.handleLinkPresentedKeysOnBillEdited.bind(this) + ); + bus.subscribe( + events.bill.onDeleting, + this.handleUnlinkAttachmentsOnBillDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating bill. + * @param {ISaleInvoiceCreatingPaylaod} + * @returns {Promise} + */ + private async validateAttachmentsOnBillCreate({ + billDTO, + tenantId, + }: IBillCreatingPayload): Promise { + if (isEmpty(billDTO.attachments)) { + return; + } + const documentKeys = billDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created bill. + * @param {ISaleInvoiceCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnBillCreated({ + tenantId, + bill, + billDTO, + trx, + }: IBillCreatedPayload): Promise { + if (isEmpty(billDTO.attachments)) return; + + const keys = billDTO.attachments?.map((attachment) => attachment.key); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'Bill', + bill.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited bill. + * @param {IBillEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnBillEdited({ + tenantId, + billDTO, + bill, + trx + }: IBillEditedPayload) { + const keys = billDTO.attachments?.map((attachment) => attachment.key); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'Bill', + bill.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited bill. + * @param {ISaleInvoiceEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnBillEdited({ + tenantId, + billDTO, + oldBill, + trx, + }: IBillEditedPayload) { + if (isEmpty(billDTO.attachments)) return; + + const keys = billDTO.attachments?.map((attachment) => attachment.key); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'Bill', + oldBill.id, + trx + ); + } + + /** + * Unlink all attachments once the bill deleted. + * @param {ISaleInvoiceDeletedPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnBillDeleted({ + tenantId, + oldBill, + trx, + }: IBIllEventDeletedPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'Bill', + oldBill.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnCreditNote.ts b/packages/server/src/services/Attachments/events/AttachmentsOnCreditNote.ts new file mode 100644 index 000000000..32547b036 --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnCreditNote.ts @@ -0,0 +1,155 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + ICreditNoteCreatedPayload, + ICreditNoteCreatingPayload, + ICreditNoteDeletingPayload, + ICreditNoteEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnCreditNote { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.creditNote.onCreating, + this.validateAttachmentsOnCreditNoteCreate.bind(this) + ); + bus.subscribe( + events.creditNote.onCreated, + this.handleAttachmentsOnCreditNoteCreated.bind(this) + ); + bus.subscribe( + events.creditNote.onEdited, + this.handleUnlinkUnpresentedKeysOnCreditNoteEdited.bind(this) + ); + bus.subscribe( + events.creditNote.onEdited, + this.handleLinkPresentedKeysOnCreditNoteEdited.bind(this) + ); + bus.subscribe( + events.creditNote.onDeleting, + this.handleUnlinkAttachmentsOnCreditNoteDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating credit note. + * @param {ICreditNoteCreatingPayload} + * @returns {Promise} + */ + private async validateAttachmentsOnCreditNoteCreate({ + creditNoteDTO, + tenantId, + }: ICreditNoteCreatingPayload): Promise { + if (isEmpty(creditNoteDTO.attachments)) { + return; + } + const documentKeys = creditNoteDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created credit note. + * @param {ICreditNoteCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnCreditNoteCreated({ + tenantId, + creditNote, + creditNoteDTO, + trx, + }: ICreditNoteCreatedPayload): Promise { + if (isEmpty(creditNoteDTO.attachments)) return; + + const keys = creditNoteDTO.attachments?.map((attachment) => attachment.key); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'CreditNote', + creditNote.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited credit note. + * @param {ICreditNoteEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnCreditNoteEdited({ + tenantId, + creditNoteEditDTO, + oldCreditNote, + trx, + }: ICreditNoteEditedPayload) { + const keys = creditNoteEditDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'CreditNote', + oldCreditNote.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited credit note. + * @param {ICreditNoteEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnCreditNoteEdited({ + tenantId, + creditNoteEditDTO, + oldCreditNote, + trx, + }: ICreditNoteEditedPayload) { + if (isEmpty(creditNoteEditDTO.attachments)) return; + + const keys = creditNoteEditDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'CreditNote', + oldCreditNote.id, + trx + ); + } + + /** + * Unlink all attachments once the credit note deleted. + * @param {ICreditNoteDeletingPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnCreditNoteDeleted({ + tenantId, + oldCreditNote, + trx, + }: ICreditNoteDeletingPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'CreditNote', + oldCreditNote.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnExpenses.ts b/packages/server/src/services/Attachments/events/AttachmentsOnExpenses.ts new file mode 100644 index 000000000..77babe6c0 --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnExpenses.ts @@ -0,0 +1,151 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IExpenseCreatedPayload, + IExpenseCreatingPayload, + IExpenseDeletingPayload, + IExpenseEventEditPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnExpenses { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.expenses.onCreating, + this.validateAttachmentsOnExpenseCreate.bind(this) + ); + bus.subscribe( + events.expenses.onCreated, + this.handleAttachmentsOnExpenseCreated.bind(this) + ); + bus.subscribe( + events.expenses.onEdited, + this.handleUnlinkUnpresentedKeysOnExpenseEdited.bind(this) + ); + bus.subscribe( + events.expenses.onEdited, + this.handleLinkPresentedKeysOnExpenseEdited.bind(this) + ); + bus.subscribe( + events.expenses.onDeleting, + this.handleUnlinkAttachmentsOnExpenseDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating expense. + * @param {ISaleInvoiceCreatingPaylaod} + * @returns {Promise} + */ + private async validateAttachmentsOnExpenseCreate({ + expenseDTO, + tenantId, + }: IExpenseCreatingPayload): Promise { + if (isEmpty(expenseDTO.attachments)) { + return; + } + const documentKeys = expenseDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created expense. + * @param {ISaleInvoiceCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnExpenseCreated({ + tenantId, + expenseDTO, + expense, + trx, + }: IExpenseCreatedPayload): Promise { + if (isEmpty(expenseDTO.attachments)) return; + + const keys = expenseDTO.attachments?.map((attachment) => attachment.key); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'Expense', + expense.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited expense. + * @param {ISaleInvoiceEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnExpenseEdited({ + tenantId, + expenseDTO, + expense, + trx, + }: IExpenseEventEditPayload) { + const keys = expenseDTO.attachments?.map((attachment) => attachment.key); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'Expense', + expense.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited expense. + * @param {ISaleInvoiceEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnExpenseEdited({ + tenantId, + expenseDTO, + oldExpense, + trx, + }: IExpenseEventEditPayload) { + if (isEmpty(expenseDTO.attachments)) return; + + const keys = expenseDTO.attachments?.map((attachment) => attachment.key); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'Expense', + oldExpense.id, + trx + ); + } + + /** + * Unlink all attachments once the expense deleted. + * @param {ISaleInvoiceDeletedPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnExpenseDeleted({ + tenantId, + oldExpense, + trx, + }: IExpenseDeletingPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'Expense', + oldExpense.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnManualJournals.ts b/packages/server/src/services/Attachments/events/AttachmentsOnManualJournals.ts new file mode 100644 index 000000000..b0a133d3a --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnManualJournals.ts @@ -0,0 +1,157 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IManualJournalCreatingPayload, + IManualJournalEventCreatedPayload, + IManualJournalEventDeletedPayload, + IManualJournalEventEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnManualJournals { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.manualJournals.onCreating, + this.validateAttachmentsOnManualJournalCreate.bind(this) + ); + bus.subscribe( + events.manualJournals.onCreated, + this.handleAttachmentsOnManualJournalCreated.bind(this) + ); + bus.subscribe( + events.manualJournals.onEdited, + this.handleUnlinkUnpresentedKeysOnManualJournalEdited.bind(this) + ); + bus.subscribe( + events.manualJournals.onEdited, + this.handleLinkPresentedKeysOnManualJournalEdited.bind(this) + ); + bus.subscribe( + events.manualJournals.onDeleting, + this.handleUnlinkAttachmentsOnManualJournalDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating manual journal. + * @param {IManualJournalCreatingPayload} + * @returns {Promise} + */ + private async validateAttachmentsOnManualJournalCreate({ + manualJournalDTO, + tenantId, + }: IManualJournalCreatingPayload): Promise { + if (isEmpty(manualJournalDTO.attachments)) { + return; + } + const documentKeys = manualJournalDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created manual journal. + * @param {IManualJournalEventCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnManualJournalCreated({ + tenantId, + manualJournalDTO, + manualJournal, + trx, + }: IManualJournalEventCreatedPayload): Promise { + if (isEmpty(manualJournalDTO.attachments)) return; + + const keys = manualJournalDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'ManualJournal', + manualJournal.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited manual journal. + * @param {ISaleInvoiceEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnManualJournalEdited({ + tenantId, + manualJournalDTO, + manualJournal, + trx + }: IManualJournalEventEditedPayload) { + const keys = manualJournalDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'SaleInvoice', + manualJournal.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited manual journal. + * @param {ISaleInvoiceEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnManualJournalEdited({ + tenantId, + manualJournalDTO, + oldManualJournal, + trx, + }: IManualJournalEventEditedPayload) { + if (isEmpty(manualJournalDTO.attachments)) return; + + const keys = manualJournalDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'ManualJournal', + oldManualJournal.id, + trx + ); + } + + /** + * Unlink all attachments once the manual journal deleted. + * @param {ISaleInvoiceDeletedPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnManualJournalDeleted({ + tenantId, + oldManualJournal, + trx, + }: IManualJournalEventDeletedPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'SaleInvoice', + oldManualJournal.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnPaymentsMade.ts b/packages/server/src/services/Attachments/events/AttachmentsOnPaymentsMade.ts new file mode 100644 index 000000000..276e1abbb --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnPaymentsMade.ts @@ -0,0 +1,157 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IBillPaymentCreatingPayload, + IBillPaymentDeletingPayload, + IBillPaymentEventCreatedPayload, + IBillPaymentEventEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnBillPayments { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.billPayment.onCreating, + this.validateAttachmentsOnBillPaymentCreate.bind(this) + ); + bus.subscribe( + events.billPayment.onCreated, + this.handleAttachmentsOnBillPaymentCreated.bind(this) + ); + bus.subscribe( + events.billPayment.onEdited, + this.handleUnlinkUnpresentedKeysOnBillPaymentEdited.bind(this) + ); + bus.subscribe( + events.billPayment.onEdited, + this.handleLinkPresentedKeysOnBillPaymentEdited.bind(this) + ); + bus.subscribe( + events.billPayment.onDeleting, + this.handleUnlinkAttachmentsOnBillPaymentDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating bill payment. + * @param {IBillPaymentCreatingPayload} + * @returns {Promise} + */ + private async validateAttachmentsOnBillPaymentCreate({ + billPaymentDTO, + tenantId, + }: IBillPaymentCreatingPayload): Promise { + if (isEmpty(billPaymentDTO.attachments)) { + return; + } + const documentKeys = billPaymentDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created bill payment. + * @param {IBillPaymentEventCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnBillPaymentCreated({ + tenantId, + billPaymentDTO, + billPayment, + trx, + }: IBillPaymentEventCreatedPayload): Promise { + if (isEmpty(billPaymentDTO.attachments)) return; + + const keys = billPaymentDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'BillPayment', + billPayment.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited bill payment. + * @param {IBillPaymentEventEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnBillPaymentEdited({ + tenantId, + billPaymentDTO, + oldBillPayment, + trx, + }: IBillPaymentEventEditedPayload) { + const keys = billPaymentDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'BillPayment', + oldBillPayment.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited bill payment. + * @param {IBillPaymentEventEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnBillPaymentEdited({ + tenantId, + billPaymentDTO, + oldBillPayment, + trx, + }: IBillPaymentEventEditedPayload) { + if (isEmpty(billPaymentDTO.attachments)) return; + + const keys = billPaymentDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'BillPayment', + oldBillPayment.id, + trx + ); + } + + /** + * Unlink all attachments once the bill payment deleted. + * @param {IBillPaymentDeletingPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnBillPaymentDeleted({ + tenantId, + oldBillPayment, + trx, + }: IBillPaymentDeletingPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'BillPayment', + oldBillPayment.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnPaymentsReceived.ts b/packages/server/src/services/Attachments/events/AttachmentsOnPaymentsReceived.ts new file mode 100644 index 000000000..f4e876c6a --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnPaymentsReceived.ts @@ -0,0 +1,157 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IPaymentReceiveCreatedPayload, + IPaymentReceiveCreatingPayload, + IPaymentReceiveDeletingPayload, + IPaymentReceiveEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnPaymentsReceived { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.paymentReceive.onCreating, + this.validateAttachmentsOnPaymentCreate.bind(this) + ); + bus.subscribe( + events.paymentReceive.onCreated, + this.handleAttachmentsOnPaymentCreated.bind(this) + ); + bus.subscribe( + events.paymentReceive.onEdited, + this.handleUnlinkUnpresentedKeysOnPaymentEdited.bind(this) + ); + bus.subscribe( + events.paymentReceive.onEdited, + this.handleLinkPresentedKeysOnPaymentEdited.bind(this) + ); + bus.subscribe( + events.paymentReceive.onDeleting, + this.handleUnlinkAttachmentsOnPaymentDelete.bind(this) + ); + } + + /** + * Validates the attachment keys on creating payment. + * @param {IPaymentReceiveCreatingPayload} + * @returns {Promise} + */ + private async validateAttachmentsOnPaymentCreate({ + paymentReceiveDTO, + tenantId, + }: IPaymentReceiveCreatingPayload): Promise { + if (isEmpty(paymentReceiveDTO.attachments)) { + return; + } + const documentKeys = paymentReceiveDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created payment. + * @param {IPaymentReceiveCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnPaymentCreated({ + tenantId, + paymentReceiveDTO, + paymentReceive, + trx, + }: IPaymentReceiveCreatedPayload): Promise { + if (isEmpty(paymentReceiveDTO.attachments)) return; + + const keys = paymentReceiveDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'PaymentReceive', + paymentReceive.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited payment. + * @param {IPaymentReceiveEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnPaymentEdited({ + tenantId, + paymentReceiveDTO, + oldPaymentReceive, + trx, + }: IPaymentReceiveEditedPayload) { + const keys = paymentReceiveDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'PaymentReceive', + oldPaymentReceive.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited payment. + * @param {IPaymentReceiveEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnPaymentEdited({ + tenantId, + paymentReceiveDTO, + oldPaymentReceive, + trx, + }: IPaymentReceiveEditedPayload) { + if (isEmpty(paymentReceiveDTO.attachments)) return; + + const keys = paymentReceiveDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'PaymentReceive', + oldPaymentReceive.id, + trx + ); + } + + /** + * Unlink all attachments once the payment deleted. + * @param {ISaleInvoiceDeletedPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnPaymentDelete({ + tenantId, + oldPaymentReceive, + trx, + }: IPaymentReceiveDeletingPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'PaymentReceive', + oldPaymentReceive.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnSaleEstimates.ts b/packages/server/src/services/Attachments/events/AttachmentsOnSaleEstimates.ts new file mode 100644 index 000000000..53eb483bb --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnSaleEstimates.ts @@ -0,0 +1,154 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + ISaleEstimateCreatedPayload, + ISaleEstimateCreatingPayload, + ISaleEstimateDeletingPayload, + ISaleEstimateEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnSaleEstimates { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.saleEstimate.onCreating, + this.validateAttachmentsOnSaleEstimateCreated.bind(this) + ); + bus.subscribe( + events.saleEstimate.onCreated, + this.handleAttachmentsOnSaleEstimateCreated.bind(this) + ); + bus.subscribe( + events.saleEstimate.onEdited, + this.handleUnlinkUnpresentedKeysOnSaleEstimateEdited.bind(this) + ); + bus.subscribe( + events.saleEstimate.onEdited, + this.handleLinkPresentedKeysOnSaleEstimateEdited.bind(this) + ); + bus.subscribe( + events.saleEstimate.onDeleting, + this.handleUnlinkAttachmentsOnSaleEstimateDelete.bind(this) + ); + } + + /** + * Validates the attachment keys on creating sale estimate. + * @param {ISaleEstimateCreatingPayload} + * @returns {Promise} + */ + private async validateAttachmentsOnSaleEstimateCreated({ + estimateDTO, + tenantId, + }: ISaleEstimateCreatingPayload): Promise { + if (isEmpty(estimateDTO.attachments)) { + return; + } + const documentKeys = estimateDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created sale estimate. + * @param {ISaleEstimateCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnSaleEstimateCreated({ + tenantId, + saleEstimateDTO, + saleEstimate, + trx, + }: ISaleEstimateCreatedPayload): Promise { + if (isEmpty(saleEstimateDTO.attachments)) return; + + const keys = saleEstimateDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'SaleEstimate', + saleEstimate.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited sale estimate. + * @param {ISaleEstimateEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnSaleEstimateEdited({ + tenantId, + estimateDTO, + oldSaleEstimate, + trx + }: ISaleEstimateEditedPayload) { + const keys = estimateDTO.attachments?.map((attachment) => attachment.key); + + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'SaleEstimate', + oldSaleEstimate.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited sale estimate. + * @param {ISaleEstimateEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnSaleEstimateEdited({ + tenantId, + estimateDTO, + oldSaleEstimate, + trx, + }: ISaleEstimateEditedPayload) { + if (isEmpty(estimateDTO.attachments)) return; + + const keys = estimateDTO.attachments?.map((attachment) => attachment.key); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'SaleEstimate', + oldSaleEstimate.id, + trx + ); + } + + /** + * Unlink all attachments once the estimate deleted. + * @param {ISaleEstimateDeletingPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnSaleEstimateDelete({ + tenantId, + oldSaleEstimate, + trx, + }: ISaleEstimateDeletingPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'SaleEstimate', + oldSaleEstimate.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnSaleInvoice.ts b/packages/server/src/services/Attachments/events/AttachmentsOnSaleInvoice.ts new file mode 100644 index 000000000..e6a51e4b6 --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnSaleInvoice.ts @@ -0,0 +1,159 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + ISaleInvoiceCreatedPayload, + ISaleInvoiceCreatingPaylaod, + ISaleInvoiceDeletePayload, + ISaleInvoiceEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnSaleInvoiceCreated { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.saleInvoice.onCreating, + this.validateAttachmentsOnSaleInvoiceCreate.bind(this) + ); + bus.subscribe( + events.saleInvoice.onCreated, + this.handleAttachmentsOnSaleInvoiceCreated.bind(this) + ); + bus.subscribe( + events.saleInvoice.onEdited, + this.handleUnlinkUnpresentedKeysOnInvoiceEdited.bind(this) + ); + bus.subscribe( + events.saleInvoice.onEdited, + this.handleLinkPresentedKeysOnInvoiceEdited.bind(this) + ); + bus.subscribe( + events.saleInvoice.onDeleting, + this.handleUnlinkAttachmentsOnInvoiceDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating sale invoice. + * @param {ISaleInvoiceCreatingPaylaod} + * @returns {Promise} + */ + private async validateAttachmentsOnSaleInvoiceCreate({ + saleInvoiceDTO, + tenantId, + }: ISaleInvoiceCreatingPaylaod): Promise { + if (isEmpty(saleInvoiceDTO.attachments)) { + return; + } + const documentKeys = saleInvoiceDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created sale invoice. + * @param {ISaleInvoiceCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnSaleInvoiceCreated({ + tenantId, + saleInvoiceDTO, + saleInvoice, + trx, + }: ISaleInvoiceCreatedPayload): Promise { + if (isEmpty(saleInvoiceDTO.attachments)) return; + + const keys = saleInvoiceDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'SaleInvoice', + saleInvoice.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited sale invoice. + * @param {ISaleInvoiceEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnInvoiceEdited({ + tenantId, + saleInvoiceDTO, + saleInvoice, + trx, + }: ISaleInvoiceEditedPayload) { + // if (isEmpty(saleInvoiceDTO.attachments)) return; + + const keys = saleInvoiceDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'SaleInvoice', + saleInvoice.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited sale invoice. + * @param {ISaleInvoiceEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnInvoiceEdited({ + tenantId, + saleInvoiceDTO, + oldSaleInvoice, + trx, + }: ISaleInvoiceEditedPayload) { + if (isEmpty(saleInvoiceDTO.attachments)) return; + + const keys = saleInvoiceDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'SaleInvoice', + oldSaleInvoice.id, + trx + ); + } + + /** + * Unlink all attachments once the invoice deleted. + * @param {ISaleInvoiceDeletedPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnInvoiceDeleted({ + tenantId, + saleInvoice, + trx, + }: ISaleInvoiceDeletePayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'SaleInvoice', + saleInvoice.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnSaleReceipts.ts b/packages/server/src/services/Attachments/events/AttachmentsOnSaleReceipts.ts new file mode 100644 index 000000000..01a8d9fc3 --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnSaleReceipts.ts @@ -0,0 +1,157 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + ISaleReceiptCreatedPayload, + ISaleReceiptCreatingPayload, + ISaleReceiptDeletingPayload, + ISaleReceiptEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnSaleReceipt { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.saleReceipt.onCreating, + this.validateAttachmentsOnSaleInvoiceCreate.bind(this) + ); + bus.subscribe( + events.saleReceipt.onCreated, + this.handleAttachmentsOnSaleInvoiceCreated.bind(this) + ); + bus.subscribe( + events.saleReceipt.onEdited, + this.handleUnlinkUnpresentedKeysOnInvoiceEdited.bind(this) + ); + bus.subscribe( + events.saleReceipt.onEdited, + this.handleLinkPresentedKeysOnInvoiceEdited.bind(this) + ); + bus.subscribe( + events.saleReceipt.onDeleting, + this.handleUnlinkAttachmentsOnReceiptDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating sale receipt. + * @param {ISaleReceiptCreatingPayload} + * @returns {Promise} + */ + private async validateAttachmentsOnSaleInvoiceCreate({ + saleReceiptDTO, + tenantId, + }: ISaleReceiptCreatingPayload): Promise { + if (isEmpty(saleReceiptDTO.attachments)) { + return; + } + const documentKeys = saleReceiptDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created sale receipt. + * @param {ISaleReceiptCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnSaleInvoiceCreated({ + tenantId, + saleReceiptDTO, + saleReceipt, + trx, + }: ISaleReceiptCreatedPayload): Promise { + if (isEmpty(saleReceiptDTO.attachments)) return; + + const keys = saleReceiptDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'SaleReceipt', + saleReceipt.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited sale receipt. + * @param {ISaleReceiptEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnInvoiceEdited({ + tenantId, + saleReceiptDTO, + saleReceipt, + trx, + }: ISaleReceiptEditedPayload) { + const keys = saleReceiptDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'SaleReceipt', + saleReceipt.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited sale receipt. + * @param {ISaleReceiptEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnInvoiceEdited({ + tenantId, + saleReceiptDTO, + oldSaleReceipt, + trx, + }: ISaleReceiptEditedPayload) { + if (isEmpty(saleReceiptDTO.attachments)) return; + + const keys = saleReceiptDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'SaleReceipt', + oldSaleReceipt.id, + trx + ); + } + + /** + * Unlink all attachments once the receipt deleted. + * @param {ISaleReceiptDeletingPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnReceiptDeleted({ + tenantId, + oldSaleReceipt, + trx, + }: ISaleReceiptDeletingPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'SaleReceipt', + oldSaleReceipt.id, + trx + ); + } +} diff --git a/packages/server/src/services/Attachments/events/AttachmentsOnVendorCredits.ts b/packages/server/src/services/Attachments/events/AttachmentsOnVendorCredits.ts new file mode 100644 index 000000000..30ce63ca5 --- /dev/null +++ b/packages/server/src/services/Attachments/events/AttachmentsOnVendorCredits.ts @@ -0,0 +1,157 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IVendorCreditCreatedPayload, + IVendorCreditCreatingPayload, + IVendorCreditDeletingPayload, + IVendorCreditEditedPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { LinkAttachment } from '../LinkAttachment'; +import { ValidateAttachments } from '../ValidateAttachments'; +import { UnlinkAttachment } from '../UnlinkAttachment'; + +@Service() +export class AttachmentsOnVendorCredits { + @Inject() + private linkAttachmentService: LinkAttachment; + + @Inject() + private unlinkAttachmentService: UnlinkAttachment; + + @Inject() + private validateDocuments: ValidateAttachments; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.vendorCredit.onCreating, + this.validateAttachmentsOnVendorCreditCreate.bind(this) + ); + bus.subscribe( + events.vendorCredit.onCreated, + this.handleAttachmentsOnVendorCreditCreated.bind(this) + ); + bus.subscribe( + events.vendorCredit.onEdited, + this.handleUnlinkUnpresentedKeysOnVendorCreditEdited.bind(this) + ); + bus.subscribe( + events.vendorCredit.onEdited, + this.handleLinkPresentedKeysOnVendorCreditEdited.bind(this) + ); + bus.subscribe( + events.vendorCredit.onDeleting, + this.handleUnlinkAttachmentsOnVendorCreditDeleted.bind(this) + ); + } + + /** + * Validates the attachment keys on creating vendor credit. + * @param {IVendorCreditCreatingPayload} + * @returns {Promise} + */ + private async validateAttachmentsOnVendorCreditCreate({ + vendorCreditCreateDTO, + tenantId, + }: IVendorCreditCreatingPayload): Promise { + if (isEmpty(vendorCreditCreateDTO.attachments)) { + return; + } + const documentKeys = vendorCreditCreateDTO?.attachments?.map((a) => a.key); + + await this.validateDocuments.validate(tenantId, documentKeys); + } + + /** + * Handles linking the attachments of the created vendor credit. + * @param {IVendorCreditCreatedPayload} + * @returns {Promise} + */ + private async handleAttachmentsOnVendorCreditCreated({ + tenantId, + vendorCreditCreateDTO, + vendorCredit, + trx, + }: IVendorCreditCreatedPayload): Promise { + if (isEmpty(vendorCreditCreateDTO.attachments)) return; + + const keys = vendorCreditCreateDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'VendorCredit', + vendorCredit.id, + trx + ); + } + + /** + * Handles unlinking all the unpresented keys of the edited vendor credit. + * @param {IVendorCreditEditedPayload} + */ + private async handleUnlinkUnpresentedKeysOnVendorCreditEdited({ + tenantId, + vendorCreditDTO, + oldVendorCredit, + trx, + }: IVendorCreditEditedPayload) { + const keys = vendorCreditDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.unlinkAttachmentService.unlinkUnpresentedKeys( + tenantId, + keys, + 'VendorCredit', + oldVendorCredit.id, + trx + ); + } + + /** + * Handles linking all the presented keys of the edited vendor credit. + * @param {IVendorCreditEditedPayload} + * @returns {Promise} + */ + private async handleLinkPresentedKeysOnVendorCreditEdited({ + tenantId, + vendorCreditDTO, + oldVendorCredit, + trx, + }: IVendorCreditEditedPayload) { + if (isEmpty(vendorCreditDTO.attachments)) return; + + const keys = vendorCreditDTO.attachments?.map( + (attachment) => attachment.key + ); + await this.linkAttachmentService.bulkLink( + tenantId, + keys, + 'VendorCredit', + oldVendorCredit.id, + trx + ); + } + + /** + * Unlink all attachments once the vendor credit deleted. + * @param {IVendorCreditDeletingPayload} + * @returns {Promise} + */ + private async handleUnlinkAttachmentsOnVendorCreditDeleted({ + tenantId, + oldVendorCredit, + trx, + }: IVendorCreditDeletingPayload) { + await this.unlinkAttachmentService.unlinkAllModelKeys( + tenantId, + 'VendorCredit', + oldVendorCredit.id, + trx + ); + } +} diff --git a/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts b/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts index 14f9aaa07..19f432df2 100644 --- a/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts +++ b/packages/server/src/services/Authentication/events/SendVerfiyMailOnSignUp.ts @@ -20,6 +20,10 @@ export class SendVerfiyMailOnSignUp { private handleSendVerifyMailOnSignup = async ({ user, }: IAuthSignedUpEventPayload) => { + // Can't continue if the user is verified. + if (user.verified) { + return; + } const payload = { email: user.email, token: user.verifyToken, diff --git a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts index 6ed80a6f0..67b51b40d 100644 --- a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts +++ b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts @@ -2,6 +2,7 @@ import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { ItemEntryTransformer } from '../Sales/Invoices/ItemEntryTransformer'; import { ICreditNote } from '@/interfaces'; +import { AttachmentTransformer } from '../Attachments/AttachmentTransformer'; export class CreditNoteTransformer extends Transformer { /** @@ -16,6 +17,7 @@ export class CreditNoteTransformer extends Transformer { 'formattedCreditsUsed', 'formattedSubtotal', 'entries', + 'attachments', ]; }; @@ -80,4 +82,13 @@ export class CreditNoteTransformer extends Transformer { currencyCode: credit.currencyCode, }); }; + + /** + * Retrieves the credit note attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (creditNote) => { + return this.item(creditNote.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/CreditNotes/CreditNotes.ts b/packages/server/src/services/CreditNotes/CreditNotes.ts index f5485ad08..6fedf3245 100644 --- a/packages/server/src/services/CreditNotes/CreditNotes.ts +++ b/packages/server/src/services/CreditNotes/CreditNotes.ts @@ -57,7 +57,7 @@ export default class BaseCreditNotes { autoNextNumber; const initialDTO = { - ...omit(creditNoteDTO, ['open']), + ...omit(creditNoteDTO, ['open', 'attachments']), creditNoteNumber, amount, currencyCode: customerCurrencyCode, diff --git a/packages/server/src/services/CreditNotes/GetCreditNote.ts b/packages/server/src/services/CreditNotes/GetCreditNote.ts index 95dd044fd..8f9484b8f 100644 --- a/packages/server/src/services/CreditNotes/GetCreditNote.ts +++ b/packages/server/src/services/CreditNotes/GetCreditNote.ts @@ -28,7 +28,8 @@ export default class GetCreditNote extends BaseCreditNotes { .findById(creditNoteId) .withGraphFetched('entries.item') .withGraphFetched('customer') - .withGraphFetched('branch'); + .withGraphFetched('branch') + .withGraphFetched('attachments'); if (!creditNote) { throw new ServiceError(ERRORS.CREDIT_NOTE_NOT_FOUND); diff --git a/packages/server/src/services/Expenses/CRUD/CreateExpense.ts b/packages/server/src/services/Expenses/CRUD/CreateExpense.ts index 8d002f9b0..a342daed7 100644 --- a/packages/server/src/services/Expenses/CRUD/CreateExpense.ts +++ b/packages/server/src/services/Expenses/CRUD/CreateExpense.ts @@ -123,6 +123,7 @@ export class CreateExpense { tenantId, expenseId: expense.id, authorizedUser, + expenseDTO, expense, trx, } as IExpenseCreatedPayload); diff --git a/packages/server/src/services/Expenses/CRUD/ExpenseDTOTransformer.ts b/packages/server/src/services/Expenses/CRUD/ExpenseDTOTransformer.ts index 9357df7c5..d53148e92 100644 --- a/packages/server/src/services/Expenses/CRUD/ExpenseDTOTransformer.ts +++ b/packages/server/src/services/Expenses/CRUD/ExpenseDTOTransformer.ts @@ -54,7 +54,7 @@ export class ExpenseDTOTransformer { const initialDTO = { categories: [], - ...omit(expenseDTO, ['publish']), + ...omit(expenseDTO, ['publish', 'attachments']), totalAmount, landedCostAmount, paymentDate: moment(expenseDTO.paymentDate).toMySqlDateTime(), diff --git a/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts b/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts index 89f461934..92caf0959 100644 --- a/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts +++ b/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts @@ -2,6 +2,7 @@ import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { IExpense } from '@/interfaces'; import { ExpenseCategoryTransformer } from './ExpenseCategoryTransformer'; +import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; export class ExpenseTransfromer extends Transformer { /** @@ -15,6 +16,7 @@ export class ExpenseTransfromer extends Transformer { 'formattedAllocatedCostAmount', 'formattedDate', 'categories', + 'attachments', ]; }; @@ -70,4 +72,13 @@ export class ExpenseTransfromer extends Transformer { currencyCode: expense.currencyCode, }); }; + + /** + * Retrieves the sale invoice attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (expense: IExpense) => { + return this.item(expense.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Expenses/CRUD/GetExpense.ts b/packages/server/src/services/Expenses/CRUD/GetExpense.ts index 2203d4e13..e8961ecca 100644 --- a/packages/server/src/services/Expenses/CRUD/GetExpense.ts +++ b/packages/server/src/services/Expenses/CRUD/GetExpense.ts @@ -29,6 +29,7 @@ export class GetExpense { .withGraphFetched('categories.expenseAccount') .withGraphFetched('paymentAccount') .withGraphFetched('branch') + .withGraphFetched('attachments') .throwIfNotFound(); // Transformes expense model to POJO. diff --git a/packages/server/src/services/ManualJournals/CreateManualJournal.ts b/packages/server/src/services/ManualJournals/CreateManualJournal.ts index 3212e1d1d..513f8d642 100644 --- a/packages/server/src/services/ManualJournals/CreateManualJournal.ts +++ b/packages/server/src/services/ManualJournals/CreateManualJournal.ts @@ -12,7 +12,7 @@ import { } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import events from '@/subscribers/events'; -import { Tenant, TenantMetadata } from '@/system/models'; +import { TenantMetadata } from '@/system/models'; import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { CommandManualJournalValidators } from './CommandManualJournalValidators'; @@ -59,7 +59,7 @@ export class CreateManualJournalService { const journalNumber = manualJournalDTO.journalNumber || autoNextNumber; const initialDTO = { - ...omit(manualJournalDTO, ['publish']), + ...omit(manualJournalDTO, ['publish', 'attachments']), ...(manualJournalDTO.publish ? { publishedAt: moment().toMySqlDateTime() } : {}), @@ -173,6 +173,7 @@ export class CreateManualJournalService { tenantId, manualJournal, manualJournalId: manualJournal.id, + manualJournalDTO, trx, } as IManualJournalEventCreatedPayload); diff --git a/packages/server/src/services/ManualJournals/EditManualJournal.ts b/packages/server/src/services/ManualJournals/EditManualJournal.ts index 39ff8cb8e..56ba1cf7d 100644 --- a/packages/server/src/services/ManualJournals/EditManualJournal.ts +++ b/packages/server/src/services/ManualJournals/EditManualJournal.ts @@ -78,7 +78,7 @@ export class EditManualJournal { return { id: oldManualJournal.id, - ...omit(manualJournalDTO, ['publish']), + ...omit(manualJournalDTO, ['publish', 'attachments']), ...(manualJournalDTO.publish && !oldManualJournal.publishedAt ? { publishedAt: moment().toMySqlDateTime() } : {}), @@ -143,6 +143,7 @@ export class EditManualJournal { tenantId, manualJournal, oldManualJournal, + manualJournalDTO, trx, } as IManualJournalEventEditedPayload); diff --git a/packages/server/src/services/ManualJournals/GetManualJournal.ts b/packages/server/src/services/ManualJournals/GetManualJournal.ts index 97142a494..8be45f948 100644 --- a/packages/server/src/services/ManualJournals/GetManualJournal.ts +++ b/packages/server/src/services/ManualJournals/GetManualJournal.ts @@ -28,7 +28,7 @@ export class GetManualJournal { .withGraphFetched('entries.contact') .withGraphFetched('entries.branch') .withGraphFetched('transactions') - .withGraphFetched('media') + .withGraphFetched('attachments') .throwIfNotFound(); return this.transformer.transform( diff --git a/packages/server/src/services/ManualJournals/ManualJournalTransformer.ts b/packages/server/src/services/ManualJournals/ManualJournalTransformer.ts index 7df4f71ae..cd4d0baa9 100644 --- a/packages/server/src/services/ManualJournals/ManualJournalTransformer.ts +++ b/packages/server/src/services/ManualJournals/ManualJournalTransformer.ts @@ -1,6 +1,7 @@ import { IManualJournal } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; +import { AttachmentTransformer } from '../Attachments/AttachmentTransformer'; export class ManualJournalTransfromer extends Transformer { /** @@ -8,7 +9,12 @@ export class ManualJournalTransfromer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['formattedAmount', 'formattedDate', 'formattedPublishedAt']; + return [ + 'formattedAmount', + 'formattedDate', + 'formattedPublishedAt', + 'attachments', + ]; }; /** @@ -39,4 +45,13 @@ export class ManualJournalTransfromer extends Transformer { protected formattedPublishedAt = (manualJorunal: IManualJournal): string => { return this.formatDate(manualJorunal.publishedAt); }; + + /** + * Retrieves the manual journal attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (manualJorunal: IManualJournal) => { + return this.item(manualJorunal.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Purchases/BillPayments/BillPaymentTransformer.ts b/packages/server/src/services/Purchases/BillPayments/BillPaymentTransformer.ts index a6662dc2d..adf77dd41 100644 --- a/packages/server/src/services/Purchases/BillPayments/BillPaymentTransformer.ts +++ b/packages/server/src/services/Purchases/BillPayments/BillPaymentTransformer.ts @@ -2,6 +2,7 @@ import { IBillPayment } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { BillPaymentEntryTransformer } from './BillPaymentEntryTransformer'; +import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; export class BillPaymentTransformer extends Transformer { /** @@ -9,7 +10,12 @@ export class BillPaymentTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['formattedPaymentDate', 'formattedAmount', 'entries']; + return [ + 'formattedPaymentDate', + 'formattedAmount', + 'entries', + 'attachments', + ]; }; /** @@ -38,4 +44,13 @@ export class BillPaymentTransformer extends Transformer { protected entries = (billPayment) => { return this.item(billPayment.entries, new BillPaymentEntryTransformer()); }; + + /** + * Retrieves the bill attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (billPayment) => { + return this.item(billPayment.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Purchases/BillPayments/BillPaymentsPages.ts b/packages/server/src/services/Purchases/BillPayments/BillPaymentsPages.ts index 0eb20b856..a23d0c472 100644 --- a/packages/server/src/services/Purchases/BillPayments/BillPaymentsPages.ts +++ b/packages/server/src/services/Purchases/BillPayments/BillPaymentsPages.ts @@ -25,7 +25,8 @@ export default class BillPaymentsPages { const { BillPayment, Bill } = this.tenancy.models(tenantId); const billPayment = await BillPayment.query() .findById(billPaymentId) - .withGraphFetched('entries.bill'); + .withGraphFetched('entries.bill') + .withGraphFetched('attachments'); // Throw not found the bill payment. if (!billPayment) { diff --git a/packages/server/src/services/Purchases/BillPayments/CommandBillPaymentDTOTransformer.ts b/packages/server/src/services/Purchases/BillPayments/CommandBillPaymentDTOTransformer.ts index cefad23ec..5b6f1451d 100644 --- a/packages/server/src/services/Purchases/BillPayments/CommandBillPaymentDTOTransformer.ts +++ b/packages/server/src/services/Purchases/BillPayments/CommandBillPaymentDTOTransformer.ts @@ -1,6 +1,6 @@ import { Inject, Service } from 'typedi'; import * as R from 'ramda'; -import { sumBy } from 'lodash'; +import { omit, sumBy } from 'lodash'; import { IBillPayment, IBillPaymentDTO, IVendor } from '@/interfaces'; import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; import { formatDateFields } from '@/utils'; @@ -24,7 +24,9 @@ export class CommandBillPaymentDTOTransformer { oldBillPayment?: IBillPayment ): Promise { const initialDTO = { - ...formatDateFields(billPaymentDTO, ['paymentDate']), + ...formatDateFields(omit(billPaymentDTO, ['attachments']), [ + 'paymentDate', + ]), amount: sumBy(billPaymentDTO.entries, 'paymentAmount'), currencyCode: vendor.currencyCode, exchangeRate: billPaymentDTO.exchangeRate || 1, diff --git a/packages/server/src/services/Purchases/BillPayments/CreateBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/CreateBillPayment.ts index 81eb3753c..ed16e0715 100644 --- a/packages/server/src/services/Purchases/BillPayments/CreateBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/CreateBillPayment.ts @@ -112,12 +112,12 @@ export class CreateBillPayment { const billPayment = await BillPayment.query(trx).insertGraphAndFetch({ ...billPaymentObj, }); - // Triggers `onBillPaymentCreated` event. await this.eventPublisher.emitAsync(events.billPayment.onCreated, { tenantId, billPayment, billPaymentId: billPayment.id, + billPaymentDTO, trx, } as IBillPaymentEventCreatedPayload); diff --git a/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts index de18853bb..78141b035 100644 --- a/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts @@ -137,6 +137,7 @@ export class EditBillPayment { billPaymentId, billPayment, oldBillPayment, + billPaymentDTO, trx, } as IBillPaymentEventEditedPayload); diff --git a/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts index ccb1cab77..6383c8a4e 100644 --- a/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts @@ -13,7 +13,7 @@ export class GetBillPayment { private transformer: TransformerInjectable; /** - * Retrieve bill payment. + * Retrieves bill payment. * @param {number} tenantId * @param {number} billPyamentId * @return {Promise} @@ -30,6 +30,7 @@ export class GetBillPayment { .withGraphFetched('paymentAccount') .withGraphFetched('transactions') .withGraphFetched('branch') + .withGraphFetched('attachments') .findById(billPyamentId) .throwIfNotFound(); diff --git a/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts b/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts index fed2c249e..50808340d 100644 --- a/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts +++ b/packages/server/src/services/Purchases/Bills/BillDTOTransformer.ts @@ -96,7 +96,7 @@ export class BillDTOTransformer { )(asyncEntries); const initialDTO = { - ...formatDateFields(omit(billDTO, ['open', 'entries']), [ + ...formatDateFields(omit(billDTO, ['open', 'entries', 'attachments']), [ 'billDate', 'dueDate', ]), diff --git a/packages/server/src/services/Purchases/Bills/CreateBill.ts b/packages/server/src/services/Purchases/Bills/CreateBill.ts index 98faea3c5..2bb317ff6 100644 --- a/packages/server/src/services/Purchases/Bills/CreateBill.ts +++ b/packages/server/src/services/Purchases/Bills/CreateBill.ts @@ -110,6 +110,7 @@ export class CreateBill { tenantId, bill, billId: bill.id, + billDTO, trx, } as IBillCreatedPayload); diff --git a/packages/server/src/services/Purchases/Bills/EditBill.ts b/packages/server/src/services/Purchases/Bills/EditBill.ts index 7b10bb7f1..4bb61f4f5 100644 --- a/packages/server/src/services/Purchases/Bills/EditBill.ts +++ b/packages/server/src/services/Purchases/Bills/EditBill.ts @@ -148,6 +148,7 @@ export class EditBill { billId, oldBill, bill, + billDTO, trx, } as IBillEditedPayload); diff --git a/packages/server/src/services/Purchases/Bills/GetBill.ts b/packages/server/src/services/Purchases/Bills/GetBill.ts index 03f6a8361..0efc5dfd1 100644 --- a/packages/server/src/services/Purchases/Bills/GetBill.ts +++ b/packages/server/src/services/Purchases/Bills/GetBill.ts @@ -29,7 +29,8 @@ export class GetBill { .withGraphFetched('vendor') .withGraphFetched('entries.item') .withGraphFetched('branch') - .withGraphFetched('taxes.taxRate'); + .withGraphFetched('taxes.taxRate') + .withGraphFetched('attachments'); // Validates the bill existance. this.validators.validateBillExistance(bill); diff --git a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts index 4cb92adf9..43b8a4b1a 100644 --- a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts +++ b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts @@ -1,5 +1,6 @@ import { IBill } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; +import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; import { ItemEntryTransformer } from '@/services/Sales/Invoices/ItemEntryTransformer'; import { SaleInvoiceTaxEntryTransformer } from '@/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer'; import { formatNumber } from 'utils'; @@ -26,6 +27,7 @@ export class PurchaseInvoiceTransformer extends Transformer { 'totalLocalFormatted', 'taxes', 'entries', + 'attachments', ]; }; @@ -192,4 +194,13 @@ export class PurchaseInvoiceTransformer extends Transformer { currencyCode: bill.currencyCode, }); }; + + /** + * Retrieves the bill attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (bill) => { + return this.item(bill.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Purchases/VendorCredits/BaseVendorCredit.ts b/packages/server/src/services/Purchases/VendorCredits/BaseVendorCredit.ts index 3782562f7..a07fcc79b 100644 --- a/packages/server/src/services/Purchases/VendorCredits/BaseVendorCredit.ts +++ b/packages/server/src/services/Purchases/VendorCredits/BaseVendorCredit.ts @@ -64,7 +64,7 @@ export default class BaseVendorCredit { autoNextNumber; const initialDTO = { - ...omit(vendorCreditDTO, ['open']), + ...omit(vendorCreditDTO, ['open', 'attachments']), amount, currencyCode: vendorCurrencyCode, exchangeRate: vendorCreditDTO.exchangeRate || 1, diff --git a/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts b/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts index a0ff0f421..1ec313852 100644 --- a/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts +++ b/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts @@ -93,6 +93,7 @@ export default class EditVendorCredit extends BaseVendorCredit { oldVendorCredit, vendorCredit, vendorCreditId, + vendorCreditDTO, trx, } as IVendorCreditEditedPayload); diff --git a/packages/server/src/services/Purchases/VendorCredits/GetVendorCredit.ts b/packages/server/src/services/Purchases/VendorCredits/GetVendorCredit.ts index 19a437976..5a1f373f0 100644 --- a/packages/server/src/services/Purchases/VendorCredits/GetVendorCredit.ts +++ b/packages/server/src/services/Purchases/VendorCredits/GetVendorCredit.ts @@ -26,7 +26,8 @@ export default class GetVendorCredit { .findById(vendorCreditId) .withGraphFetched('entries.item') .withGraphFetched('vendor') - .withGraphFetched('branch'); + .withGraphFetched('branch') + .withGraphFetched('attachments'); if (!vendorCredit) { throw new ServiceError(ERRORS.VENDOR_CREDIT_NOT_FOUND); diff --git a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts index 282b6f08e..86518c23e 100644 --- a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts +++ b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts @@ -1,5 +1,6 @@ import { IVendorCredit } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; +import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; import { ItemEntryTransformer } from '@/services/Sales/Invoices/ItemEntryTransformer'; import { formatNumber } from 'utils'; @@ -16,6 +17,7 @@ export class VendorCreditTransformer extends Transformer { 'formattedCreditsRemaining', 'formattedInvoicedAmount', 'entries', + 'attachments', ]; }; @@ -80,4 +82,13 @@ export class VendorCreditTransformer extends Transformer { currencyCode: vendorCredit.currencyCode, }); }; + + /** + * Retrieves the vendor credit attachments. + * @param {IVendorCredit} invoice + * @returns + */ + protected attachments = (vendorCredit) => { + return this.item(vendorCredit.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Sales/Estimates/EditSaleEstimate.ts b/packages/server/src/services/Sales/Estimates/EditSaleEstimate.ts index 5b2323a23..bbc73902c 100644 --- a/packages/server/src/services/Sales/Estimates/EditSaleEstimate.ts +++ b/packages/server/src/services/Sales/Estimates/EditSaleEstimate.ts @@ -114,6 +114,7 @@ export class EditSaleEstimate { estimateId, saleEstimate, oldSaleEstimate, + estimateDTO, trx, } as ISaleEstimateEditedPayload); diff --git a/packages/server/src/services/Sales/Estimates/GetSaleEstimate.ts b/packages/server/src/services/Sales/Estimates/GetSaleEstimate.ts index f4d0d1419..c1a89c20d 100644 --- a/packages/server/src/services/Sales/Estimates/GetSaleEstimate.ts +++ b/packages/server/src/services/Sales/Estimates/GetSaleEstimate.ts @@ -28,7 +28,8 @@ export class GetSaleEstimate { .findById(estimateId) .withGraphFetched('entries.item') .withGraphFetched('customer') - .withGraphFetched('branch'); + .withGraphFetched('branch') + .withGraphFetched('attachments'); // Validates the estimate existance. this.validators.validateEstimateExistance(estimate); diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts b/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts index 967edcb63..df982c816 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimateDTOTransformer.ts @@ -58,10 +58,10 @@ export class SaleEstimateDTOTransformer { const initialDTO = { amount, - ...formatDateFields(omit(estimateDTO, ['delivered', 'entries']), [ - 'estimateDate', - 'expirationDate', - ]), + ...formatDateFields( + omit(estimateDTO, ['delivered', 'entries', 'attachments']), + ['estimateDate', 'expirationDate'] + ), currencyCode: paymentCustomer.currencyCode, exchangeRate: estimateDTO.exchangeRate || 1, ...(estimateNumber ? { estimateNumber } : {}), diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts index 8cd99a9db..9e6cb1be3 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts @@ -2,6 +2,7 @@ import { ISaleEstimate } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { ItemEntryTransformer } from '../Invoices/ItemEntryTransformer'; +import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; export class SaleEstimateTransfromer extends Transformer { /** @@ -18,6 +19,7 @@ export class SaleEstimateTransfromer extends Transformer { 'formattedApprovedAtDate', 'formattedRejectedAtDate', 'entries', + 'attachments', ]; }; @@ -91,9 +93,18 @@ export class SaleEstimateTransfromer extends Transformer { * @param {ISaleEstimate} estimate * @returns {} */ - protected entries = (estimate) => { + protected entries = (estimate: ISaleEstimate) => { return this.item(estimate.entries, new ItemEntryTransformer(), { currencyCode: estimate.currencyCode, }); }; + + /** + * Retrieves the sale estimate attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (estimate: ISaleEstimate) => { + return this.item(estimate.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts index e6f4c054e..4a4f5f6ea 100644 --- a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts @@ -86,7 +86,12 @@ export class CommandSaleInvoiceDTOTransformer { const initialDTO = { ...formatDateFields( - omit(saleInvoiceDTO, ['delivered', 'entries', 'fromEstimateId']), + omit(saleInvoiceDTO, [ + 'delivered', + 'entries', + 'fromEstimateId', + 'attachments', + ]), ['invoiceDate', 'dueDate'] ), // Avoid rewrite the deliver date in edit mode when already published. diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts index b57f86ed9..f788354e0 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts @@ -34,7 +34,8 @@ export class GetSaleInvoice { .withGraphFetched('entries.tax') .withGraphFetched('customer') .withGraphFetched('branch') - .withGraphFetched('taxes.taxRate'); + .withGraphFetched('taxes.taxRate') + .withGraphFetched('attachments'); // Validates the given sale invoice existance. this.validators.validateInvoiceExistance(saleInvoice); diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts index 878160b96..4d3b9d2c2 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts @@ -2,6 +2,7 @@ import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { SaleInvoiceTaxEntryTransformer } from './SaleInvoiceTaxEntryTransformer'; import { ItemEntryTransformer } from './ItemEntryTransformer'; +import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; export class SaleInvoiceTransformer extends Transformer { /** @@ -25,6 +26,7 @@ export class SaleInvoiceTransformer extends Transformer { 'totalLocalFormatted', 'taxes', 'entries', + 'attachments', ]; }; @@ -190,4 +192,13 @@ export class SaleInvoiceTransformer extends Transformer { currencyCode: invoice.currencyCode, }); }; + + /** + * Retrieves the sale invoice attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (invoice) => { + return this.item(invoice.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Sales/PaymentReceives/CreatePaymentReceive.ts b/packages/server/src/services/Sales/PaymentReceives/CreatePaymentReceive.ts index 4e0708cbf..7649177d3 100644 --- a/packages/server/src/services/Sales/PaymentReceives/CreatePaymentReceive.ts +++ b/packages/server/src/services/Sales/PaymentReceives/CreatePaymentReceive.ts @@ -110,6 +110,7 @@ export class CreatePaymentReceive { tenantId, paymentReceive, paymentReceiveId: paymentReceive.id, + paymentReceiveDTO, authorizedUser, trx, } as IPaymentReceiveCreatedPayload); diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveDTOTransformer.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveDTOTransformer.ts index a1eab9814..8d0357e04 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveDTOTransformer.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveDTOTransformer.ts @@ -51,7 +51,7 @@ export class PaymentReceiveDTOTransformer { this.validators.validatePaymentNoRequire(paymentReceiveNo); const initialDTO = { - ...formatDateFields(omit(paymentReceiveDTO, ['entries']), [ + ...formatDateFields(omit(paymentReceiveDTO, ['entries', 'attachments']), [ 'paymentDate', ]), amount: paymentAmount, diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesPages.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesPages.ts index b08f2ac71..72068225c 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesPages.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesPages.ts @@ -66,7 +66,7 @@ export default class PaymentReceivesPages { */ public async getPaymentReceiveEditPage( tenantId: number, - paymentReceiveId: number, + paymentReceiveId: number ): Promise<{ paymentReceive: Omit; entries: IPaymentReceivePageEntry[]; @@ -76,7 +76,8 @@ export default class PaymentReceivesPages { // Retrieve payment receive. const paymentReceive = await PaymentReceive.query() .findById(paymentReceiveId) - .withGraphFetched('entries.invoice'); + .withGraphFetched('entries.invoice') + .withGraphFetched('attachments'); // Throw not found the payment receive. if (!paymentReceive) { diff --git a/packages/server/src/services/Sales/Receipts/CreateSaleReceipt.ts b/packages/server/src/services/Sales/Receipts/CreateSaleReceipt.ts index 65129125f..b66fe7daf 100644 --- a/packages/server/src/services/Sales/Receipts/CreateSaleReceipt.ts +++ b/packages/server/src/services/Sales/Receipts/CreateSaleReceipt.ts @@ -101,6 +101,7 @@ export class CreateSaleReceipt { tenantId, saleReceipt, saleReceiptId: saleReceipt.id, + saleReceiptDTO, trx, } as ISaleReceiptCreatedPayload); diff --git a/packages/server/src/services/Sales/Receipts/EditSaleReceipt.ts b/packages/server/src/services/Sales/Receipts/EditSaleReceipt.ts index fbdb397e4..793494199 100644 --- a/packages/server/src/services/Sales/Receipts/EditSaleReceipt.ts +++ b/packages/server/src/services/Sales/Receipts/EditSaleReceipt.ts @@ -110,6 +110,7 @@ export class EditSaleReceipt { oldSaleReceipt, saleReceipt, saleReceiptId, + saleReceiptDTO, trx, } as ISaleReceiptEditedPayload); diff --git a/packages/server/src/services/Sales/Receipts/GetSaleReceipt.ts b/packages/server/src/services/Sales/Receipts/GetSaleReceipt.ts index b2da2660c..c66a93019 100644 --- a/packages/server/src/services/Sales/Receipts/GetSaleReceipt.ts +++ b/packages/server/src/services/Sales/Receipts/GetSaleReceipt.ts @@ -28,7 +28,8 @@ export class GetSaleReceipt { .withGraphFetched('entries.item') .withGraphFetched('customer') .withGraphFetched('depositAccount') - .withGraphFetched('branch'); + .withGraphFetched('branch') + .withGraphFetched('attachments'); // Valdiates the sale receipt existance. this.validators.validateReceiptExistance(saleReceipt); diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts index 3fd078862..86c391f6e 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptDTOTransformer.ts @@ -68,9 +68,10 @@ export class SaleReceiptDTOTransformer { const initialDTO = { amount, - ...formatDateFields(omit(saleReceiptDTO, ['closed', 'entries']), [ - 'receiptDate', - ]), + ...formatDateFields( + omit(saleReceiptDTO, ['closed', 'entries', 'attachments']), + ['receiptDate'] + ), currencyCode: paymentCustomer.currencyCode, exchangeRate: saleReceiptDTO.exchangeRate || 1, receiptNumber, diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts index 9e5d3a127..f031e567a 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts @@ -3,6 +3,7 @@ import { ISaleReceipt } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { ItemEntryTransformer } from '../Invoices/ItemEntryTransformer'; +import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; @Service() export class SaleReceiptTransformer extends Transformer { @@ -17,6 +18,7 @@ export class SaleReceiptTransformer extends Transformer { 'formattedReceiptDate', 'formattedClosedAtDate', 'entries', + 'attachments', ]; }; @@ -68,4 +70,13 @@ export class SaleReceiptTransformer extends Transformer { currencyCode: receipt.currencyCode, }); }; + + /** + * Retrieves the sale receipt attachments. + * @param {ISaleReceipt} invoice + * @returns + */ + protected attachments = (receipt) => { + return this.item(receipt.attachments, new AttachmentTransformer()); + }; } diff --git a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts index adec8b028..6a7c3966d 100644 --- a/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts +++ b/packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts @@ -10,6 +10,7 @@ import { } from './utils'; import { Plan } from '@/system/models'; import { Subscription } from './Subscription'; +import { isEmpty } from 'lodash'; @Service() export class LemonSqueezyWebhooks { @@ -32,6 +33,9 @@ export class LemonSqueezyWebhooks { if (!config.lemonSqueezy.webhookSecret) { throw new Error('Lemon Squeezy Webhook Secret not set in .env'); } + if (!signature) { + throw new Error('Request signature is required.'); + } const secret = config.lemonSqueezy.webhookSecret; const hmacSignature = createHmacSignature(secret, rawData); @@ -49,7 +53,7 @@ export class LemonSqueezyWebhooks { /** * This action will process a webhook event in the database. - * @param {unknown} eventBody - + * @param {unknown} eventBody - * @returns {Promise} */ private async processWebhookEvent(eventBody): Promise { @@ -96,7 +100,7 @@ export class LemonSqueezyWebhooks { if (webhookEvent === 'subscription_created') { await this.subscriptionService.newSubscribtion( tenantId, - 'early-adaptor', + 'early-adaptor' ); } } diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesForm.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesForm.tsx index 205d4c0b4..a47b3b6aa 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesForm.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalEntriesForm.tsx @@ -32,6 +32,7 @@ import { defaultManualJournal, } from './utils'; import { JournalSyncIncrementSettingsToForm } from './components'; +import { transformAttachmentsToRequest } from '@/containers/Attachments/utils'; /** * Journal entries form. @@ -61,7 +62,6 @@ function MakeJournalEntriesForm({ journalNumberPrefix, journalNextNumber, ); - // Form initial values. const initialValues = useMemo( () => ({ @@ -112,6 +112,7 @@ function MakeJournalEntriesForm({ setSubmitting(false); return; } + const attachments = transformAttachmentsToRequest(values); const form = { ...omit(values, ['journal_number_manually']), ...(values.journal_number_manually && { @@ -119,6 +120,7 @@ function MakeJournalEntriesForm({ }), entries: R.compose(orderingLinesIndexes)(entries), publish: submitPayload.publish, + attachments, }; // Handle the request error. const handleError = ({ diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooter.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooter.tsx index 0fcf729a8..1266dee98 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooter.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/MakeJournalFormFooter.tsx @@ -7,6 +7,7 @@ import { CLASSES } from '@/constants/classes'; import { Row, Col, Paper } from '@/components'; import { MakeJournalFormFooterLeft } from './MakeJournalFormFooterLeft'; import { MakeJournalFormFooterRight } from './MakeJournalFormFooterRight'; +import { UploadAttachmentButton } from '@/containers/Attachments/UploadAttachmentButton'; export default function MakeJournalFormFooter() { return ( @@ -15,6 +16,7 @@ export default function MakeJournalFormFooter() { + diff --git a/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx b/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx index 5259a554e..ab7ff4c49 100644 --- a/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx +++ b/packages/webapp/src/containers/Accounting/MakeJournal/utils.tsx @@ -18,6 +18,7 @@ import { AppToaster } from '@/components'; import { useFormikContext } from 'formik'; import { useMakeJournalFormContext } from './MakeJournalProvider'; import { useCurrentOrganization } from '@/hooks/state'; +import { transformAttachmentsToForm } from '@/containers/Attachments/utils'; const ERROR = { JOURNAL_NUMBER_ALREADY_EXISTS: 'JOURNAL.NUMBER.ALREADY.EXISTS', @@ -57,6 +58,7 @@ export const defaultManualJournal = { branch_id: '', exchange_rate: 1, entries: [...repeatValue(defaultEntry, DEFAULT_LINES_NUMBER)], + attachments: [], }; // Transform to edit form. @@ -76,9 +78,12 @@ export function transformToEditForm(manualJournal) { ensureEntriesHasEmptyLine(MIN_LINES_NUMBER, defaultEntry), )(initialEntries); + const attachments = transformAttachmentsToForm(manualJournal); + return { ...transformToForm(manualJournal, defaultManualJournal), entries, + attachments, }; } diff --git a/packages/webapp/src/containers/Attachments/UploadAttachmentButton.module.scss b/packages/webapp/src/containers/Attachments/UploadAttachmentButton.module.scss new file mode 100644 index 000000000..1fc37bfc4 --- /dev/null +++ b/packages/webapp/src/containers/Attachments/UploadAttachmentButton.module.scss @@ -0,0 +1,17 @@ + +.popover :global .bp4-popover-content{ + min-width: 600px; +} + +.attachmentButton:not([class*=bp4-intent-]) { + &, + &:hover{ + background-color: #fff; + } + border: 1px solid rgb(206, 212, 218); +} + +.attachmentField :global .bp4-label{ + font-weight: 500; + font-size: 12px; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Attachments/UploadAttachmentButton.tsx b/packages/webapp/src/containers/Attachments/UploadAttachmentButton.tsx new file mode 100644 index 000000000..667ab8986 --- /dev/null +++ b/packages/webapp/src/containers/Attachments/UploadAttachmentButton.tsx @@ -0,0 +1,57 @@ +// @ts-nocheck +import clsx from 'classnames'; +import { Field, useFormikContext } from 'formik'; +import { + Button, + Classes, + Popover, + PopoverInteractionKind, +} from '@blueprintjs/core'; +import { FFormGroup } from '@/components'; +import { UploadAttachmentsPopoverContent } from './UploadAttachmentsPopoverContent'; +import { transformToCamelCase, transfromToSnakeCase } from '@/utils'; +import styles from './UploadAttachmentButton.module.scss'; + +function UploadAttachmentButtonButtonContentField() { + return ( + + {({ form: { setFieldValue }, field: { value } }) => ( + { + setFieldValue('attachments', transfromToSnakeCase(changedValue)); + }} + /> + )} + + ); +} + +export function UploadAttachmentButton() { + const { values } = useFormikContext(); + const uploadedFiles = values?.attachments?.length || 0; + + return ( + + } + > + + + + ); +} diff --git a/packages/webapp/src/containers/Attachments/UploadAttachmentPopoverContent.module.scss b/packages/webapp/src/containers/Attachments/UploadAttachmentPopoverContent.module.scss new file mode 100644 index 000000000..791ad18ca --- /dev/null +++ b/packages/webapp/src/containers/Attachments/UploadAttachmentPopoverContent.module.scss @@ -0,0 +1,63 @@ + +.content { +} + +.hintText{ + display: flex; + font-size: 11px; + margin-top: 6px; + color: #738091; + justify-content: space-between; +} + +.attachments{ + margin-top: 12px; +} + +.attachmentItem{ + border-top: 1px solid #D3D8DE; + border-left: 1px solid #D3D8DE; + border-right: 1px solid #D3D8DE; + padding: 10px 14px; + justify-content: space-between; + + &:first-child { + border-radius: 3px 3px 0 0; + } + &:last-child{ + border-radius: 0 0 3px 3px; + border-bottom: 1px solid #D3D8DE; + } +} + +.attachmentFilenameText{ + +} + +.attachmentSizeText, +.attachmentLoadingText{ + font-size: 13px; + color: #738091; +} + +.attachmentContent{ +} + +.attachmentIcon{ + color: #626b7c; +} + +.label{ + font-size: 12px; + margin-bottom: 4px; +} + +.dropzoneRoot{ + min-height: 140px; + padding: 10px; +} + +.attachmentIconWrap{ + width: 20PX; + text-align: right; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Attachments/UploadAttachmentsPopoverContent.tsx b/packages/webapp/src/containers/Attachments/UploadAttachmentsPopoverContent.tsx new file mode 100644 index 000000000..d7a5b8c1e --- /dev/null +++ b/packages/webapp/src/containers/Attachments/UploadAttachmentsPopoverContent.tsx @@ -0,0 +1,224 @@ +// @ts-nocheck +import { useState } from 'react'; +import { isEmpty } from 'lodash'; +import { Button, Intent, Text, Spinner } from '@blueprintjs/core'; +import { Box, Group, Icon, Stack } from '@/components'; +import { + ImportDropzoneField, + ImportDropzoneFieldProps, +} from '@/containers/Import/ImportDropzoneFile'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; +import { + useGetPresignedUrlAttachment, + useUploadAttachments, +} from '@/hooks/query/attachments'; +import styles from './UploadAttachmentPopoverContent.module.scss'; +import { MIME_TYPES } from '@/components/Dropzone/mine-types'; +import { formatBytes } from '@/utils/format-bytes'; + +interface AttachmentFileCommon { + originName: string; + key: string; + size: number; + mimeType: string; +} +interface AttachmentFileLoaded extends AttachmentFileCommon {} +interface AttachmentFileLoading extends AttachmentFileCommon { + loading: boolean; +} +type AttachmentFile = AttachmentFileLoaded | AttachmentFileLoading; + +interface UploadAttachmentsPopoverContentProps { + initialValue?: AttachmentFile[]; + value?: AttachmentFile[]; + onChange?: (value: AttachmentFile[]) => void; + onUploadedChange?: (value: AttachmentFile[]) => void; + dropzoneFieldProps?: ImportDropzoneFieldProps; +} + +/** + * Uploads and list the attachments with ability to delete particular attachment. + * @param {UploadAttachmentsPopoverContentProps} + */ +export function UploadAttachmentsPopoverContent({ + initialValue, + value, + onChange, + onUploadedChange, + dropzoneFieldProps, +}: UploadAttachmentsPopoverContentProps) { + // Controlled/uncontrolled value state. + const [localFiles, handleFilesChange] = useUncontrolled({ + finalValue: [], + initialValue, + value, + onChange: onChange, + }); + // Stops loading of the given attachment key and updates it to new key, + // that came from the server-side after uploading is done. + const stopLoadingAttachment = ( + localFiles: AttachmentFile[], + internalKey: string, + newKey: string, + ) => { + return localFiles.map((localFile) => { + if (localFile.key === internalKey) { + return { + ...localFile, + key: newKey, + loading: false, + }; + } + return localFile; + }); + }; + // Uploads the attachments. + const { mutateAsync: uploadAttachments } = useUploadAttachments({ + onSuccess: (data) => { + const newLocalFiles = stopLoadingAttachment( + localFiles, + data.config.data.get('internalKey'), + data.data.data.key, + ); + handleFilesChange(newLocalFiles); + onUploadedChange && onUploadedChange(newLocalFiles); + }, + }); + // Deletes the attachment of the given file key. + const handleClick = (key: string) => () => { + const updatedFiles = localFiles.filter((file, i) => file.key !== key); + handleFilesChange(updatedFiles); + onUploadedChange && onUploadedChange(updatedFiles); + }; + + // Handle change dropzone. + const handleChangeDropzone = (file: File) => { + const formData = new FormData(); + const key = Date.now().toString(); + + formData.append('file', file); + formData.append('internalKey', key); + + handleFilesChange([ + { + originName: file.name, + size: file.size, + key, + loading: true, + }, + ...localFiles, + ]); + uploadAttachments(formData); + }; + + return ( +
+
+ Attach documents + + + + Maximum: 25MB + + + + {!isEmpty(localFiles) && ( + + {localFiles.map((localFile: AttachmentFile, index: number) => ( + + +
+ {localFile.loading ? ( + + ) : ( + + )} +
+ + + {localFile.originName} + + {localFile.loading ? ( + + Loading... + + ) : ( + + {formatBytes(localFile.size)} + + )} + +
+ + {!localFile.loading && ( + + + + + )} +
+ ))} +
+ )} +
+
+ ); +} + +const ViewButton = ({ fileKey }: { fileKey: string }) => { + const [isLoading, setLoading] = useState(false); + const { mutateAsync: getAttachmentPresignedUrl } = + useGetPresignedUrlAttachment(); + + const handleViewBtnClick = (key: string) => () => { + setLoading(true); + + getAttachmentPresignedUrl(key).then((data) => { + window.open(data.presigned_url); + setLoading(false); + }); + }; + + return ( + + ); +}; diff --git a/packages/webapp/src/containers/Attachments/utils.ts b/packages/webapp/src/containers/Attachments/utils.ts new file mode 100644 index 000000000..168aabc77 --- /dev/null +++ b/packages/webapp/src/containers/Attachments/utils.ts @@ -0,0 +1,19 @@ +// @ts-nocheck +import { transformToForm } from '@/utils'; + +const attachmentReqSchema = { + key: '', + size: '', + origin_name: '', + mime_type: '', +}; + +export const transformAttachmentsToForm = (values) => { + return values.attachments?.map((attachment) => + transformToForm(attachment, attachmentReqSchema), + ); +}; + +export const transformAttachmentsToRequest = (values) => { + return values.attachments?.map((attachment) => ({ key: attachment.key })); +}; diff --git a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooter.tsx b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooter.tsx index e3f0d2027..4178c27ca 100644 --- a/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooter.tsx +++ b/packages/webapp/src/containers/Expenses/ExpenseForm/ExpenseFormFooter.tsx @@ -7,6 +7,7 @@ import { CLASSES } from '@/constants/classes'; import { Row, Col, Paper } from '@/components'; import { ExpenseFormFooterLeft } from './ExpenseFormFooterLeft'; import { ExpenseFormFooterRight } from './ExpenseFormFooterRight'; +import { UploadAttachmentButton } from '@/containers/Attachments/UploadAttachmentButton'; export default function ExpenseFormFooter() { return ( @@ -15,6 +16,7 @@ export default function ExpenseFormFooter() { + diff --git a/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx b/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx index 73c28bdad..e411d810a 100644 --- a/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx +++ b/packages/webapp/src/containers/Expenses/ExpenseForm/utils.tsx @@ -18,6 +18,10 @@ import { formattedAmount, } from '@/utils'; import { useCurrentOrganization } from '@/hooks/state'; +import { + transformAttachmentsToForm, + transformAttachmentsToRequest, +} from '@/containers/Attachments/utils'; const ERROR = { EXPENSE_ALREADY_PUBLISHED: 'EXPENSE.ALREADY.PUBLISHED', @@ -46,6 +50,7 @@ export const defaultExpense = { branch_id: '', exchange_rate: 1, categories: [...repeatValue(defaultExpenseEntry, MIN_LINES_NUMBER)], + attachments: [], }; /** @@ -93,9 +98,12 @@ export const transformToEditForm = ( ensureEntriesHasEmptyLine(MIN_LINES_NUMBER, expenseEntry), )(initialEntries); + const attachments = transformAttachmentsToForm(expense); + return { ...transformToForm(expense, defaultExpense), categories, + attachments, }; }; @@ -133,10 +141,12 @@ export const filterNonZeroEntries = (categories) => { */ export const transformFormValuesToRequest = (values) => { const categories = filterNonZeroEntries(values.categories); + const attachments = transformAttachmentsToRequest(values); return { ...values, categories: R.compose(orderingLinesIndexes)(categories), + attachments, }; }; diff --git a/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx b/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx index 2cec9242f..b2705d84c 100644 --- a/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx +++ b/packages/webapp/src/containers/Import/ImportDropzoneFile.tsx @@ -1,17 +1,22 @@ // @ts-nocheck import { useRef } from 'react'; import { Button, Intent } from '@blueprintjs/core'; +import clsx from 'classnames'; import { Box, Icon, Stack } from '@/components'; import { Dropzone, DropzoneProps } from '@/components/Dropzone'; import { MIME_TYPES } from '@/components/Dropzone/mine-types'; -import styles from './ImportDropzone.module.css'; import { useUncontrolled } from '@/hooks/useUncontrolled'; +import styles from './ImportDropzone.module.css'; -interface ImportDropzoneFieldProps { +export interface ImportDropzoneFieldProps { initialValue?: File; value?: File; onChange?: (file: File) => void; dropzoneProps?: DropzoneProps; + uploadIcon?: JSX.Element; + title?: string; + subtitle?: string; + classNames?: Record; } export function ImportDropzoneField({ @@ -19,6 +24,10 @@ export function ImportDropzoneField({ value, onChange, dropzoneProps, + uploadIcon = , + title = 'Drag images here or click to select files', + subtitle = 'Drag and Drop file here or Choose file', + classNames, }: ImportDropzoneFieldProps) { const [localValue, handleChange] = useUncontrolled({ value, @@ -38,15 +47,18 @@ export function ImportDropzoneField({ onReject={(files) => console.log('rejected files', files)} maxSize={5 * 1024 ** 2} accept={[MIME_TYPES.csv, MIME_TYPES.xls, MIME_TYPES.xlsx]} - classNames={{ content: styles.dropzoneContent }} + classNames={{ root: classNames?.root, content: styles.dropzoneContent }} activateOnClick={false} openRef={openRef} {...dropzoneProps} > - - - - + + {uploadIcon && {uploadIcon}} + {localValue ? (

{localValue.name}

@@ -56,15 +68,10 @@ export function ImportDropzoneField({
) : ( -

- Drag images here or click to select files -

- - Drag and Drop file here or Choose file - + {title &&

{title}

} + {subtitle && {subtitle}}
)} -