diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index e0f557664..5d51cc68a 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -7,6 +7,7 @@ import ExpensesService from "services/Expenses/ExpensesService"; import { IExpenseDTO } from 'interfaces'; import { ServiceError } from "exceptions"; import DynamicListingService from 'services/DynamicListing/DynamicListService'; +import { takeWhile } from "lodash"; @Service() export default class ExpensesController extends BaseController { @@ -73,10 +74,19 @@ export default class ExpensesController extends BaseController { '/', [ ...this.expensesListSchema, ], + this.validationResult, asyncMiddleware(this.getExpensesList.bind(this)), this.dynamicListService.handlerErrorsToResponse, this.catchServiceErrors, ); + router.get( + '/:id', [ + this.expenseParamSchema, + ], + this.validationResult, + asyncMiddleware(this.getExpense.bind(this)), + this.catchServiceErrors, + ); return router; } @@ -280,6 +290,24 @@ export default class ExpensesController extends BaseController { } } + /** + * Retrieve expense details. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async getExpense(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: expenseId } = req.params; + + try { + const expense = await this.expensesService.getExpense(tenantId, expenseId); + return res.status(200).send({ expense }); + } catch (error) { + next(error); + } + } + /** * Transform service errors to api response errors. * @param {Response} res diff --git a/server/src/api/controllers/Media.js b/server/src/api/controllers/Media.js deleted file mode 100644 index 94631e804..000000000 --- a/server/src/api/controllers/Media.js +++ /dev/null @@ -1,163 +0,0 @@ - -import express from 'express'; -import { - param, - query, - validationResult, -} from 'express-validator'; -import Container from 'typedi'; -import fs from 'fs'; -import { difference } from 'lodash'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; - -const fsPromises = fs.promises; - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.post('/upload', - this.upload.validation, - asyncMiddleware(this.upload.handler)); - - router.delete('/', - this.delete.validation, - asyncMiddleware(this.delete.handler)); - - router.get('/', - this.get.validation, - asyncMiddleware(this.get.handler)); - - return router; - }, - - /** - * Retrieve all or the given attachment ids. - */ - get: { - validation: [ - query('ids'), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const { Media } = req.models; - const media = await Media.query().onBuild((builder) => { - - if (req.query.ids) { - const ids = Array.isArray(req.query.ids) ? req.query.ids : [req.query.ids]; - builder.whereIn('id', ids); - } - }); - - return res.status(200).send({ media }); - }, - }, - - /** - * Uploads the given attachment file. - */ - upload: { - validation: [ - // check('attachment').exists(), - ], - async handler(req, res) { - const Logger = Container.get('logger'); - - if (!req.files.attachment) { - return res.status(400).send({ - errors: [{ type: 'ATTACHMENT.NOT.FOUND', code: 200 }], - }); - } - const publicPath = 'storage/app/public/'; - const attachmentsMimes = ['image/png', 'image/jpeg']; - const { attachment } = req.files; - const { Media } = req.models; - - const errorReasons = []; - - // Validate the attachment. - if (attachment && attachmentsMimes.indexOf(attachment.mimetype) === -1) { - errorReasons.push({ type: 'ATTACHMENT.MINETYPE.NOT.SUPPORTED', code: 160 }); - } - // Catch all error reasons to response 400. - if (errorReasons.length > 0) { - return res.status(400).send({ errors: errorReasons }); - } - - try { - await attachment.mv(`${publicPath}${req.organizationId}/${attachment.md5}.png`); - Logger.info('[attachment] uploaded successfully'); - } catch (error) { - Logger.info('[attachment] uploading failed.', { error }); - } - - const media = await Media.query().insert({ - attachment_file: `${attachment.md5}.png`, - }); - return res.status(200).send({ media }); - }, - }, - - /** - * Deletes the given attachment ids from file system and database. - */ - delete: { - validation: [ - query('ids').exists().isArray(), - query('ids.*').exists().isNumeric().toInt(), - ], - async handler(req, res) { - const Logger = Container.get('logger'); - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const { Media, MediaLink } = req.models; - const ids = Array.isArray(req.query.ids) ? req.query.ids : [req.query.ids]; - const media = await Media.query().whereIn('id', ids); - const mediaIds = media.map((m) => m.id); - const notFoundMedia = difference(ids, mediaIds); - - if (notFoundMedia.length) { - return res.status(400).send({ - errors: [{ type: 'MEDIA.IDS.NOT.FOUND', code: 200, ids: notFoundMedia }], - }); - } - const publicPath = 'storage/app/public/'; - const tenantPath = `${publicPath}${req.organizationId}`; - const unlinkOpers = []; - - media.forEach((mediaModel) => { - const oper = fsPromises.unlink(`${tenantPath}/${mediaModel.attachmentFile}`); - unlinkOpers.push(oper); - }); - await Promise.all(unlinkOpers).then((resolved) => { - resolved.forEach(() => { - Logger.info('[attachment] file has been deleted.'); - }); - }) - .catch((errors) => { - errors.forEach((error) => { - Logger.info('[attachment] Delete item attachment file delete failed.', { error }); - }) - }); - - await MediaLink.query().whereIn('media_id', mediaIds).delete(); - await Media.query().whereIn('id', mediaIds).delete(); - - return res.status(200).send(); - }, - }, -}; diff --git a/server/src/api/controllers/Media.ts b/server/src/api/controllers/Media.ts new file mode 100644 index 000000000..469d6564b --- /dev/null +++ b/server/src/api/controllers/Media.ts @@ -0,0 +1,212 @@ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + param, + query, + check, +} from 'express-validator'; +import { camelCase, upperFirst } from 'lodash'; +import { Inject, Service } from 'typedi'; +import { IMediaLinkDTO } from 'interfaces'; +import fs from 'fs'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; +import BaseController from './BaseController'; +import MediaService from 'services/Media/MediaService'; +import { ServiceError } from 'exceptions'; + +const fsPromises = fs.promises; + +@Service() +export default class MediaController extends BaseController { + @Inject() + mediaService: MediaService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post('/upload', [ + ...this.uploadValidationSchema, + ], + this.validationResult, + asyncMiddleware(this.uploadMedia.bind(this)), + this.handlerServiceErrors, + ); + router.post('/:id/link', [ + ...this.mediaIdParamSchema, + ...this.linkValidationSchema, + ], + this.validationResult, + asyncMiddleware(this.linkMedia.bind(this)), + this.handlerServiceErrors, + ); + router.delete('/', [ + ...this.deleteValidationSchema, + ], + this.validationResult, + asyncMiddleware(this.deleteMedia.bind(this)), + this.handlerServiceErrors, + ); + router.get('/:id', [ + ...this.mediaIdParamSchema, + ], + this.validationResult, + asyncMiddleware(this.getMedia.bind(this)), + this.handlerServiceErrors, + ); + return router; + } + + get uploadValidationSchema() { + return [ + // check('attachment'), + check('model_name').optional().trim().escape(), + check('model_id').optional().isNumeric().toInt(), + ]; + } + + get linkValidationSchema() { + return [ + check('model_name').exists().trim().escape(), + check('model_id').exists().isNumeric().toInt(), + ] + } + + get deleteValidationSchema() { + return [ + query('ids').exists().isArray(), + query('ids.*').exists().isNumeric().toInt(), + ]; + } + + get mediaIdParamSchema() { + return [ + param('id').exists().isNumeric().toInt(), + ]; + } + + /** + * Retrieve all or the given attachment ids. + * @param {Request} req - + * @param {Response} req - + * @param {NextFunction} req - + */ + async getMedia(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: mediaId } = req.params; + + try { + const media = await this.mediaService.getMedia(tenantId, mediaId); + return res.status(200).send({ media }); + } catch (error) { + next(error); + } + } + + /** + * Uploads media. + * @param {Request} req - + * @param {Response} req - + * @param {NextFunction} req - + */ + async uploadMedia(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { attachment } = req.files + + const linkMediaDTO: IMediaLinkDTO = this.matchedBodyData(req); + const modelName = linkMediaDTO.modelName + ? upperFirst(camelCase(linkMediaDTO.modelName)) : ''; + + try { + const media = await this.mediaService.upload(tenantId, attachment, modelName, linkMediaDTO.modelId); + return res.status(200).send({ media_id: media.id }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given attachment ids from file system and database. + * @param {Request} req - + * @param {Response} req - + * @param {NextFunction} req - + */ + async deleteMedia(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { ids: mediaIds } = req.query; + + try { + await this.mediaService.deleteMedia(tenantId, mediaIds); + return res.status(200).send({ + media_ids: mediaIds + }); + } catch (error) { + next(error); + } + } + + /** + * Links the given media to the specific resource model. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async linkMedia(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: mediaId } = req.params; + const linkMediaDTO: IMediaLinkDTO = this.matchedBodyData(req); + const modelName = upperFirst(camelCase(linkMediaDTO.modelName)); + + try { + await this.mediaService.linkMedia(tenantId, mediaId, linkMediaDTO.modelId, modelName); + return res.status(200).send({ media_id: mediaId }); + } catch (error) { + next(error); + } + } + + /** + * Handler service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + handlerServiceErrors(error, req: Request, res: Response, next: NextFunction) { + if (error instanceof ServiceError) { + if (error.errorType === 'MINETYPE_NOT_SUPPORTED') { + return res.boom.badRequest(null, { + errors: [{ type: 'MINETYPE_NOT_SUPPORTED', code: 100, }] + }); + } + if (error.errorType === 'MEDIA_NOT_FOUND') { + return res.boom.badRequest(null, { + errors: [{ type: 'MEDIA_NOT_FOUND', code: 200 }] + }); + } + if (error.errorType === 'MODEL_NAME_HAS_NO_MEDIA') { + return res.boom.badRequest(null, { + errors: [{ type: 'MODEL_NAME_HAS_NO_MEDIA', code: 300 }] + }); + } + if (error.errorType === 'MODEL_ID_NOT_FOUND') { + return res.boom.badRequest(null, { + errors: [{ type: 'MODEL_ID_NOT_FOUND', code: 400 }] + }); + } + if (error.errorType === 'MEDIA_IDS_NOT_FOUND') { + return res.boom.badRequest(null, { + errors: [{ type: 'MEDIA_IDS_NOT_FOUND', code: 500 }], + }); + } + if (error.errorType === 'MEDIA_LINK_EXISTS') { + return res.boom.badRequest(null, { + errors: [{ type: 'MEDIA_LINK_EXISTS', code: 600 }], + }); + } + } + next(error); + } +}; diff --git a/server/src/api/index.ts b/server/src/api/index.ts index 0cf504623..12dc5138b 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -98,7 +98,7 @@ export default () => { // dashboard.use('/purchases', Container.get(Purchases).router()); dashboard.use('/resources', Container.get(Resources).router()); dashboard.use('/exchange_rates', Container.get(ExchangeRates).router()); - dashboard.use('/media', Media.router()); + dashboard.use('/media', Container.get(Media).router()); app.use('/', dashboard); diff --git a/server/src/interfaces/Expenses.ts b/server/src/interfaces/Expenses.ts index 04e998ea5..4a3263f0f 100644 --- a/server/src/interfaces/Expenses.ts +++ b/server/src/interfaces/Expenses.ts @@ -66,4 +66,5 @@ export interface IExpensesService { publishBulkExpenses(tenantId: number, expensesIds: number[], authorizedUser: ISystemUser): Promise; getExpensesList(tenantId: number, expensesFilter: IExpensesFilter): Promise<{ expenses: IExpense[], pagination: IPaginationMeta, filterMeta: IFilterMeta }>; + getExpense(tenantId: number, expenseId: number): Promise; } \ No newline at end of file diff --git a/server/src/interfaces/Media.ts b/server/src/interfaces/Media.ts new file mode 100644 index 000000000..6cd338583 --- /dev/null +++ b/server/src/interfaces/Media.ts @@ -0,0 +1,25 @@ + + +export interface IMedia { + id?: number, + attachmentFile: string, + createdAt?: Date, +}; + +export interface IMediaLink { + mediaId: number, + modelName: string, + modelId: number, +}; + +export interface IMediaLinkDTO { + modelName: string, + modelId: number, +}; + +export interface IMediaService { + linkMedia(tenantId: number, mediaId: number, modelId?: number, modelName?: string): Promise; + getMedia(tenantId: number, mediaId: number): Promise; + deleteMedia(tenantId: number, mediaId: number | number[]): Promise; + upload(tenantId: number, attachment: any, modelName?: string, modelId?: number): Promise; +} \ No newline at end of file diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 0dd3c1c20..61d02b690 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -24,4 +24,5 @@ export * from './Tenancy'; export * from './View'; export * from './ManualJournal'; export * from './Currency'; -export * from './ExchangeRate'; \ No newline at end of file +export * from './ExchangeRate'; +export * from './Media'; \ No newline at end of file diff --git a/server/src/models/Expense.js b/server/src/models/Expense.js index 5ad8baa24..d49577982 100644 --- a/server/src/models/Expense.js +++ b/server/src/models/Expense.js @@ -1,6 +1,7 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; import { viewRolesBuilder } from 'lib/ViewRolesBuilder'; +import Media from './Media'; export default class Expense extends TenantModel { /** @@ -24,6 +25,11 @@ export default class Expense extends TenantModel { return ['createdAt', 'updatedAt']; } + + static get media () { + return true; + } + /** * Model modifiers. */ @@ -55,7 +61,6 @@ export default class Expense extends TenantModel { query.where('payment_account_id', accountId); } }, - viewRolesBuilder(query, conditionals, expression) { viewRolesBuilder(conditionals, expression)(query); }, @@ -68,6 +73,7 @@ export default class Expense extends TenantModel { static get relationMappings() { const Account = require('models/Account'); const ExpenseCategory = require('models/ExpenseCategory'); + const Media = require('models/Media'); return { paymentAccount: { @@ -86,6 +92,18 @@ export default class Expense extends TenantModel { to: 'expense_transaction_categories.expenseId', }, }, + media: { + relation: Model.ManyToManyRelation, + modelClass: Media.default, + join: { + from: 'expenses_transactions.id', + through: { + from: 'media_links.model_id', + to: 'media_links.media_id', + }, + to: 'media.id', + }, + }, }; } diff --git a/server/src/models/Media.js b/server/src/models/Media.js index acf421451..aab3aa227 100644 --- a/server/src/models/Media.js +++ b/server/src/models/Media.js @@ -1,3 +1,4 @@ +import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; export default class Media extends TenantModel { @@ -7,4 +8,29 @@ export default class Media extends TenantModel { static get tableName() { return 'media'; } + + /** + * Model timestamps. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const MediaLink = require('models/MediaLink'); + + return { + links: { + relation: Model.HasManyRelation, + modelClass: MediaLink.default, + join: { + from: 'media.id', + to: 'media_links.media_id', + }, + }, + }; + } } diff --git a/server/src/models/ResourcableModel.js b/server/src/models/ResourcableModel.js new file mode 100644 index 000000000..289c2dfa3 --- /dev/null +++ b/server/src/models/ResourcableModel.js @@ -0,0 +1,8 @@ + + +export default class ResourceableModel { + + static get resourceable() { + return true; + } +} \ No newline at end of file diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts index f4ef12ab5..cc9b083a2 100644 --- a/server/src/services/Expenses/ExpensesService.ts +++ b/server/src/services/Expenses/ExpensesService.ts @@ -48,7 +48,7 @@ export default class ExpensesService implements IExpensesService { this.logger.info('[expenses] trying to get the given payment account.', { tenantId, paymentAccountId }); const { accountRepository } = this.tenancy.repositories(tenantId); - const paymentAccount = await accountRepository.getById(paymentAccountId) + const paymentAccount = await accountRepository.findById(paymentAccountId) if (!paymentAccount) { this.logger.info('[expenses] the given payment account not found.', { tenantId, paymentAccountId }); @@ -460,4 +460,24 @@ export default class ExpensesService implements IExpensesService { dynamicFilter.getResponseMeta(), }; } + + /** + * Retrieve expense details. + * @param {number} tenantId + * @param {number} expenseId + * @return {Promise} + */ + public async getExpense(tenantId: number, expenseId: number): Promise { + const { Expense } = this.tenancy.models(tenantId); + + const expense = await Expense.query().findById(expenseId) + .withGraphFetched('paymentAccount') + .withGraphFetched('media') + .withGraphFetched('categories'); + + if (!expense) { + throw new ServiceError(ERRORS.EXPENSE_NOT_FOUND); + } + return expense; + } } \ No newline at end of file diff --git a/server/src/services/ItemCategories/ItemCategoriesService.ts b/server/src/services/ItemCategories/ItemCategoriesService.ts index 9be35319b..a8d95e618 100644 --- a/server/src/services/ItemCategories/ItemCategoriesService.ts +++ b/server/src/services/ItemCategories/ItemCategoriesService.ts @@ -108,7 +108,7 @@ export default class ItemCategoriesService implements IItemCategoriesService { this.logger.info('[items] validate sell account existance.', { tenantId, sellAccountId }); const incomeType = await accountTypeRepository.getByKey('income'); - const foundAccount = await accountRepository.getById(sellAccountId); + const foundAccount = await accountRepository.findById(sellAccountId); if (!foundAccount) { this.logger.info('[items] sell account not found.', { tenantId, sellAccountId }); @@ -130,7 +130,7 @@ export default class ItemCategoriesService implements IItemCategoriesService { this.logger.info('[items] validate cost account existance.', { tenantId, costAccountId }); const COGSType = await accountTypeRepository.getByKey('cost_of_goods_sold'); - const foundAccount = await accountRepository.getById(costAccountId) + const foundAccount = await accountRepository.findById(costAccountId) if (!foundAccount) { this.logger.info('[items] cost account not found.', { tenantId, costAccountId }); @@ -152,7 +152,7 @@ export default class ItemCategoriesService implements IItemCategoriesService { this.logger.info('[items] validate inventory account existance.', { tenantId, inventoryAccountId }); const otherAsset = await accountTypeRepository.getByKey('other_asset'); - const foundAccount = await accountRepository.getById(inventoryAccountId); + const foundAccount = await accountRepository.findById(inventoryAccountId); if (!foundAccount) { this.logger.info('[items] inventory account not found.', { tenantId, inventoryAccountId }); diff --git a/server/src/services/Items/ItemsService.ts b/server/src/services/Items/ItemsService.ts index 4b195aeb2..3597219c4 100644 --- a/server/src/services/Items/ItemsService.ts +++ b/server/src/services/Items/ItemsService.ts @@ -85,7 +85,7 @@ export default class ItemsService implements IItemsService { this.logger.info('[items] validate cost account existance.', { tenantId, costAccountId }); const COGSType = await accountTypeRepository.getByKey('cost_of_goods_sold'); - const foundAccount = await accountRepository.getById(costAccountId) + const foundAccount = await accountRepository.findById(costAccountId) if (!foundAccount) { this.logger.info('[items] cost account not found.', { tenantId, costAccountId }); @@ -106,7 +106,7 @@ export default class ItemsService implements IItemsService { this.logger.info('[items] validate sell account existance.', { tenantId, sellAccountId }); const incomeType = await accountTypeRepository.getByKey('income'); - const foundAccount = await accountRepository.getById(sellAccountId); + const foundAccount = await accountRepository.findById(sellAccountId); if (!foundAccount) { this.logger.info('[items] sell account not found.', { tenantId, sellAccountId }); @@ -127,7 +127,7 @@ export default class ItemsService implements IItemsService { this.logger.info('[items] validate inventory account existance.', { tenantId, inventoryAccountId }); const otherAsset = await accountTypeRepository.getByKey('other_asset'); - const foundAccount = await accountRepository.getById(inventoryAccountId); + const foundAccount = await accountRepository.findById(inventoryAccountId); if (!foundAccount) { this.logger.info('[items] inventory account not found.', { tenantId, inventoryAccountId }); diff --git a/server/src/services/Media/MediaService.ts b/server/src/services/Media/MediaService.ts new file mode 100644 index 000000000..eb8da6f70 --- /dev/null +++ b/server/src/services/Media/MediaService.ts @@ -0,0 +1,223 @@ +import fs from 'fs'; +import { Service, Inject } from 'typedi'; +import TenancyService from 'services/Tenancy/TenancyService'; +import { ServiceError } from "exceptions"; +import { IMedia, IMediaService } from 'interfaces'; +import { difference } from 'lodash'; + +const fsPromises = fs.promises; + +const ERRORS = { + MINETYPE_NOT_SUPPORTED: 'MINETYPE_NOT_SUPPORTED', + MEDIA_NOT_FOUND: 'MEDIA_NOT_FOUND', + MODEL_NAME_HAS_NO_MEDIA: 'MODEL_NAME_HAS_NO_MEDIA', + MODEL_ID_NOT_FOUND: 'MODEL_ID_NOT_FOUND', + MEDIA_IDS_NOT_FOUND: 'MEDIA_IDS_NOT_FOUND', + MEDIA_LINK_EXISTS: 'MEDIA_LINK_EXISTS' +} +const publicPath = 'storage/app/public/'; +const attachmentsMimes = ['image/png', 'image/jpeg']; + +@Service() +export default class MediaService implements IMediaService { + @Inject('logger') + logger: any; + + @Inject() + tenancy: TenancyService; + + @Inject('repositories') + sysRepositories: any; + + /** + * Retrieve media model or throw not found error + * @param tenantId + * @param mediaId + */ + async getMediaOrThrowError(tenantId: number, mediaId: number) { + const { Media } = this.tenancy.models(tenantId); + const foundMedia = await Media.query().findById(mediaId); + + if (!foundMedia) { + throw new ServiceError(ERRORS.MEDIA_NOT_FOUND); + } + return foundMedia; + } + + /** + * Retreive media models by the given ids or throw not found error. + * @param {number} tenantId + * @param {number[]} mediaIds + */ + async getMediaByIdsOrThrowError(tenantId: number, mediaIds: number[]) { + const { Media } = this.tenancy.models(tenantId); + const foundMedia = await Media.query().whereIn('id', mediaIds); + + const storedMediaIds = foundMedia.map((m) => m.id); + const notFoundMedia = difference(mediaIds, storedMediaIds); + + if (notFoundMedia.length > 0) { + throw new ServiceError(ERRORS.MEDIA_IDS_NOT_FOUND); + } + return foundMedia; + } + + /** + * Validates the model name and id. + * @param {number} tenantId + * @param {string} modelName + * @param {number} modelId + */ + async validateModelNameAndIdExistance(tenantId: number, modelName: string, modelId: number) { + const models = this.tenancy.models(tenantId); + this.logger.info('[media] trying to validate model name and id.', { tenantId, modelName, modelId }); + + if (!models[modelName]) { + this.logger.info('[media] model name not found.', { tenantId, modelName, modelId }); + throw new ServiceError(ERRORS.MODEL_NAME_HAS_NO_MEDIA); + } + if (!models[modelName].media) { + this.logger.info('[media] model is not media-able.', { tenantId, modelName, modelId }); + throw new ServiceError(ERRORS.MODEL_NAME_HAS_NO_MEDIA); + } + + const foundModel = await models[modelName].query().findById(modelId); + + if (!foundModel) { + this.logger.info('[media] model is not found.', { tenantId, modelName, modelId }); + throw new ServiceError(ERRORS.MODEL_ID_NOT_FOUND); + } + } + + /** + * Validates the media existance. + * @param {number} tenantId + * @param {number} mediaId + * @param {number} modelId + * @param {string} modelName + */ + async validateMediaLinkExistance( + tenantId: number, + mediaId: number, + modelId: number, + modelName: string + ) { + const { MediaLink } = this.tenancy.models(tenantId); + + const foundMediaLinks = await MediaLink.query() + .where('media_id', mediaId) + .where('model_id', modelId) + .where('model_name', modelName); + + if (foundMediaLinks.length > 0) { + throw new ServiceError(ERRORS.MEDIA_LINK_EXISTS); + } + } + + /** + * Links the given media to the specific media-able model resource. + * @param {number} tenantId + * @param {number} mediaId + * @param {number} modelId + * @param {string} modelType + */ + async linkMedia(tenantId: number, mediaId: number, modelId: number, modelName: string) { + this.logger.info('[media] trying to link media.', { tenantId, mediaId, modelId, modelName }); + const { MediaLink } = this.tenancy.models(tenantId); + await this.validateMediaLinkExistance(tenantId, mediaId, modelId, modelName); + + const media = await this.getMediaOrThrowError(tenantId, mediaId); + await this.validateModelNameAndIdExistance(tenantId, modelName, modelId); + + await MediaLink.query().insert({ mediaId, modelId, modelName }); + } + + /** + * Retrieve media metadata. + * @param {number} tenantId - Tenant id. + * @param {number} mediaId - Media id. + * @return {Promise} + */ + public async getMedia(tenantId: number, mediaId: number): Promise { + this.logger.info('[media] try to get media.', { tenantId, mediaId }); + return this.getMediaOrThrowError(tenantId, mediaId); + } + + /** + * Deletes the given media. + * @param {number} tenantId + * @param {number} mediaId + * @return {Promise} + */ + public async deleteMedia(tenantId: number, mediaId: number|number[]): Promise { + const { Media, MediaLink } = this.tenancy.models(tenantId); + const { tenantRepository } = this.sysRepositories; + + this.logger.info('[media] trying to delete media.', { tenantId, mediaId }); + + const mediaIds = Array.isArray(mediaId) ? mediaId : [mediaId]; + + const tenant = await tenantRepository.getById(tenantId); + const media = await this.getMediaByIdsOrThrowError(tenantId, mediaIds); + + const tenantPath = `${publicPath}${tenant.organizationId}`; + const unlinkOpers = []; + + media.forEach((mediaModel) => { + const oper = fsPromises.unlink(`${tenantPath}/${mediaModel.attachmentFile}`); + unlinkOpers.push(oper); + }); + await Promise.all(unlinkOpers) + .then((resolved) => { + resolved.forEach(() => { + this.logger.info('[attachment] file has been deleted.'); + }); + }) + .catch((errors) => { + this.logger.info('[attachment] Delete item attachment file delete failed.', { errors }); + }); + await MediaLink.query().whereIn('media_id', mediaIds).delete(); + await Media.query().whereIn('id', mediaIds).delete(); + } + + /** + * Uploads the given attachment. + * @param {number} tenantId - + * @param {any} attachment - + * @return {Promise} + */ + public async upload(tenantId: number, attachment: any, modelName?: string, modelId?: number): Promise { + const { tenantRepository } = this.sysRepositories; + const { Media } = this.tenancy.models(tenantId); + + this.logger.info('[media] trying to upload media.', { tenantId }); + + const tenant = await tenantRepository.getById(tenantId); + const fileName = `${attachment.md5}.png`; + + // Validate the attachment. + if (attachment && attachmentsMimes.indexOf(attachment.mimetype) === -1) { + throw new ServiceError(ERRORS.MINETYPE_NOT_SUPPORTED); + } + if (modelName && modelId) { + await this.validateModelNameAndIdExistance(tenantId, modelName, modelId); + } + try { + await attachment.mv(`${publicPath}${tenant.organizationId}/${fileName}`); + this.logger.info('[attachment] uploaded successfully'); + } catch (error) { + this.logger.info('[attachment] uploading failed.', { error }); + } + const media = await Media.query().insertGraph({ + attachmentFile: `${fileName}`, + ...(modelName && modelId) ? { + links: [{ + modelName, + modelId, + }] + } : {}, + }); + this.logger.info('[media] uploaded successfully.', { tenantId, fileName, modelName, modelId }); + return media; + } +} \ No newline at end of file