mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-16 04:40:32 +00:00
feat(nestjs): migrate to NestJS
This commit is contained in:
7
packages/server/src/utils/accum-sum.ts
Normal file
7
packages/server/src/utils/accum-sum.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
export const accumSum = (data: any[], callback: (data: any) => number): number => {
|
||||
return data.reduce((acc, _data) => {
|
||||
const amount = callback(_data);
|
||||
return acc + amount;
|
||||
}, 0);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IContact } from '@/interfaces';
|
||||
import { Contact } from "@/modules/Contacts/models/Contact";
|
||||
|
||||
interface OrganizationAddressFormatArgs {
|
||||
organizationName?: string;
|
||||
@@ -83,7 +83,7 @@ export const defaultContactAddressFormat = `{CONTACT_NAME}
|
||||
`;
|
||||
|
||||
export const contactAddressTextFormat = (
|
||||
contact: IContact,
|
||||
contact: Contact,
|
||||
message: string = defaultContactAddressFormat
|
||||
) => {
|
||||
const args = {
|
||||
|
||||
13
packages/server/src/utils/all-conditions-passed.ts
Normal file
13
packages/server/src/utils/all-conditions-passed.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as R from 'ramda';
|
||||
/**
|
||||
* All passed conditions should pass.
|
||||
* @param condsPairFilters
|
||||
* @returns
|
||||
*/
|
||||
export const allPassedConditionsPass = (condsPairFilters: any[]): Function => {
|
||||
const filterCallbacks = condsPairFilters
|
||||
.filter((cond) => cond[0])
|
||||
.map((cond) => cond[1]);
|
||||
|
||||
return R.allPass(filterCallbacks);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
export const assocDepthLevelToObjectTree = (
|
||||
objects,
|
||||
level = 1,
|
||||
propertyName = 'level'
|
||||
) => {
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
const object = objects[i];
|
||||
object[propertyName] = level;
|
||||
|
||||
if (object.children) {
|
||||
assocDepthLevelToObjectTree(object.children, level + 1, propertyName);
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
};
|
||||
15
packages/server/src/utils/associate-item-entries-index.ts
Normal file
15
packages/server/src/utils/associate-item-entries-index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { isNull, isUndefined } from 'lodash';
|
||||
|
||||
export function assocItemEntriesDefaultIndex<T>(
|
||||
entries: Array<T & { index?: number }>,
|
||||
): Array<T & { index: number }> {
|
||||
return entries.map((entry, index) => {
|
||||
return {
|
||||
index:
|
||||
isUndefined(entry.index) || isNull(entry.index)
|
||||
? index + 1
|
||||
: entry.index,
|
||||
...entry,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { trim } from 'lodash';
|
||||
|
||||
export const castCommaListEnvVarToArray = (envVar: string): Array<string> => {
|
||||
return envVar ? envVar?.split(',')?.map(trim) : [];
|
||||
};
|
||||
57
packages/server/src/utils/date-range-collection.ts
Normal file
57
packages/server/src/utils/date-range-collection.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as moment from 'moment';
|
||||
|
||||
export const dateRangeCollection = (
|
||||
fromDate,
|
||||
toDate,
|
||||
addType: moment.unitOfTime.StartOf = 'day',
|
||||
increment: number = 1,
|
||||
) => {
|
||||
const collection = [];
|
||||
const momentFromDate = moment(fromDate);
|
||||
let dateFormat = '';
|
||||
|
||||
switch (addType) {
|
||||
case 'day':
|
||||
default:
|
||||
dateFormat = 'YYYY-MM-DD';
|
||||
break;
|
||||
case 'month':
|
||||
case 'quarter':
|
||||
dateFormat = 'YYYY-MM';
|
||||
break;
|
||||
case 'year':
|
||||
dateFormat = 'YYYY';
|
||||
break;
|
||||
}
|
||||
for (
|
||||
let i = momentFromDate;
|
||||
i.isBefore(toDate, addType) || i.isSame(toDate, addType);
|
||||
i.add(increment, `${addType}s` as moment.unitOfTime.DurationConstructor)
|
||||
) {
|
||||
collection.push(i.endOf(addType).format(dateFormat));
|
||||
}
|
||||
return collection;
|
||||
};
|
||||
|
||||
export const dateRangeFromToCollection = (
|
||||
fromDate: moment.MomentInput,
|
||||
toDate: moment.MomentInput,
|
||||
addType: moment.unitOfTime.StartOf = 'day',
|
||||
increment: number = 1,
|
||||
) => {
|
||||
const collection = [];
|
||||
const momentFromDate = moment(fromDate);
|
||||
const dateFormat = 'YYYY-MM-DD';
|
||||
|
||||
for (
|
||||
let i = momentFromDate;
|
||||
i.isBefore(toDate, addType) || i.isSame(toDate, addType);
|
||||
i.add(increment, `${addType}s` as moment.unitOfTime.DurationConstructor)
|
||||
) {
|
||||
collection.push({
|
||||
fromDate: i.startOf(addType).format(dateFormat),
|
||||
toDate: i.endOf(addType).format(dateFormat),
|
||||
});
|
||||
}
|
||||
return collection;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import * as _ from 'lodash';
|
||||
import * as deepdash from 'deepdash';
|
||||
import * as addDeepdash from 'deepdash';
|
||||
|
||||
const {
|
||||
condense,
|
||||
@@ -16,6 +17,7 @@ const {
|
||||
mapDeep,
|
||||
mapKeysDeep,
|
||||
mapValuesDeep,
|
||||
mapValues,
|
||||
omitDeep,
|
||||
pathMatches,
|
||||
pathToString,
|
||||
@@ -24,7 +26,7 @@ const {
|
||||
reduceDeep,
|
||||
someDeep,
|
||||
iteratee,
|
||||
} = deepdash.default(_);
|
||||
} = addDeepdash(_);
|
||||
|
||||
const mapValuesDeepReverse = (nodes, callback, config?) => {
|
||||
const clonedNodes = _.clone(nodes);
|
||||
@@ -115,6 +117,7 @@ export {
|
||||
mapDeep,
|
||||
mapKeysDeep,
|
||||
mapValuesDeep,
|
||||
mapValues,
|
||||
omitDeep,
|
||||
pathMatches,
|
||||
pathToString,
|
||||
|
||||
30
packages/server/src/utils/entries-amount-diff.ts
Normal file
30
packages/server/src/utils/entries-amount-diff.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
export const entriesAmountDiff = (
|
||||
newEntries,
|
||||
oldEntries,
|
||||
amountAttribute,
|
||||
idAttribute,
|
||||
) => {
|
||||
const oldEntriesTable = _.chain(oldEntries)
|
||||
.groupBy(idAttribute)
|
||||
.mapValues((group) => _.sumBy(group, amountAttribute) || 0)
|
||||
.value();
|
||||
|
||||
const newEntriesTable = _.chain(newEntries)
|
||||
.groupBy(idAttribute)
|
||||
.mapValues((group) => _.sumBy(group, amountAttribute) || 0)
|
||||
.mergeWith(oldEntriesTable, (objValue, srcValue) => {
|
||||
return _.isNumber(objValue) ? objValue - srcValue : srcValue * -1;
|
||||
})
|
||||
.value();
|
||||
|
||||
return _.chain(newEntriesTable)
|
||||
.mapValues((value, key) => ({
|
||||
[idAttribute]: key,
|
||||
[amountAttribute]: value,
|
||||
}))
|
||||
.filter((entry) => entry[amountAttribute] != 0)
|
||||
.values()
|
||||
.value();
|
||||
};
|
||||
24
packages/server/src/utils/flat-to-nested-array.ts
Normal file
24
packages/server/src/utils/flat-to-nested-array.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const flatToNestedArray = (
|
||||
data,
|
||||
config = { id: 'id', parentId: 'parent_id' },
|
||||
) => {
|
||||
const map = {};
|
||||
const nestedArray = [];
|
||||
|
||||
data.forEach((item) => {
|
||||
map[item[config.id]] = item;
|
||||
map[item[config.id]].children = [];
|
||||
});
|
||||
|
||||
data.forEach((item) => {
|
||||
const parentItemId = item[config.parentId];
|
||||
|
||||
if (!item[config.parentId]) {
|
||||
nestedArray.push(item);
|
||||
}
|
||||
if (parentItemId) {
|
||||
map[parentItemId].children.push(item);
|
||||
}
|
||||
});
|
||||
return nestedArray;
|
||||
};
|
||||
23
packages/server/src/utils/format-date-fields.ts
Normal file
23
packages/server/src/utils/format-date-fields.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as moment from 'moment';
|
||||
|
||||
/**
|
||||
* Formats the given date fields.
|
||||
* @param {any} inputDTO - Input data.
|
||||
* @param {Array<string>} fields - Fields to format.
|
||||
* @param {string} format - Format string.
|
||||
* @returns {any}
|
||||
*/
|
||||
export const formatDateFields = (
|
||||
inputDTO: any,
|
||||
fields: Array<string>,
|
||||
format = 'YYYY-MM-DD',
|
||||
) => {
|
||||
const _inputDTO = { ...inputDTO };
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (_inputDTO[field]) {
|
||||
_inputDTO[field] = moment(_inputDTO[field]).format(format);
|
||||
}
|
||||
});
|
||||
return _inputDTO;
|
||||
};
|
||||
16
packages/server/src/utils/format-message.ts
Normal file
16
packages/server/src/utils/format-message.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defaultTo } from 'lodash';
|
||||
|
||||
export const formatMessage = (message: string, args: Record<string, any>) => {
|
||||
let formattedMessage = message;
|
||||
|
||||
Object.keys(args).forEach((key) => {
|
||||
const variable = `{${key}}`;
|
||||
const value = defaultTo(args[key], '');
|
||||
|
||||
formattedMessage = formattedMessage.replace(
|
||||
new RegExp(variable, 'g'),
|
||||
value
|
||||
);
|
||||
});
|
||||
return formattedMessage;
|
||||
};
|
||||
67
packages/server/src/utils/format-number.ts
Normal file
67
packages/server/src/utils/format-number.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { get } from 'lodash';
|
||||
import * as accounting from 'accounting';
|
||||
import * as Currencies from 'js-money/lib/currency';
|
||||
|
||||
const getNegativeFormat = (formatName) => {
|
||||
switch (formatName) {
|
||||
case 'parentheses':
|
||||
return '(%s%v)';
|
||||
case 'mines':
|
||||
return '-%s%v';
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrencySign = (currencyCode) => {
|
||||
return get(Currencies, `${currencyCode}.symbol`);
|
||||
};
|
||||
|
||||
export interface IFormatNumberSettings {
|
||||
precision?: number;
|
||||
divideOn1000?: boolean;
|
||||
excerptZero?: boolean;
|
||||
negativeFormat?: string;
|
||||
thousand?: string;
|
||||
decimal?: string;
|
||||
zeroSign?: string;
|
||||
money?: boolean;
|
||||
currencyCode?: string;
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export const formatNumber = (
|
||||
balance,
|
||||
{
|
||||
precision = 2,
|
||||
divideOn1000 = false,
|
||||
excerptZero = false,
|
||||
negativeFormat = 'mines',
|
||||
thousand = ',',
|
||||
decimal = '.',
|
||||
zeroSign = '',
|
||||
money = true,
|
||||
currencyCode,
|
||||
symbol = '',
|
||||
}: IFormatNumberSettings,
|
||||
) => {
|
||||
const formattedSymbol = getCurrencySign(currencyCode);
|
||||
const negForamt = getNegativeFormat(negativeFormat);
|
||||
const format = '%s%v';
|
||||
|
||||
let formattedBalance = parseFloat(balance);
|
||||
|
||||
if (divideOn1000) {
|
||||
formattedBalance /= 1000;
|
||||
}
|
||||
return accounting.formatMoney(
|
||||
formattedBalance,
|
||||
money ? formattedSymbol : symbol ? symbol : '',
|
||||
precision,
|
||||
thousand,
|
||||
decimal,
|
||||
{
|
||||
pos: format,
|
||||
neg: negForamt,
|
||||
zero: excerptZero ? zeroSign : format,
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
|
||||
export function formatMinutes(totalMinutes: number) {
|
||||
const minutes = totalMinutes % 60;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
|
||||
return `${padTo2Digits(hours)}:${padTo2Digits(minutes)}`;
|
||||
}
|
||||
|
||||
export function padTo2Digits(num: number) {
|
||||
return num.toString().padStart(2, '0');
|
||||
}
|
||||
9
packages/server/src/utils/increment.ts
Normal file
9
packages/server/src/utils/increment.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
export const increment = (n: number = 0) => {
|
||||
let counter = n;
|
||||
|
||||
return () => {
|
||||
counter += 1;
|
||||
return counter;
|
||||
};
|
||||
};
|
||||
@@ -1,522 +0,0 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import moment from 'moment';
|
||||
import _, { isEmpty } from 'lodash';
|
||||
import path from 'path';
|
||||
import * as R from 'ramda';
|
||||
|
||||
import accounting from 'accounting';
|
||||
import pug from 'pug';
|
||||
import Currencies from 'js-money/lib/currency';
|
||||
import definedOptions from '@/data/options';
|
||||
|
||||
export * from './table';
|
||||
|
||||
const hashPassword = (password) =>
|
||||
new Promise((resolve) => {
|
||||
bcrypt.genSalt(10, (error, salt) => {
|
||||
bcrypt.hash(password, salt, (err, hash) => {
|
||||
resolve(hash);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const origin = (request) => `${request.protocol}://${request.hostname}`;
|
||||
|
||||
const dateRangeCollection = (
|
||||
fromDate,
|
||||
toDate,
|
||||
addType = 'day',
|
||||
increment = 1
|
||||
) => {
|
||||
const collection = [];
|
||||
const momentFromDate = moment(fromDate);
|
||||
let dateFormat = '';
|
||||
|
||||
switch (addType) {
|
||||
case 'day':
|
||||
default:
|
||||
dateFormat = 'YYYY-MM-DD';
|
||||
break;
|
||||
case 'month':
|
||||
case 'quarter':
|
||||
dateFormat = 'YYYY-MM';
|
||||
break;
|
||||
case 'year':
|
||||
dateFormat = 'YYYY';
|
||||
break;
|
||||
}
|
||||
for (
|
||||
let i = momentFromDate;
|
||||
i.isBefore(toDate, addType) || i.isSame(toDate, addType);
|
||||
i.add(increment, `${addType}s`)
|
||||
) {
|
||||
collection.push(i.endOf(addType).format(dateFormat));
|
||||
}
|
||||
return collection;
|
||||
};
|
||||
|
||||
const dateRangeFromToCollection = (
|
||||
fromDate,
|
||||
toDate,
|
||||
addType = 'day',
|
||||
increment = 1
|
||||
) => {
|
||||
const collection = [];
|
||||
const momentFromDate = moment(fromDate);
|
||||
const dateFormat = 'YYYY-MM-DD';
|
||||
|
||||
for (
|
||||
let i = momentFromDate;
|
||||
i.isBefore(toDate, addType) || i.isSame(toDate, addType);
|
||||
i.add(increment, `${addType}s`)
|
||||
) {
|
||||
collection.push({
|
||||
fromDate: i.startOf(addType).format(dateFormat),
|
||||
toDate: i.endOf(addType).format(dateFormat),
|
||||
});
|
||||
}
|
||||
return collection;
|
||||
};
|
||||
|
||||
const dateRangeFormat = (rangeType) => {
|
||||
switch (rangeType) {
|
||||
case 'year':
|
||||
return 'YYYY';
|
||||
case 'month':
|
||||
case 'quarter':
|
||||
default:
|
||||
return 'YYYY-MM';
|
||||
}
|
||||
};
|
||||
|
||||
function mapKeysDeep(obj, cb, isRecursive) {
|
||||
if (!obj && !isRecursive) {
|
||||
return {};
|
||||
}
|
||||
if (!isRecursive) {
|
||||
if (
|
||||
typeof obj === 'string' ||
|
||||
typeof obj === 'number' ||
|
||||
typeof obj === 'boolean'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => mapKeysDeep(item, cb, true));
|
||||
}
|
||||
if (!_.isPlainObject(obj)) {
|
||||
return obj;
|
||||
}
|
||||
const result = _.mapKeys(obj, cb);
|
||||
return _.mapValues(result, (value) => mapKeysDeep(value, cb, true));
|
||||
}
|
||||
|
||||
const mapValuesDeep = (v, callback) =>
|
||||
_.isObject(v)
|
||||
? _.mapValues(v, (v) => mapValuesDeep(v, callback))
|
||||
: callback(v);
|
||||
|
||||
const promiseSerial = (funcs) => {
|
||||
return funcs.reduce(
|
||||
(promise, func) =>
|
||||
promise.then((result) =>
|
||||
func().then(Array.prototype.concat.bind(result))
|
||||
),
|
||||
Promise.resolve([])
|
||||
);
|
||||
};
|
||||
|
||||
const flatToNestedArray = (
|
||||
data,
|
||||
config = { id: 'id', parentId: 'parent_id' }
|
||||
) => {
|
||||
const map = {};
|
||||
const nestedArray = [];
|
||||
|
||||
data.forEach((item) => {
|
||||
map[item[config.id]] = item;
|
||||
map[item[config.id]].children = [];
|
||||
});
|
||||
|
||||
data.forEach((item) => {
|
||||
const parentItemId = item[config.parentId];
|
||||
|
||||
if (!item[config.parentId]) {
|
||||
nestedArray.push(item);
|
||||
}
|
||||
if (parentItemId) {
|
||||
map[parentItemId].children.push(item);
|
||||
}
|
||||
});
|
||||
return nestedArray;
|
||||
};
|
||||
|
||||
const itemsStartWith = (items, char) => {
|
||||
return items.filter((item) => item.indexOf(char) === 0);
|
||||
};
|
||||
|
||||
const getTotalDeep = (items, deepProp, totalProp) =>
|
||||
items.reduce((acc, item) => {
|
||||
const total = Array.isArray(item[deepProp])
|
||||
? getTotalDeep(item[deepProp], deepProp, totalProp)
|
||||
: 0;
|
||||
return _.sumBy(item, totalProp) + total + acc;
|
||||
}, 0);
|
||||
|
||||
function applyMixins(derivedCtor, baseCtors) {
|
||||
baseCtors.forEach((baseCtor) => {
|
||||
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
|
||||
Object.defineProperty(
|
||||
derivedCtor.prototype,
|
||||
name,
|
||||
Object.getOwnPropertyDescriptor(baseCtor.prototype, name)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const formatDateFields = (inputDTO, fields, format = 'YYYY-MM-DD') => {
|
||||
const _inputDTO = { ...inputDTO };
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (_inputDTO[field]) {
|
||||
_inputDTO[field] = moment(_inputDTO[field]).format(format);
|
||||
}
|
||||
});
|
||||
return _inputDTO;
|
||||
};
|
||||
|
||||
const getDefinedOptions = () => {
|
||||
const options = [];
|
||||
|
||||
Object.keys(definedOptions).forEach((groupKey) => {
|
||||
const groupOptions = definedOptions[groupKey];
|
||||
groupOptions.forEach((option) => {
|
||||
options.push({ ...option, group: groupKey });
|
||||
});
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
const getDefinedOption = (key, group) => {
|
||||
return definedOptions?.[group]?.find((option) => option.key == key);
|
||||
};
|
||||
|
||||
const isDefinedOptionConfigurable = (key, group) => {
|
||||
const definedOption = getDefinedOption(key, group);
|
||||
return definedOption?.config || false;
|
||||
};
|
||||
|
||||
const entriesAmountDiff = (
|
||||
newEntries,
|
||||
oldEntries,
|
||||
amountAttribute,
|
||||
idAttribute
|
||||
) => {
|
||||
const oldEntriesTable = _.chain(oldEntries)
|
||||
.groupBy(idAttribute)
|
||||
.mapValues((group) => _.sumBy(group, amountAttribute) || 0)
|
||||
.value();
|
||||
|
||||
const newEntriesTable = _.chain(newEntries)
|
||||
.groupBy(idAttribute)
|
||||
.mapValues((group) => _.sumBy(group, amountAttribute) || 0)
|
||||
.mergeWith(oldEntriesTable, (objValue, srcValue) => {
|
||||
return _.isNumber(objValue) ? objValue - srcValue : srcValue * -1;
|
||||
})
|
||||
.value();
|
||||
|
||||
return _.chain(newEntriesTable)
|
||||
.mapValues((value, key) => ({
|
||||
[idAttribute]: key,
|
||||
[amountAttribute]: value,
|
||||
}))
|
||||
.filter((entry) => entry[amountAttribute] != 0)
|
||||
.values()
|
||||
.value();
|
||||
};
|
||||
|
||||
const convertEmptyStringToNull = (value) => {
|
||||
return typeof value === 'string'
|
||||
? value.trim() === ''
|
||||
? null
|
||||
: value
|
||||
: value;
|
||||
};
|
||||
|
||||
const getNegativeFormat = (formatName) => {
|
||||
switch (formatName) {
|
||||
case 'parentheses':
|
||||
return '(%s%v)';
|
||||
case 'mines':
|
||||
return '-%s%v';
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrencySign = (currencyCode) => {
|
||||
return _.get(Currencies, `${currencyCode}.symbol`);
|
||||
};
|
||||
|
||||
const formatNumber = (
|
||||
balance,
|
||||
{
|
||||
precision = 2,
|
||||
divideOn1000 = false,
|
||||
excerptZero = false,
|
||||
negativeFormat = 'mines',
|
||||
thousand = ',',
|
||||
decimal = '.',
|
||||
zeroSign = '',
|
||||
money = true,
|
||||
currencyCode,
|
||||
symbol = '',
|
||||
}
|
||||
) => {
|
||||
const formattedSymbol = getCurrencySign(currencyCode);
|
||||
const negForamt = getNegativeFormat(negativeFormat);
|
||||
const format = '%s%v';
|
||||
|
||||
let formattedBalance = parseFloat(balance);
|
||||
|
||||
if (divideOn1000) {
|
||||
formattedBalance /= 1000;
|
||||
}
|
||||
return accounting.formatMoney(
|
||||
formattedBalance,
|
||||
money ? formattedSymbol : symbol ? symbol : '',
|
||||
precision,
|
||||
thousand,
|
||||
decimal,
|
||||
{
|
||||
pos: format,
|
||||
neg: negForamt,
|
||||
zero: excerptZero ? zeroSign : format,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const isBlank = (value) => {
|
||||
return (_.isEmpty(value) && !_.isNumber(value)) || _.isNaN(value);
|
||||
};
|
||||
|
||||
function defaultToTransform(value, defaultOrTransformedValue, defaultValue) {
|
||||
const _defaultValue =
|
||||
typeof defaultValue === 'undefined'
|
||||
? defaultOrTransformedValue
|
||||
: defaultValue;
|
||||
|
||||
const _transfromedValue =
|
||||
typeof defaultValue === 'undefined' ? value : defaultOrTransformedValue;
|
||||
|
||||
return value == null || value !== value || value === ''
|
||||
? _defaultValue
|
||||
: _transfromedValue;
|
||||
}
|
||||
|
||||
const transformToMap = (objects, key) => {
|
||||
const map = new Map();
|
||||
|
||||
objects.forEach((object) => {
|
||||
map.set(object[key], object);
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const transactionIncrement = (s) => s.replace(/([0-8]|\d?9+)?$/, (e) => ++e);
|
||||
|
||||
const booleanValuesRepresentingTrue: string[] = ['true', '1'];
|
||||
const booleanValuesRepresentingFalse: string[] = ['false', '0'];
|
||||
|
||||
const normalizeValue = (value: any): string =>
|
||||
value?.toString().trim().toLowerCase();
|
||||
|
||||
const booleanValues: string[] = [
|
||||
...booleanValuesRepresentingTrue,
|
||||
...booleanValuesRepresentingFalse,
|
||||
].map((value) => normalizeValue(value));
|
||||
|
||||
export const parseBoolean = <T>(value: any, defaultValue: T): T | boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value; // Retrun early we have nothing to parse.
|
||||
}
|
||||
const normalizedValue = normalizeValue(value);
|
||||
if (isEmpty(value) || booleanValues.indexOf(normalizedValue) === -1) {
|
||||
return defaultValue;
|
||||
}
|
||||
return booleanValuesRepresentingTrue.indexOf(normalizedValue) !== -1;
|
||||
};
|
||||
|
||||
var increment = (n) => {
|
||||
return () => {
|
||||
n += 1;
|
||||
return n;
|
||||
};
|
||||
};
|
||||
|
||||
const transformToMapBy = (collection, key) => {
|
||||
return new Map(Object.entries(_.groupBy(collection, key)));
|
||||
};
|
||||
|
||||
const transformToMapKeyValue = (collection, key) => {
|
||||
return new Map(collection.map((item) => [item[key], item]));
|
||||
};
|
||||
|
||||
const accumSum = (data, callback) => {
|
||||
return data.reduce((acc, _data) => {
|
||||
const amount = callback(_data);
|
||||
return acc + amount;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const mergeObjectsBykey = (object1, object2, key) => {
|
||||
var merged = _.merge(_.keyBy(object1, key), _.keyBy(object2, key));
|
||||
return _.values(merged);
|
||||
};
|
||||
|
||||
function templateRender(filePath, options) {
|
||||
const basePath = path.join(global.__resources_dir, '/views');
|
||||
return pug.renderFile(`${basePath}/${filePath}.pug`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* All passed conditions should pass.
|
||||
* @param condsPairFilters
|
||||
* @returns
|
||||
*/
|
||||
export const allPassedConditionsPass = (condsPairFilters): Function => {
|
||||
const filterCallbacks = condsPairFilters
|
||||
.filter((cond) => cond[0])
|
||||
.map((cond) => cond[1]);
|
||||
|
||||
return R.allPass(filterCallbacks);
|
||||
};
|
||||
|
||||
export const runningAmount = (amount: number) => {
|
||||
let runningBalance = amount;
|
||||
|
||||
return {
|
||||
decrement: (decrement: number) => {
|
||||
runningBalance -= decrement;
|
||||
},
|
||||
increment: (increment: number) => {
|
||||
runningBalance += increment;
|
||||
},
|
||||
amount: () => runningBalance,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatSmsMessage = (message: string, args) => {
|
||||
let formattedMessage = message;
|
||||
|
||||
Object.keys(args).forEach((key) => {
|
||||
const variable = `{${key}}`;
|
||||
const value = _.defaultTo(args[key], '');
|
||||
|
||||
formattedMessage = formattedMessage.replace(
|
||||
new RegExp(variable, 'g'),
|
||||
value
|
||||
);
|
||||
});
|
||||
return formattedMessage;
|
||||
};
|
||||
|
||||
export const parseDate = (date: string) => {
|
||||
return date ? moment(date).utcOffset(0).format('YYYY-MM-DD') : '';
|
||||
};
|
||||
|
||||
const nestedArrayToFlatten = (
|
||||
collection,
|
||||
property = 'children',
|
||||
parseItem = (a, level) => a,
|
||||
level = 1
|
||||
) => {
|
||||
const parseObject = (obj) =>
|
||||
parseItem(
|
||||
{
|
||||
..._.omit(obj, [property]),
|
||||
},
|
||||
level
|
||||
);
|
||||
|
||||
return collection.reduce((items, currentValue, index) => {
|
||||
let localItems = [...items];
|
||||
const parsedItem = parseObject(currentValue, level);
|
||||
localItems.push(parsedItem);
|
||||
|
||||
if (Array.isArray(currentValue[property])) {
|
||||
const flattenArray = nestedArrayToFlatten(
|
||||
currentValue[property],
|
||||
property,
|
||||
parseItem,
|
||||
level + 1
|
||||
);
|
||||
localItems = _.concat(localItems, flattenArray);
|
||||
}
|
||||
return localItems;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const assocDepthLevelToObjectTree = (
|
||||
objects,
|
||||
level = 1,
|
||||
propertyName = 'level'
|
||||
) => {
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
const object = objects[i];
|
||||
object[propertyName] = level;
|
||||
|
||||
if (object.children) {
|
||||
assocDepthLevelToObjectTree(object.children, level + 1, propertyName);
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
};
|
||||
|
||||
const castCommaListEnvVarToArray = (envVar: string): Array<string> => {
|
||||
return envVar ? envVar?.split(',')?.map(_.trim) : [];
|
||||
};
|
||||
|
||||
export const sortObjectKeysAlphabetically = (object) => {
|
||||
return Object.keys(object)
|
||||
.sort()
|
||||
.reduce((objEntries, key) => {
|
||||
objEntries[key] = object[key];
|
||||
return objEntries;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export {
|
||||
templateRender,
|
||||
accumSum,
|
||||
increment,
|
||||
hashPassword,
|
||||
origin,
|
||||
dateRangeCollection,
|
||||
dateRangeFormat,
|
||||
mapValuesDeep,
|
||||
mapKeysDeep,
|
||||
promiseSerial,
|
||||
flatToNestedArray,
|
||||
itemsStartWith,
|
||||
getTotalDeep,
|
||||
applyMixins,
|
||||
formatDateFields,
|
||||
isDefinedOptionConfigurable,
|
||||
getDefinedOption,
|
||||
getDefinedOptions,
|
||||
entriesAmountDiff,
|
||||
convertEmptyStringToNull,
|
||||
formatNumber,
|
||||
isBlank,
|
||||
defaultToTransform,
|
||||
transformToMap,
|
||||
transactionIncrement,
|
||||
transformToMapBy,
|
||||
dateRangeFromToCollection,
|
||||
transformToMapKeyValue,
|
||||
mergeObjectsBykey,
|
||||
nestedArrayToFlatten,
|
||||
assocDepthLevelToObjectTree,
|
||||
castCommaListEnvVarToArray,
|
||||
};
|
||||
5
packages/server/src/utils/is-blank.ts
Normal file
5
packages/server/src/utils/is-blank.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
export const isBlank = (value) => {
|
||||
return (_.isEmpty(value) && !_.isNumber(value)) || _.isNaN(value);
|
||||
};
|
||||
3
packages/server/src/utils/items-start-with.ts
Normal file
3
packages/server/src/utils/items-start-with.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const itemsStartWith = (items: string[], char: string) => {
|
||||
return items.filter((item) => item.indexOf(char) === 0);
|
||||
};
|
||||
14
packages/server/src/utils/moment-mysql.ts
Normal file
14
packages/server/src/utils/moment-mysql.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as moment from 'moment';
|
||||
|
||||
// Extends moment prototype to add a new method to format date to MySQL datetime format.
|
||||
moment.prototype.toMySqlDateTime = function () {
|
||||
return this.format('YYYY-MM-DD HH:mm:ss');
|
||||
};
|
||||
|
||||
declare global {
|
||||
namespace moment {
|
||||
interface Moment {
|
||||
toMySqlDateTime(): string;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { assert } from 'chai';
|
||||
import { multiNumberParse } from './multi-number-parse';
|
||||
|
||||
const correctNumbers = [
|
||||
{ actual: '10.5', expected: 10.5 },
|
||||
{ actual: '10,5', expected: 10.5 },
|
||||
{ actual: '1.235,76', expected: 1235.76 },
|
||||
{ actual: '2,543.56', expected: 2543.56 },
|
||||
{ actual: '10 654.1234', expected: 10654.1234 },
|
||||
{ actual: '2.654$10', expected: 2654.1 },
|
||||
{ actual: '5.435.123,645', expected: 5435123.645 },
|
||||
{ actual: '2,566,765.234', expected: 2566765.234 },
|
||||
{ actual: '2,432,123$23', expected: 2432123.23 },
|
||||
{ actual: '2,45EUR', expected: 2.45 },
|
||||
{ actual: '4.78€', expected: 4.78 },
|
||||
{ actual: '28', expected: 28 },
|
||||
{ actual: '-48', expected: -48 },
|
||||
{ actual: '39USD', expected: 39 },
|
||||
|
||||
// Some negative numbers
|
||||
{ actual: '-2,543.56', expected: -2543.56 },
|
||||
{ actual: '-10 654.1234', expected: -10654.1234 },
|
||||
{ actual: '-2.654$10', expected: -2654.1 },
|
||||
];
|
||||
|
||||
const incorrectNumbers = [
|
||||
'10 345,234.21', // too many different separators
|
||||
'1.123.234,534,234', // impossible to detect where's the decimal separator
|
||||
'10.4,2', // malformed digit groups
|
||||
'1.123.2', // also malformed digit groups
|
||||
];
|
||||
|
||||
describe('Test numbers', () => {
|
||||
correctNumbers.forEach((item) => {
|
||||
it(`"${item.actual}" should return ${item.expected}`, (done) => {
|
||||
const parsed = multiNumberParse(item.actual);
|
||||
assert.isNotNaN(parsed);
|
||||
assert.equal(parsed, item.expected);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
incorrectNumbers.forEach((item) => {
|
||||
it(`"${item}" should return NaN`, (done) => {
|
||||
assert.isNaN(numberParse(item));
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
const validGrouping = (integerPart, sep) =>
|
||||
integerPart.split(sep).reduce((acc, group, idx) => {
|
||||
if (idx > 0) {
|
||||
return acc && group.length === 3;
|
||||
}
|
||||
|
||||
return acc && group.length;
|
||||
}, true);
|
||||
|
||||
export const multiNumberParse = (number: number | string, standardDecSep = '.') => {
|
||||
// if it's a number already, this is going to be easy...
|
||||
if (typeof number === 'number') {
|
||||
return number;
|
||||
}
|
||||
|
||||
// check validity of parameters
|
||||
if (!number || typeof number !== 'string') {
|
||||
throw new TypeError('number must be a string');
|
||||
}
|
||||
|
||||
if (typeof standardDecSep !== 'string' || standardDecSep.length !== 1) {
|
||||
throw new TypeError('standardDecSep must be a single character string');
|
||||
}
|
||||
|
||||
// check if negative
|
||||
const negative = number[0] === '-';
|
||||
|
||||
// strip unnecessary chars
|
||||
const stripped = number
|
||||
// get rid of trailing non-numbers
|
||||
.replace(/[^\d]+$/, '')
|
||||
// get rid of the signal
|
||||
.slice(negative ? 1 : 0);
|
||||
|
||||
// analyze separators
|
||||
const separators = (stripped.match(/[^\d]/g) || []).reduce(
|
||||
(acc, sep, idx) => {
|
||||
const sepChr = `str_${sep.codePointAt(0)}`;
|
||||
const cnt = ((acc[sepChr] || {}).cnt || 0) + 1;
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[sepChr]: {
|
||||
sep,
|
||||
cnt,
|
||||
lastIdx: idx,
|
||||
},
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
// check correctness of separators
|
||||
const sepKeys = Object.keys(separators);
|
||||
|
||||
if (!sepKeys.length) {
|
||||
// no separator, that's easy-peasy
|
||||
return parseInt(stripped, 10) * (negative ? -1 : 1);
|
||||
}
|
||||
|
||||
if (sepKeys.length > 2) {
|
||||
// there's more than 2 separators, that's wrong
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
if (sepKeys.length > 1) {
|
||||
// there's two separators, that's ok by now
|
||||
let sep1 = separators[sepKeys[0]];
|
||||
let sep2 = separators[sepKeys[1]];
|
||||
|
||||
if (sep1.lastIdx > sep2.lastIdx) {
|
||||
// swap
|
||||
[sep1, sep2] = [sep2, sep1];
|
||||
}
|
||||
|
||||
// if more than one separator appears more than once, that's wrong
|
||||
if (sep1.cnt > 1 && sep2.cnt > 1) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
// check if the last separator is the single one
|
||||
if (sep2.cnt > 1) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
// check the groupings
|
||||
const [integerPart] = stripped.split(sep2.sep);
|
||||
|
||||
if (!validGrouping(integerPart, sep1.sep)) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
// ok, we got here! let's handle it
|
||||
return (
|
||||
parseFloat(stripped.split(sep1.sep).join('').replace(sep2.sep, '.')) *
|
||||
(negative ? -1 : 1)
|
||||
);
|
||||
}
|
||||
|
||||
// ok, only one separator, which is nice
|
||||
const sep = separators[sepKeys[0]];
|
||||
|
||||
if (sep.cnt > 1) {
|
||||
// there's more than one separator, which means it's integer
|
||||
// let's check the groupings
|
||||
if (!validGrouping(stripped, sep.sep)) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
// it's valid, let's return an integer
|
||||
return parseInt(stripped.split(sep.sep).join(''), 10) * (negative ? -1 : 1);
|
||||
}
|
||||
|
||||
// just one separator, let's check last group
|
||||
const groups = stripped.split(sep.sep);
|
||||
|
||||
if (groups[groups.length - 1].length === 3) {
|
||||
// ok, we're in ambiguous territory here
|
||||
|
||||
if (sep.sep !== standardDecSep) {
|
||||
// it's an integer
|
||||
return (
|
||||
parseInt(stripped.split(sep.sep).join(''), 10) * (negative ? -1 : 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// well, it looks like it's a simple float
|
||||
return parseFloat(stripped.replace(sep.sep, '.')) * (negative ? -1 : 1);
|
||||
};
|
||||
33
packages/server/src/utils/nested-array-to-flatten.ts
Normal file
33
packages/server/src/utils/nested-array-to-flatten.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { omit, concat } from 'lodash';
|
||||
|
||||
export const nestedArrayToFlatten = (
|
||||
collection,
|
||||
property = 'children',
|
||||
parseItem = (a, level) => a,
|
||||
level = 1,
|
||||
) => {
|
||||
const parseObject = (obj) =>
|
||||
parseItem(
|
||||
{
|
||||
...omit(obj, [property]),
|
||||
},
|
||||
level,
|
||||
);
|
||||
|
||||
return collection.reduce((items, currentValue, index) => {
|
||||
let localItems = [...items];
|
||||
const parsedItem = parseObject(currentValue);
|
||||
localItems.push(parsedItem);
|
||||
|
||||
if (Array.isArray(currentValue[property])) {
|
||||
const flattenArray = nestedArrayToFlatten(
|
||||
currentValue[property],
|
||||
property,
|
||||
parseItem,
|
||||
level + 1,
|
||||
);
|
||||
localItems = concat(localItems, flattenArray);
|
||||
}
|
||||
return localItems;
|
||||
}, []);
|
||||
};
|
||||
23
packages/server/src/utils/parse-boolean.ts
Normal file
23
packages/server/src/utils/parse-boolean.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
const booleanValuesRepresentingTrue: string[] = ['true', '1'];
|
||||
const booleanValuesRepresentingFalse: string[] = ['false', '0'];
|
||||
|
||||
const normalizeValue = (value: any): string =>
|
||||
value?.toString().trim().toLowerCase();
|
||||
|
||||
const booleanValues: string[] = [
|
||||
...booleanValuesRepresentingTrue,
|
||||
...booleanValuesRepresentingFalse,
|
||||
].map((value) => normalizeValue(value));
|
||||
|
||||
export const parseBoolean = <T>(value: any, defaultValue: T): T | boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value; // Retrun early we have nothing to parse.
|
||||
}
|
||||
const normalizedValue = normalizeValue(value);
|
||||
if (isEmpty(value) || booleanValues.indexOf(normalizedValue) === -1) {
|
||||
return defaultValue;
|
||||
}
|
||||
return booleanValuesRepresentingTrue.indexOf(normalizedValue) !== -1;
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export const parseJsonSafe = (value: string) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { get } from 'lodash';
|
||||
import { IColumnMapperMeta, ITableRow } from '@/interfaces';
|
||||
|
||||
export function tableMapper(
|
||||
data: Object[],
|
||||
columns: IColumnMapperMeta[],
|
||||
rowsMeta
|
||||
): ITableRow[] {
|
||||
return data.map((object) => tableRowMapper(object, columns, rowsMeta));
|
||||
}
|
||||
|
||||
function getAccessor(object, accessor) {
|
||||
return typeof accessor === 'function'
|
||||
? accessor(object)
|
||||
: get(object, accessor);
|
||||
}
|
||||
|
||||
export function tableRowMapper(
|
||||
object: Object,
|
||||
columns: IColumnMapperMeta[],
|
||||
rowMeta
|
||||
): ITableRow {
|
||||
const cells = columns.map((column) => ({
|
||||
key: column.key,
|
||||
value: column.value
|
||||
? column.value
|
||||
: getAccessor(object, column.accessor) || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
cells,
|
||||
...rowMeta,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Get inclusive tax amount.
|
||||
* @param {number} amount
|
||||
* @param {number} taxRate
|
||||
* @returns {number}
|
||||
*/
|
||||
export const getInclusiveTaxAmount = (amount: number, taxRate: number) => {
|
||||
return (amount * taxRate) / (100 + taxRate);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get exclusive tax amount.
|
||||
* @param {number} amount
|
||||
* @param {number} taxRate
|
||||
* @returns {number}
|
||||
*/
|
||||
export const getExlusiveTaxAmount = (amount: number, taxRate: number) => {
|
||||
return (amount * taxRate) / 100;
|
||||
};
|
||||
7
packages/server/src/utils/template-render.ts
Normal file
7
packages/server/src/utils/template-render.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import path from 'path';
|
||||
import pug from 'pug';
|
||||
|
||||
export function templateRender(filePath: string, options: Record<string, any>) {
|
||||
const basePath = path.join(global.__resources_dir, '/views');
|
||||
return pug.renderFile(`${basePath}/${filePath}.pug`, options);
|
||||
}
|
||||
1
packages/server/src/utils/transaction-increment.ts
Normal file
1
packages/server/src/utils/transaction-increment.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const transactionIncrement = (s) => s.replace(/([0-8]|\d?9+)?$/, (e) => ++e);
|
||||
@@ -1,36 +0,0 @@
|
||||
import { isObject, upperFirst, camelCase } from 'lodash';
|
||||
import {
|
||||
TransactionTypes,
|
||||
CashflowTransactionTypes,
|
||||
} from '@/data/TransactionTypes';
|
||||
|
||||
/**
|
||||
* Retrieves the formatted type of account transaction.
|
||||
* @param {string} referenceType
|
||||
* @param {string} transactionType
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTransactionTypeLabel = (
|
||||
referenceType: string,
|
||||
transactionType?: string
|
||||
) => {
|
||||
const _referenceType = upperFirst(camelCase(referenceType));
|
||||
const _transactionType = upperFirst(camelCase(transactionType));
|
||||
|
||||
return isObject(TransactionTypes[_referenceType])
|
||||
? TransactionTypes[_referenceType][_transactionType]
|
||||
: TransactionTypes[_referenceType] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the formatted type of cashflow transaction.
|
||||
* @param {string} transactionType
|
||||
* @returns {string¿}
|
||||
*/
|
||||
export const getCashflowTransactionFormattedType = (
|
||||
transactionType: string
|
||||
) => {
|
||||
const _transactionType = upperFirst(camelCase(transactionType));
|
||||
|
||||
return CashflowTransactionTypes[_transactionType] || null;
|
||||
};
|
||||
9
packages/server/src/utils/transform-to-key.ts
Normal file
9
packages/server/src/utils/transform-to-key.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
export const transformToMap = (objects, key) => {
|
||||
const map = new Map();
|
||||
|
||||
objects.forEach((object) => {
|
||||
map.set(object[key], object);
|
||||
});
|
||||
return map;
|
||||
};
|
||||
8
packages/server/src/utils/transform-to-map-by.ts
Normal file
8
packages/server/src/utils/transform-to-map-by.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
export const transformToMapBy = <T>(
|
||||
collection: T[],
|
||||
key: keyof T,
|
||||
): Map<string, T[]> => {
|
||||
return new Map(Object.entries(groupBy(collection, key)));
|
||||
};
|
||||
7
packages/server/src/utils/transform-to-map-key-value.ts
Normal file
7
packages/server/src/utils/transform-to-map-key-value.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const transformToMapKeyValue = <T, K extends string | number>(
|
||||
collection: T[],
|
||||
key: keyof T,
|
||||
): Map<K, T> => {
|
||||
// @ts-ignore
|
||||
return new Map(collection.map((item) => [item[key], item]));
|
||||
};
|
||||
Reference in New Issue
Block a user