Merge pull request #400 from bigcapitalhq/clean-up-templ-import-files

feat: clean up the imported temp files
This commit is contained in:
Ahmed Bouhuolia
2024-04-09 00:16:36 +02:00
committed by GitHub
23 changed files with 224 additions and 88 deletions

View File

@@ -3,3 +3,4 @@
stdout.log stdout.log
/dist /dist
/build /build
/public/imports

BIN
packages/server/public/.DS_Store vendored Normal file

Binary file not shown.

BIN
packages/server/public/imports/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -48,6 +48,7 @@ export class ImportController extends BaseController {
router.get( router.get(
'/sample', '/sample',
[query('resource').exists(), query('format').optional()], [query('resource').exists(), query('format').optional()],
this.validationResult,
this.downloadImportSample.bind(this), this.downloadImportSample.bind(this),
this.catchServiceErrors this.catchServiceErrors
); );

View File

@@ -5,17 +5,28 @@ export function allowSheetExtensions(req, file, cb) {
if ( if (
file.mimetype !== 'text/csv' && file.mimetype !== 'text/csv' &&
file.mimetype !== 'application/vnd.ms-excel' && file.mimetype !== 'application/vnd.ms-excel' &&
file.mimetype !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' file.mimetype !==
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
) { ) {
cb(new ServiceError('IMPORTED_FILE_EXTENSION_INVALID')); cb(new ServiceError('IMPORTED_FILE_EXTENSION_INVALID'));
return; return;
} }
cb(null, true); cb(null, true);
} }
const storage = Multer.diskStorage({
destination: function (req, file, cb) {
cb(null, './public/imports');
},
filename: function (req, file, cb) {
// Add the creation timestamp to clean up temp files later.
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, uniqueSuffix);
},
});
export const uploadImportFile = Multer({ export const uploadImportFile = Multer({
dest: './public/imports', storage,
limits: { fileSize: 5 * 1024 * 1024 }, limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: allowSheetExtensions, fileFilter: allowSheetExtensions,
}); });

View File

@@ -11,6 +11,7 @@ import { SendSaleEstimateMailJob } from '@/services/Sales/Estimates/SendSaleEsti
import { SaleReceiptMailNotificationJob } from '@/services/Sales/Receipts/SaleReceiptMailNotificationJob'; import { SaleReceiptMailNotificationJob } from '@/services/Sales/Receipts/SaleReceiptMailNotificationJob';
import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob'; import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob';
import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob'; import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob';
import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDeleteExpiredFilesJob';
export default ({ agenda }: { agenda: Agenda }) => { export default ({ agenda }: { agenda: Agenda }) => {
new ResetPasswordMailJob(agenda); new ResetPasswordMailJob(agenda);
@@ -25,6 +26,9 @@ export default ({ agenda }: { agenda: Agenda }) => {
new SaleReceiptMailNotificationJob(agenda); new SaleReceiptMailNotificationJob(agenda);
new PaymentReceiveMailNotificationJob(agenda); new PaymentReceiveMailNotificationJob(agenda);
new PlaidFetchTransactionsJob(agenda); new PlaidFetchTransactionsJob(agenda);
new ImportDeleteExpiredFilesJobs(agenda);
agenda.start(); agenda.start().then(() => {
agenda.every('1 hours', 'delete-expired-imported-files', {});
});
}; };

View File

@@ -61,7 +61,6 @@ import Task from 'models/Task';
import TaxRate from 'models/TaxRate'; import TaxRate from 'models/TaxRate';
import TaxRateTransaction from 'models/TaxRateTransaction'; import TaxRateTransaction from 'models/TaxRateTransaction';
import Attachment from 'models/Attachment'; import Attachment from 'models/Attachment';
import Import from 'models/Import';
import PlaidItem from 'models/PlaidItem'; import PlaidItem from 'models/PlaidItem';
import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction'; import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction';
@@ -128,7 +127,6 @@ export default (knex) => {
TaxRate, TaxRate,
TaxRateTransaction, TaxRateTransaction,
Attachment, Attachment,
Import,
PlaidItem, PlaidItem,
UncategorizedCashflowTransaction UncategorizedCashflowTransaction
}; };

View File

@@ -1,4 +1,3 @@
import fs from 'fs/promises';
import XLSX from 'xlsx'; import XLSX from 'xlsx';
import bluebird from 'bluebird'; import bluebird from 'bluebird';
import * as R from 'ramda'; import * as R from 'ramda';
@@ -17,7 +16,7 @@ import { getUniqueImportableValue, trimObject } from './_utils';
import { ImportableResources } from './ImportableResources'; import { ImportableResources } from './ImportableResources';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import Import from '@/models/Import'; import { Import } from '@/system/models';
@Service() @Service()
export class ImportFileCommon { export class ImportFileCommon {
@@ -48,14 +47,6 @@ export class ImportFileCommon {
return XLSX.utils.sheet_to_json(worksheet, {}); return XLSX.utils.sheet_to_json(worksheet, {});
} }
/**
* Reads the import file.
* @param {string} filename
* @returns {Promise<Buffer>}
*/
public readImportFile(filename: string) {
return fs.readFile(`public/imports/${filename}`);
}
/** /**
* Imports the given parsed data to the resource storage through registered importable service. * Imports the given parsed data to the resource storage through registered importable service.
@@ -202,19 +193,4 @@ export class ImportFileCommon {
public parseSheetColumns(json: unknown[]): string[] { public parseSheetColumns(json: unknown[]): string[] {
return R.compose(Object.keys, trimObject, first)(json); return R.compose(Object.keys, trimObject, first)(json);
} }
/**
* Deletes the imported file from the storage and database.
* @param {number} tenantId
* @param {} importFile
*/
public async deleteImportFile(tenantId: number, importFile: any) {
const { Import } = this.tenancy.models(tenantId);
// Deletes the import row.
await Import.query().findById(importFile.id).delete();
// Deletes the imported file.
await fs.unlink(`public/imports/${importFile.filename}`);
}
} }

View File

@@ -9,14 +9,11 @@ import {
getFieldKey, getFieldKey,
aggregate, aggregate,
sanitizeSheetData, sanitizeSheetData,
getMapToPath,
} from './_utils'; } from './_utils';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { CurrencyParsingDTOs } from './_constants';
const CurrencyParsingDTOs = 10;
const getMapToPath = (to: string, group = '') =>
group ? `${group}.${to}` : to;
@Service() @Service()
export class ImportFileDataTransformer { export class ImportFileDataTransformer {

View File

@@ -1,6 +1,5 @@
import { fromPairs, isUndefined } from 'lodash'; import { fromPairs, isUndefined } from 'lodash';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { import {
ImportDateFormats, ImportDateFormats,
ImportFileMapPOJO, ImportFileMapPOJO,
@@ -9,12 +8,10 @@ import {
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { ERRORS } from './_utils'; import { ERRORS } from './_utils';
import { Import } from '@/system/models';
@Service() @Service()
export class ImportFileMapping { export class ImportFileMapping {
@Inject()
private tenancy: HasTenancyService;
@Inject() @Inject()
private resource: ResourceService; private resource: ResourceService;
@@ -29,8 +26,6 @@ export class ImportFileMapping {
importId: number, importId: number,
maps: ImportMappingAttr[] maps: ImportMappingAttr[]
): Promise<ImportFileMapPOJO> { ): Promise<ImportFileMapPOJO> {
const { Import } = this.tenancy.models(tenantId);
const importFile = await Import.query() const importFile = await Import.query()
.findOne('filename', importId) .findOne('filename', importId)
.throwIfNotFound(); .throwIfNotFound();

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { ImportFileMetaTransformer } from './ImportFileMetaTransformer'; import { ImportFileMetaTransformer } from './ImportFileMetaTransformer';
import { Import } from '@/system/models';
@Service() @Service()
export class ImportFileMeta { export class ImportFileMeta {
@@ -12,15 +13,15 @@ export class ImportFileMeta {
private transformer: TransformerInjectable; private transformer: TransformerInjectable;
/** /**
* * Retrieves the import meta of the given import model id.
* @param {number} tenantId * @param {number} tenantId
* @param {number} importId * @param {number} importId
* @returns {} * @returns {}
*/ */
async getImportMeta(tenantId: number, importId: string) { async getImportMeta(tenantId: number, importId: string) {
const { Import } = this.tenancy.models(tenantId); const importFile = await Import.query()
.where('tenantId', tenantId)
const importFile = await Import.query().findOne('importId', importId); .findOne('importId', importId);
// Retrieves the transformed accounts collection. // Retrieves the transformed accounts collection.
return this.transformer.transform( return this.transformer.transform(

View File

@@ -2,19 +2,16 @@ import { Inject, Service } from 'typedi';
import { chain } from 'lodash'; import { chain } from 'lodash';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { ERRORS, getSheetColumns, getUnmappedSheetColumns } from './_utils'; import { ERRORS, getSheetColumns, getUnmappedSheetColumns, readImportFile } from './_utils';
import HasTenancyService from '../Tenancy/TenancyService';
import { ImportFileCommon } from './ImportFileCommon'; import { ImportFileCommon } from './ImportFileCommon';
import { ImportFileDataTransformer } from './ImportFileDataTransformer'; import { ImportFileDataTransformer } from './ImportFileDataTransformer';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import UnitOfWork from '../UnitOfWork'; import UnitOfWork from '../UnitOfWork';
import { ImportFilePreviewPOJO } from './interfaces'; import { ImportFilePreviewPOJO } from './interfaces';
import { Import } from '@/system/models';
@Service() @Service()
export class ImportFileProcess { export class ImportFileProcess {
@Inject()
private tenancy: HasTenancyService;
@Inject() @Inject()
private resource: ResourceService; private resource: ResourceService;
@@ -38,10 +35,9 @@ export class ImportFileProcess {
importId: number, importId: number,
trx?: Knex.Transaction trx?: Knex.Transaction
): Promise<ImportFilePreviewPOJO> { ): Promise<ImportFilePreviewPOJO> {
const { Import } = this.tenancy.models(tenantId);
const importFile = await Import.query() const importFile = await Import.query()
.findOne('importId', importId) .findOne('importId', importId)
.where('tenantId', tenantId)
.throwIfNotFound(); .throwIfNotFound();
// Throw error if the import file is not mapped yet. // Throw error if the import file is not mapped yet.
@@ -49,7 +45,7 @@ export class ImportFileProcess {
throw new ServiceError(ERRORS.IMPORT_FILE_NOT_MAPPED); throw new ServiceError(ERRORS.IMPORT_FILE_NOT_MAPPED);
} }
// Read the imported file. // Read the imported file.
const buffer = await this.importCommon.readImportFile(importFile.filename); const buffer = await readImportFile(importFile.filename);
const sheetData = this.importCommon.parseXlsxSheet(buffer); const sheetData = this.importCommon.parseXlsxSheet(buffer);
const header = getSheetColumns(sheetData); const header = getSheetColumns(sheetData);

View File

@@ -1,7 +1,8 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { import {
deleteImportFile,
getResourceColumns, getResourceColumns,
readImportFile,
sanitizeResourceName, sanitizeResourceName,
validateSheetEmpty, validateSheetEmpty,
} from './_utils'; } from './_utils';
@@ -9,12 +10,10 @@ import ResourceService from '../Resource/ResourceService';
import { ImportFileCommon } from './ImportFileCommon'; import { ImportFileCommon } from './ImportFileCommon';
import { ImportFileDataValidator } from './ImportFileDataValidator'; import { ImportFileDataValidator } from './ImportFileDataValidator';
import { ImportFileUploadPOJO } from './interfaces'; import { ImportFileUploadPOJO } from './interfaces';
import { Import } from '@/system/models';
@Service() @Service()
export class ImportFileUploadService { export class ImportFileUploadService {
@Inject()
private tenancy: HasTenancyService;
@Inject() @Inject()
private resourceService: ResourceService; private resourceService: ResourceService;
@@ -25,11 +24,12 @@ export class ImportFileUploadService {
private importValidator: ImportFileDataValidator; private importValidator: ImportFileDataValidator;
/** /**
* Reads the imported file and stores the import file meta under unqiue id. * Imports the specified file for the given resource.
* @param {number} tenantId - Tenant id. * Deletes the file if an error occurs during the import process.
* @param {string} resource - Resource name. * @param {number} tenantId
* @param {string} filePath - File path. * @param {string} resourceName
* @param {string} fileName - File name. * @param {string} filename
* @param {Record<string, number | string>} params
* @returns {Promise<ImportFileUploadPOJO>} * @returns {Promise<ImportFileUploadPOJO>}
*/ */
public async import( public async import(
@@ -38,8 +38,35 @@ export class ImportFileUploadService {
filename: string, filename: string,
params: Record<string, number | string> params: Record<string, number | string>
): Promise<ImportFileUploadPOJO> { ): Promise<ImportFileUploadPOJO> {
const { Import } = this.tenancy.models(tenantId); console.log(filename, 'filename');
try {
return await this.importUnhandled(
tenantId,
resourceName,
filename,
params
);
} catch (err) {
deleteImportFile(filename);
throw err;
}
}
/**
* Reads the imported file and stores the import file meta under unqiue id.
* @param {number} tenantId - Tenant id.
* @param {string} resource - Resource name.
* @param {string} filePath - File path.
* @param {string} fileName - File name.
* @returns {Promise<ImportFileUploadPOJO>}
*/
public async importUnhandled(
tenantId: number,
resourceName: string,
filename: string,
params: Record<string, number | string>
): Promise<ImportFileUploadPOJO> {
const resource = sanitizeResourceName(resourceName); const resource = sanitizeResourceName(resourceName);
const resourceMeta = this.resourceService.getResourceMeta( const resourceMeta = this.resourceService.getResourceMeta(
tenantId, tenantId,
@@ -49,7 +76,7 @@ export class ImportFileUploadService {
this.importValidator.validateResourceImportable(resourceMeta); this.importValidator.validateResourceImportable(resourceMeta);
// Reads the imported file into buffer. // Reads the imported file into buffer.
const buffer = await this.importFileCommon.readImportFile(filename); const buffer = await readImportFile(filename);
// Parse the buffer file to array data. // Parse the buffer file to array data.
const sheetData = this.importFileCommon.parseXlsxSheet(buffer); const sheetData = this.importFileCommon.parseXlsxSheet(buffer);
@@ -76,6 +103,7 @@ export class ImportFileUploadService {
const importFile = await Import.query().insert({ const importFile = await Import.query().insert({
filename, filename,
resource, resource,
tenantId,
importId: filename, importId: filename,
columns: coumnsStringified, columns: coumnsStringified,
params: paramsStringified, params: paramsStringified,

View File

@@ -0,0 +1,34 @@
import moment from 'moment';
import bluebird from 'bluebird';
import { Import } from '@/system/models';
import { deleteImportFile } from './_utils';
import { Service } from 'typedi';
@Service()
export class ImportDeleteExpiredFiles {
/**
* Delete expired files.
*/
async deleteExpiredFiles() {
const yesterday = moment().subtract(1, 'hour').format('YYYY-MM-DD HH:mm');
const expiredImports = await Import.query().where(
'createdAt',
'<',
yesterday
);
await bluebird.map(
expiredImports,
async (expiredImport) => {
await deleteImportFile(expiredImport.filename);
},
{ concurrency: 10 }
);
const expiredImportsIds = expiredImports.map(
(expiredImport) => expiredImport.id
);
if (expiredImportsIds.length > 0) {
await Import.query().whereIn('id', expiredImportsIds).delete();
}
}
}

View File

@@ -0,0 +1,3 @@
export const CurrencyParsingDTOs = 10;

View File

@@ -2,6 +2,7 @@ import * as Yup from 'yup';
import moment from 'moment'; import moment from 'moment';
import * as R from 'ramda'; import * as R from 'ramda';
import { Knex } from 'knex'; import { Knex } from 'knex';
import fs from 'fs/promises';
import { import {
defaultTo, defaultTo,
upperFirst, upperFirst,
@@ -421,3 +422,30 @@ export function aggregate(
export const sanitizeSheetData = (json) => { export const sanitizeSheetData = (json) => {
return R.compose(R.map(trimObject))(json); return R.compose(R.map(trimObject))(json);
}; };
/**
* Returns the path to map a value to based on the 'to' and 'group' parameters.
* @param {string} to - The target key to map the value to.
* @param {string} group - The group key to nest the target key under.
* @returns {string} - The path to map the value to.
*/
export const getMapToPath = (to: string, group = '') =>
group ? `${group}.${to}` : to;
/**
* Deletes the imported file from the storage and database.
* @param {string} filename
*/
export const deleteImportFile = async (filename: string) => {
// Deletes the imported file.
await fs.unlink(`public/imports/${filename}`);
};
/**
* Reads the import file.
* @param {string} filename
* @returns {Promise<Buffer>}
*/
export const readImportFile = (filename: string) => {
return fs.readFile(`public/imports/${filename}`);
};

View File

@@ -1 +1,28 @@
export class ImportDeleteExpiredFilesJobs {} import Container, { Service } from 'typedi';
import { ImportDeleteExpiredFiles } from '../ImportRemoveExpiredFiles';
@Service()
export class ImportDeleteExpiredFilesJobs {
/**
* Constructor method.
*/
constructor(agenda) {
agenda.define('delete-expired-imported-files', this.handler);
}
/**
* Triggers sending invoice mail.
*/
private handler = async (job, done: Function) => {
const importDeleteExpiredFiles = Container.get(ImportDeleteExpiredFiles);
try {
console.log('Delete expired import files has started.');
await importDeleteExpiredFiles.deleteExpiredFiles();
done();
} catch (error) {
console.log(error);
done(error);
}
};
}

View File

@@ -7,6 +7,12 @@ exports.up = function (knex) {
table.json('columns'); table.json('columns');
table.json('mapping'); table.json('mapping');
table.json('params'); table.json('params');
table
.bigInteger('tenant_id')
.unsigned()
.index()
.references('id')
.inTable('tenants');
table.timestamps(); table.timestamps();
}); });
}; };

View File

@@ -1,10 +1,12 @@
import TenantModel from 'models/TenantModel'; import { Model, ModelObject } from 'objection';
import SystemModel from './SystemModel';
export default class Import extends TenantModel { export class Import extends SystemModel {
resource!: string; resource: string;
tenantId: number;
mapping!: string; mapping!: string;
columns!: string; columns!: string;
params!: Record<string, any>; params!: string;
/** /**
* Table name. * Table name.
@@ -24,14 +26,7 @@ export default class Import extends TenantModel {
* Timestamps columns. * Timestamps columns.
*/ */
get timestamps() { get timestamps() {
return []; return ['createdAt', 'updatedAt'];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
return {};
} }
/** /**
@@ -50,7 +45,6 @@ export default class Import extends TenantModel {
} }
} }
public get paramsParsed() { public get paramsParsed() {
try { try {
return JSON.parse(this.params); return JSON.parse(this.params);
@@ -66,4 +60,27 @@ export default class Import extends TenantModel {
return []; return [];
} }
} }
/**
* Relationship mapping.
*/
static get relationMappings() {
const Tenant = require('system/models/Tenant');
return {
/**
* System user may belongs to tenant model.
*/
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: Tenant.default,
join: {
from: 'imports.tenantId',
to: 'tenants.id',
},
},
};
}
} }
export type ImportShape = ModelObject<Import>;

View File

@@ -5,6 +5,11 @@ import BaseModel from 'models/Model';
import TenantMetadata from './TenantMetadata'; import TenantMetadata from './TenantMetadata';
export default class Tenant extends BaseModel { export default class Tenant extends BaseModel {
upgradeJobId: string;
buildJobId: string;
initializedAt!: Date | null;
seededAt!: Date | null;
/** /**
* Table name. * Table name.
*/ */
@@ -14,6 +19,7 @@ export default class Tenant extends BaseModel {
/** /**
* Timestamps columns. * Timestamps columns.
* @returns {string[]}
*/ */
get timestamps() { get timestamps() {
return ['createdAt', 'updatedAt']; return ['createdAt', 'updatedAt'];
@@ -21,6 +27,7 @@ export default class Tenant extends BaseModel {
/** /**
* Virtual attributes. * Virtual attributes.
* @returns {string[]}
*/ */
static get virtualAttributes() { static get virtualAttributes() {
return ['isReady', 'isBuildRunning', 'isUpgradeRunning']; return ['isReady', 'isBuildRunning', 'isUpgradeRunning'];
@@ -28,6 +35,7 @@ export default class Tenant extends BaseModel {
/** /**
* Tenant is ready. * Tenant is ready.
* @returns {boolean}
*/ */
get isReady() { get isReady() {
return !!(this.initializedAt && this.seededAt); return !!(this.initializedAt && this.seededAt);
@@ -35,6 +43,7 @@ export default class Tenant extends BaseModel {
/** /**
* Detarimes the tenant whether is build currently running. * Detarimes the tenant whether is build currently running.
* @returns {boolean}
*/ */
get isBuildRunning() { get isBuildRunning() {
return !!this.buildJobId; return !!this.buildJobId;
@@ -42,6 +51,7 @@ export default class Tenant extends BaseModel {
/** /**
* Detarmines the tenant whether is upgrade currently running. * Detarmines the tenant whether is upgrade currently running.
* @returns {boolean}
*/ */
get isUpgradeRunning() { get isUpgradeRunning() {
return !!this.upgradeJobId; return !!this.upgradeJobId;
@@ -64,6 +74,7 @@ export default class Tenant extends BaseModel {
}, },
}; };
} }
/** /**
* Creates a new tenant with random organization id. * Creates a new tenant with random organization id.
*/ */

View File

@@ -4,6 +4,7 @@ import SystemUser from './SystemUser';
import PasswordReset from './PasswordReset'; import PasswordReset from './PasswordReset';
import Invite from './Invite'; import Invite from './Invite';
import SystemPlaidItem from './SystemPlaidItem'; import SystemPlaidItem from './SystemPlaidItem';
import { Import } from './Import';
export { export {
Tenant, Tenant,
@@ -12,4 +13,5 @@ export {
PasswordReset, PasswordReset,
Invite, Invite,
SystemPlaidItem, SystemPlaidItem,
Import,
}; };