Compare commits

...

40 Commits

Author SHA1 Message Date
a.bouhuolia
883c5dcb41 Merge branch 'signup-restrictions' into develop 2023-05-08 00:36:50 +02:00
a.bouhuolia
be10b8934d fix(webapp): change the error code handler 2023-05-08 00:35:44 +02:00
a.bouhuolia
ce38c71fa7 fix(server): should allowed email addresses and domain be irrespective. 2023-05-08 00:35:28 +02:00
Ahmed Bouhuolia
1162fbc7c3 Merge pull request #117 from bigcapitalhq/signup-restrictions
Sign-up restrictions for self-hosted
2023-05-08 00:18:56 +02:00
a.bouhuolia
18b9e25f2b chore: update .env.example 2023-05-07 23:59:41 +02:00
a.bouhuolia
dd26bdc482 feat(webapp): sign-up restrictions 2023-05-07 23:54:42 +02:00
a.bouhuolia
ad3c9ebfe9 feat(server): sign-up restrictions for self-hosted 2023-05-07 17:22:18 +02:00
a.bouhuolia
36611652da fix(webapp): resource meta of vendors list 2023-05-05 15:41:32 +02:00
a.bouhuolia
06c7ee71b4 fix(webapp): display transactions count in cashflow account 2023-05-05 13:54:45 +02:00
Ahmed Bouhuolia
54d3188666 Merge pull request #116 from bigcapitalhq/BIG-427-fix-sending-invite-email
fix(server): sending invite email
2023-05-05 00:30:24 +02:00
a.bouhuolia
3ceb9adda2 fix(server): sending invite email 2023-05-05 00:28:57 +02:00
Ahmed Bouhuolia
1249415054 Merge pull request #115 from bigcapitalhq/BIG-409-some-flag-icons-are-missing
fix(webapp): some flag icons are missing
2023-05-04 21:32:10 +02:00
a.bouhuolia
4d44ce4c7f fix(webapp): some flag icons are missing 2023-05-04 21:29:12 +02:00
Ahmed Bouhuolia
6c96c371c5 Merge pull request #114 from bigcapitalhq/BIG-279-select-specific-accounts-in-general-ledger-does-not-working
`BIG-279` Select specific accounts in general ledger does not working.
2023-05-04 14:29:35 +02:00
a.bouhuolia
6c61a69f10 feat(webapp): handle create item on Accounts select components 2023-05-04 14:24:45 +02:00
a.bouhuolia
981b65349d feat(webapp): allow to create a new account item in accounts list component. 2023-05-03 22:41:54 +02:00
a.bouhuolia
a7d29a31c8 refactor(webapp): all services with new AccountSelect and AccountMultiSelect components. 2023-05-01 00:13:23 +02:00
a.bouhuolia
c1d92b74f0 chore(Select):style the Select button. 2023-04-30 21:13:33 +02:00
a.bouhuolia
6f0f47f38a refactor(webapp): Accounts Select and MultiSelect components 2023-04-30 17:33:15 +02:00
a.bouhuolia
83510cfa70 feat(server): add structure query flat or tree to accounts chart endpoint 2023-04-30 17:24:49 +02:00
a.bouhuolia
903dc0522a chore: add CONTRIBUTING.md file 2023-04-27 01:56:46 +02:00
Ahmed Bouhuolia
eecbcacb90 Merge pull request #112 from bigcapitalhq/BIG-417-fcy-bcy-cashflow-accounts-transactions-api
Add FCY/BCY transactions to the account drawer.
2023-04-20 06:14:25 +02:00
a.bouhuolia
cfbe4cfea0 fix(webapp): FCY/BCY typo 2023-04-20 06:10:25 +02:00
a.bouhuolia
8f039b77e7 fix(server): foreign currency in account transactions transformer 2023-04-19 06:16:21 +02:00
a.bouhuolia
672a1bbb82 feat(webapp): add FCY/BCY switch to the account transactions 2023-04-19 06:15:13 +02:00
Ahmed Bouhuolia
b2f3585047 Merge pull request #111 from bigcapitalhq/improve-webapp-users
refactor(webapp): invite and user form with new blueprintjs-formik components
2023-04-14 03:53:11 +02:00
Ahmed Bouhuolia
e6434ea2d1 Merge pull request #110 from bigcapitalhq/avoid-delete-base-currency
fix(server): prevent delete base currency
2023-04-14 03:51:10 +02:00
Ahmed Bouhuolia
a21d6a37e4 Merge pull request #109 from bigcapitalhq/BIG-419-delete-invited-user
fix(server): prevent deleting last user in the tenant
2023-04-14 03:50:28 +02:00
a.bouhuolia
e9fdffa9d9 refactor(webapp): invite and user form with new blueprintjs-formik components 2023-04-14 03:48:43 +02:00
a.bouhuolia
6bd30abddb fix(server): prevent delete base currency 2023-04-14 03:45:09 +02:00
a.bouhuolia
920c8ea95c feat(server): add user transformer 2023-04-14 03:43:39 +02:00
a.bouhuolia
8de3717587 fix(server): prevent deleting last user in the tenant 2023-04-14 03:41:11 +02:00
Ahmed Bouhuolia
cc863f774a Merge pull request #108 from bigcapitalhq/e2e-init
feat: setup e2e
2023-04-13 02:56:16 +02:00
a.bouhuolia
bcd08284b4 chore: add vercel rewrite 2023-04-13 02:50:55 +02:00
a.bouhuolia
8e8161f207 feat: add playwright base url 2023-04-13 02:38:22 +02:00
a.bouhuolia
7b4b50cf4b feat: setup e2e 2023-04-13 02:31:56 +02:00
Lars Scheibling
bca3e51fdf fix: typo in docker-compose.prod.yml (#107)
MAIL_USERNAME instead of MAIL_USERNAM
2023-04-12 19:29:04 +02:00
Ahmed Bouhuolia
6faa378577 Merge pull request #105 from bigcapitalhq/docker-compose-user-permissions
fix: docker-compose environment values
2023-04-07 18:57:47 +02:00
a.bouhuolia
012b13ad4a add comments 2023-04-07 18:50:45 +02:00
a.bouhuolia
ad8770f12c fix: docker-compose enviroment values 2023-04-07 18:41:03 +02:00
361 changed files with 2126 additions and 5771 deletions

View File

@@ -32,3 +32,8 @@ CONTACT_US_MAIL=support@bigcapital.ly
# Agendash
AGENDASH_AUTH_USER=agendash
AGENDASH_AUTH_PASSWORD=123123
# Sign-up restrictions
SIGNUP_DISABLED=true
SIGNUP_ALLOWED_DOMAINS=
SIGNUP_ALLOWED_EMAILS=

68
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: E2E
on:
push:
branches:
- main
- develop
paths:
- '**.ts'
- '**.tsx'
- '**/tsconfig.json'
- 'yarn.lock'
- '.github/workflows/e2e.yml'
pull_request:
paths:
- '**.ts'
- '**.tsx'
- '**/tsconfig.json'
- 'yarn.lock'
- '.github/workflows/e2e.yml'
defaults:
run:
shell: 'bash'
jobs:
test_setup:
name: Test setup
runs-on: ubuntu-latest
outputs:
preview_url: ${{ steps.waitForVercelPreviewDeployment.outputs.url }}
steps:
- name: Wait for Vercel preview deployment to be ready
uses: patrickedqvist/wait-for-vercel-preview@v1.3.1
id: waitForVercelPreviewDeployment
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 3000
test_e2e:
runs-on: ubuntu-latest
needs: test_setup
name: Playwright tests
timeout-minutes: 15
environment: ${{ vars.ENVIRONMENT_STAGE }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 14 # Need for npm >=7.7
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright with deps
run: npx playwright install --with-deps
- name: Run tests
run: npm run test:e2e
env:
PLAYWRIGHT_TEST_BASE_URL: ${{ needs.test_setup.outputs.preview_url }}
- uses: actions/upload-artifact@v2
if: always()
with:
name: playwright-report
path: test-results/
retention-days: 30

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/
data
.env
.env
test-results/

132
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,132 @@
# Contributing Guidelines
Thank you for considering contributing to our project! We appreciate your interest and welcome any contributions you may have.
Please read through this document before submitting any issues or pull requests to ensure we have all the necessary information to effectively respond to your bug report or contribution.
## Sections
- [General Instructions](#general-instructions)
- [Contribute to Backend](#contribute-to-backend)
- [Contribute to Frontend](#contribute-to-frontend)
- [Other Ways to Contribute](#other-ways-to-contribute)
## General Instructions
## For Pull Request(s)
Contributions via pull requests are much appreciated. Once the approach is agreed upon ✅, make your changes and open a Pull Request(s). Before sending us a pull request, please ensure that,
- Fork the repo on GitHub, clone it on your machine.
- Create a branch with your changes.
- You are working against the latest source on the `develop` branch.
- Modify the source; please focus only on the specific change.
- Ensure local tests pass.
- Commit to your fork using clear commit messages.
- Send us a pull request.
- Pay attention to any automated CI failures reported in the pull request.
- Stay involved in the conversation
⚠️ Please note: If you want to work on an issue, please ask the maintainers to assign the issue to you before starting work on it. This would help us understand who is working on an issue and prevent duplicate work. 🙏🏻
---
## Contribute to Backend
- Clone the `bigcapital` repository and `cd` into `bigcapital` directory.
- Install all npm dependencies of the monorepo, you don't have to change directory to the `backend` package. just hit these command on root directory and it will install dependencies of all packages.
```
npm install
npm run bootstrap
```
- Run all required docker containers in the development, we already configured all containers under `docker-compose.yml`.
```
docker-compose up -d
```
Wait some seconds, and hit `docker-compose ps` and you should see the same result below.
```
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d974edfab9df bigcapital-mysql "docker-entrypoint.s…" 7 seconds ago Up 1 second 0.0.0.0:3306->3306/tcp, 33060/tcp bigcapital-mysql-1
cefa73fe2881 bigcapital-redis "docker-entrypoint.s…" 7 seconds ago Up 1 second 6379/tcp bigcapital-redis-1
1ea059198cb4 bigcapital-mongo "docker-entrypoint.s…" 7 seconds ago Up 1 second 0.0.0.0:27017->27017/tcp bigcapital-mongo-1
```
- There're some CLI commands we should run before running the server like databaase migration, so we need to build the `server` app first.
```
npm run build:server
```
- Run the database migration for system database.
```
node packages/server/build/commands.js system:migrate:latest
```
And you should get something like that.
```
Batch 1 run: 6 migrations
```
- Next, start the webapp application.
```
npm run dev:server
```
**[`^top^`](#)**
----
## Contribute to Frontend
- Clone the `bigcapital` repository and cd into `bigcapital` directory.
```
git clone https://github.com/bigcapital/bigcapital.git && cd bigcaptial
```
- Install all npm dependencies of the monorepo, you don't have to change directory to the `frontend` package. just hit that command and will install all packages across all application.
```
npm install
npm run bootstrap
```
- Next, start the webapp application.
```
npm run dev:webapp
```
**[`^top^`](#)**
---
## Code Review
We welcome constructive criticism and feedback on code submitted by contributors. All feedback should be constructive and respectful, and should focus on the code rather than the contributor. Code review may include suggestions for improvement or changes to the code.
---
## Other Ways to Contribute
There are many other ways to get involved with the community and to participate in this project:
- Use the product, submitting GitHub issues when a problem is found.
- Help code review pull requests and participate in issue threads.
- Submit a new feature request as an issue.
- Help answer questions on forums such as Bigcapital Community Discord Channel.
- Tell others about the project on Twitter, your blog, etc.
**[`^top^`](#)**
Again, Feel free to ping us on [`#contributing`](https://discord.com/invite/c8nPBJafeb) on our Discord community if you need any help on this :)
Thank You!

View File

@@ -40,7 +40,7 @@ services:
environment:
# Mail
- MAIL_HOST=${MAIL_HOST}
- MAIL_USERNAME=${MAIL_USERNAM}
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_PORT=${MAIL_PORT}
- MAIL_SECURE=${MAIL_SECURE}
@@ -72,27 +72,34 @@ services:
- AGENDASH_AUTH_USER=${AGENDASH_AUTH_USER}
- AGENDASH_AUTH_PASSWORD=${AGENDASH_AUTH_PASSWORD}
# Sign-up restrictions
- SIGNUP_DISABLED=${SIGNUP_DISABLED}
- SIGNUP_ALLOWED_DOMAINS=${SIGNUP_ALLOWED_DOMAINS}
- SIGNUP_ALLOWED_EMAILS=${SIGNUP_ALLOWED_EMAILS}
database_migration:
container_name: bigcapital-database-migration
build:
context: ./
dockerfile: docker/migration/Dockerfile
args:
- DB_HOST=mysql
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_CHARSET=${DB_CHARSET}
- SYSTEM_DB_NAME=${SYSTEM_DB_NAME}
environment:
- DB_HOST=mysql
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_CHARSET=${DB_CHARSET}
- SYSTEM_DB_NAME=${SYSTEM_DB_NAME}
depends_on:
- mysql
mysql:
container_name: bigcapital-mysql
build:
context: ./docker/mysql
args:
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
- MYSQL_USER=${DB_NAME}
- MYSQL_PASSWORD=${DB_PASSWORD}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
environment:
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
- MYSQL_USER=${DB_USER}
- MYSQL_PASSWORD=${DB_PASSWORD}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
volumes:
- mysql:/var/lib/mysql
expose:
@@ -127,4 +134,4 @@ volumes:
redis:
name: bigcapital_prod_redis
driver: local
driver: local

View File

@@ -9,11 +9,11 @@ services:
mysql:
build:
context: ./docker/mysql
args:
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
- MYSQL_USER=${DB_NAME}
- MYSQL_PASSWORD=${DB_PASSWORD}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
environment:
- MYSQL_DATABASE=${SYSTEM_DB_NAME}
- MYSQL_USER=${DB_USER}
- MYSQL_PASSWORD=${DB_PASSWORD}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
volumes:
- mysql:/var/lib/mysql
expose:

View File

@@ -1,5 +1,6 @@
FROM mysql:5.7
USER root
ADD my.cnf /etc/mysql/conf.d/my.cnf
ARG MYSQL_DATABASE=default_database
@@ -12,5 +13,14 @@ ENV MYSQL_USER=$MYSQL_USER
ENV MYSQL_PASSWORD=$MYSQL_PASSWORD
ENV MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
# Copy init sql file with env vars and then the script will substitute the variables.
COPY ./init.sql /scripts/init.template.sql
COPY ./docker-entrypoint.sh /docker-entrypoint-initdb.d/docker-initialize.sh
# The scripts in the docker-entrypoint-initdb.d/ directory are executed as
# the mysql user inside the MySQL Docker container.
RUN chown -R mysql:root /docker-entrypoint-initdb.d
RUN chown -R mysql:root /scripts
CMD ["mysqld"]
EXPOSE 3306

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# chmod u+rwx /scripts/init.template.sql
cp /scripts/init.template.sql /scripts/init.sql
# Replace environment variables in SQL files with their values
if [ -n "$MYSQL_USER" ]; then
sed -i "s/{MYSQL_USER}/$MYSQL_USER/g" /scripts/init.sql
fi
if [ -n "$MYSQL_PASSWORD" ]; then
sed -i "s/{MYSQL_PASSWORD}/$MYSQL_PASSWORD/g" /scripts/init.sql
fi
if [ -n "$MYSQL_DATABASE" ]; then
sed -i "s/{MYSQL_DATABASE}/$MYSQL_DATABASE/g" /scripts/init.sql
fi
# Execute SQL file
mysql -u root -p$MYSQL_ROOT_PASSWORD < /scripts/init.sql

2
docker/mysql/init.sql Normal file
View File

@@ -0,0 +1,2 @@
GRANT ALL PRIVILEGES ON *.* TO '{MYSQL_USER}'@'%' IDENTIFIED BY '{MYSQL_PASSWORD}' WITH GRANT OPTION;
FLUSH PRIVILEGES;

View File

@@ -0,0 +1,68 @@
import { test, expect, Page } from '@playwright/test';
let authPage: Page;
test.describe('authentication', () => {
test.beforeAll(async ({ browser }) => {
authPage = await browser.newPage();
});
test.describe('login', () => {
test.beforeAll(async () => {
await authPage.goto('/auth/login');
});
test('should show the login page.', async () => {
await expect(authPage.locator('body')).toContainText(
"Don't have an account? Sign up"
);
});
test('should email and password be required.', async () => {
await authPage.getByRole('button', { name: 'Log in' }).click();
await expect(authPage.locator('form')).toContainText(
'Email is a required field'
);
await expect(authPage.locator('form')).toContainText(
'Password is a required field'
);
});
test('should go to the register page when click on sign up link', async () => {
await authPage.getByRole('link', { name: 'Sign up' }).click();
await expect(authPage.url()).toContain('/auth/register');
});
});
test.describe('register', () => {
test.beforeAll(async () => {
await authPage.goto('/auth/register');
});
test('should first name, last name, email and password be required.', async () => {
await authPage.getByRole('button', { name: 'Register' }).click();
await expect(authPage.locator('form')).toContainText(
'First name is a required field'
);
await expect(authPage.locator('form')).toContainText(
'Last name is a required field'
);
await expect(authPage.locator('form')).toContainText(
'Email is a required field'
);
await expect(authPage.locator('form')).toContainText(
'Password is a required field'
);
});
});
test.describe('reset password', () => {
test.beforeAll(async () => {
await authPage.goto('/auth/send_reset_password');
});
test('should email be required.', async () => {
await authPage.getByRole('button', { name: 'Reset Password' }).click();
await expect(authPage.locator('form')).toContainText(
'Email is a required field'
);
});
});
});

26
package-lock.json generated
View File

@@ -941,6 +941,23 @@
"esquery": "^1.0.1"
}
},
"@playwright/test": {
"version": "1.32.3",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.3.tgz",
"integrity": "sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==",
"requires": {
"@types/node": "*",
"fsevents": "2.3.2",
"playwright-core": "1.32.3"
},
"dependencies": {
"playwright-core": {
"version": "1.32.3",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.3.tgz",
"integrity": "sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg=="
}
}
},
"@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -986,8 +1003,7 @@
"@types/node": {
"version": "18.14.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.6.tgz",
"integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA==",
"dev": true
"integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA=="
},
"@types/normalize-package-data": {
"version": "2.4.1",
@@ -2304,6 +2320,12 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",

View File

@@ -10,6 +10,7 @@
"dev:server": "lerna run dev --scope \"@bigcapital/server\"",
"build:server": "lerna run build --scope \"@bigcapital/server\"",
"serve:server": "lerna run serve --scope \"@bigcapital/server\"",
"test:e2e": "playwright test",
"prepare": "husky install"
},
"workspaces": [
@@ -21,7 +22,8 @@
"@commitlint/config-lerna-scopes": "^17.4.2",
"husky": "^8.0.3",
"lerna": "^6.4.1",
"@commitlint/cli": "^17.4.2"
"@commitlint/cli": "^17.4.2",
"@playwright/test": "^1.32.3"
},
"engines": {
"node": "14.x"
@@ -30,6 +32,5 @@
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"dependencies": {}
}
}

View File

@@ -34,7 +34,11 @@ ARG MAIL_HOST= \
BASE_URL= \
# Agendash
AGENDASH_AUTH_USER=agendash \
AGENDASH_AUTH_PASSWORD=123123
AGENDASH_AUTH_PASSWORD=123123 \
# Sign-up restriction
SIGNUP_DISABLED= \
SIGNUP_ALLOWED_DOMAINS= \
SIGNUP_ALLOWED_EMAILS=
ENV MAIL_HOST=$MAIL_HOST \
MAIL_USERNAME=$MAIL_USERNAME \
@@ -68,7 +72,11 @@ ENV MAIL_HOST=$MAIL_HOST \
# MongoDB
MONGODB_DATABASE_URL=$MONGODB_DATABASE_URL \
# Application
BASE_URL=$BASE_URL
BASE_URL=$BASE_URL \
# Sign-up restriction
SIGNUP_DISABLED=$SIGNUP_DISABLED \
SIGNUP_ALLOWED_DOMAINS=$SIGNUP_ALLOWED_DOMAINS \
SIGNUP_ALLOWED_EMAILS=$SIGNUP_ALLOWED_EMAILS
# Create app directory.
WORKDIR /app

View File

@@ -3,7 +3,12 @@ import { check, param, query } from 'express-validator';
import { Service, Inject } from 'typedi';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import BaseController from '@/api/controllers/BaseController';
import { AbilitySubject, AccountAction, IAccountDTO } from '@/interfaces';
import {
AbilitySubject,
AccountAction,
IAccountDTO,
IAccountsStructureType,
} from '@/interfaces';
import { ServiceError } from '@/exceptions';
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import { DATATYPES_LENGTH } from '@/data/DataTypes';
@@ -172,6 +177,11 @@ export default class AccountsController extends BaseController {
query('inactive_mode').optional().isBoolean().toBoolean(),
query('search_keyword').optional({ nullable: true }).isString().trim(),
query('structure')
.optional()
.isString()
.isIn([IAccountsStructureType.Tree, IAccountsStructureType.Flat]),
];
}
@@ -341,6 +351,7 @@ export default class AccountsController extends BaseController {
sortOrder: 'desc',
columnSortBy: 'created_at',
inactiveMode: false,
structure: IAccountsStructureType.Tree,
...this.matchedQueryData(req),
};

View File

@@ -49,6 +49,7 @@ export default class AuthenticationController extends BaseController {
asyncMiddleware(this.resetPassword.bind(this)),
this.handlerErrors
);
router.get('/meta', asyncMiddleware(this.getAuthMeta.bind(this)));
return router;
}
@@ -207,6 +208,23 @@ export default class AuthenticationController extends BaseController {
}
}
/**
* Retrieves the authentication meta for SPA.
* @param {Request} req
* @param {Response} res
* @param {Function} next
* @returns {Response|void}
*/
private async getAuthMeta(req: Request, res: Response, next: Function) {
try {
const meta = await this.authApplication.getAuthMeta();
return res.status(200).send({ meta });
} catch (error) {
next(error);
}
}
/**
* Handles the service errors.
*/
@@ -247,6 +265,30 @@ export default class AuthenticationController extends BaseController {
errors: [{ type: 'EMAIL.EXISTS', code: 600 }],
});
}
if (error.errorType === 'SIGNUP_RESTRICTED') {
return res.status(400).send({
errors: [
{
type: 'SIGNUP_RESTRICTED',
message:
'Sign-up is restricted no one can sign-up to the system.',
code: 700,
},
],
});
}
if (error.errorType === 'SIGNUP_RESTRICTED_NOT_ALLOWED') {
return res.status(400).send({
errors: [
{
type: 'SIGNUP_RESTRICTED_NOT_ALLOWED',
message:
'Sign-up is restricted the given email address is not allowed to sign-up.',
code: 710,
},
],
});
}
}
next(error);
}

View File

@@ -67,6 +67,7 @@ export default class GeneralLedgerReportController extends BaseFinancialReportCo
try {
const { data, query, meta } =
await this.generalLedgetService.generalLedger(tenantId, filter);
return res.status(200).send({
meta: this.transfromToResponse(meta),
data: this.transfromToResponse(data),

View File

@@ -92,6 +92,7 @@ export default class InviteUsersController extends BaseController {
try {
await this.inviteUsersService.sendInvite(tenantId, sendInviteDTO, user);
return res.status(200).send({
type: 'success',
code: 'INVITE.SENT.SUCCESSFULLY',

View File

@@ -177,7 +177,7 @@ export default class ItemsController extends BaseController {
/**
* Validate list query schema.
*/
get validateListQuerySchema() {
private get validateListQuerySchema() {
return [
query('column_sort_by').optional().trim().escape(),
query('sort_order').optional().isIn(['desc', 'asc']),
@@ -193,32 +193,20 @@ export default class ItemsController extends BaseController {
];
}
/**
* Validate autocomplete list query schema.
*/
get autocompleteQuerySchema() {
return [
query('column_sort_by').optional().trim().escape(),
query('sort_order').optional().isIn(['desc', 'asc']),
query('stringified_filter_roles').optional().isJSON(),
query('limit').optional().isNumeric().toInt(),
query('keyword').optional().isString().trim().escape(),
];
}
/**
* Stores the given item details to the storage.
* @param {Request} req
* @param {Response} res
*/
async newItem(req: Request, res: Response, next: NextFunction) {
private async newItem(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const itemDTO: IItemDTO = this.matchedBodyData(req);
try {
const storedItem = await this.itemsApplication.createItem(tenantId, itemDTO);
const storedItem = await this.itemsApplication.createItem(
tenantId,
itemDTO
);
return res.status(200).send({
id: storedItem.id,
@@ -234,7 +222,7 @@ export default class ItemsController extends BaseController {
* @param {Request} req
* @param {Response} res
*/
async editItem(req: Request, res: Response, next: NextFunction) {
private async editItem(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const itemId: number = req.params.id;
const item: IItemDTO = this.matchedBodyData(req);
@@ -257,7 +245,7 @@ export default class ItemsController extends BaseController {
* @param {Response} res
* @param {NextFunction} next
*/
async activateItem(req: Request, res: Response, next: NextFunction) {
private async activateItem(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const itemId: number = req.params.id;
@@ -279,7 +267,11 @@ export default class ItemsController extends BaseController {
* @param {Response} res
* @param {NextFunction} next
*/
async inactivateItem(req: Request, res: Response, next: NextFunction) {
private async inactivateItem(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const itemId: number = req.params.id;
@@ -300,7 +292,7 @@ export default class ItemsController extends BaseController {
* @param {Request} req
* @param {Response} res
*/
async deleteItem(req: Request, res: Response, next: NextFunction) {
private async deleteItem(req: Request, res: Response, next: NextFunction) {
const itemId: number = req.params.id;
const { tenantId } = req;
@@ -322,7 +314,7 @@ export default class ItemsController extends BaseController {
* @param {Response} res
* @return {Response}
*/
async getItem(req: Request, res: Response, next: NextFunction) {
private async getItem(req: Request, res: Response, next: NextFunction) {
const itemId: number = req.params.id;
const { tenantId } = req;
@@ -342,7 +334,7 @@ export default class ItemsController extends BaseController {
* @param {Request} req
* @param {Response} res
*/
async getItemsList(req: Request, res: Response, next: NextFunction) {
private async getItemsList(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const filter = {

View File

@@ -4,6 +4,7 @@ import moment from 'moment';
global.__root_dir = path.join(__dirname, '..');
global.__resources_dir = path.join(global.__root_dir, 'resources');
global.__locales_dir = path.join(global.__resources_dir, 'locales');
global.__views_dir = path.join(global.__root_dir, 'views');
moment.prototype.toMySqlDateTime = function () {
return this.format('YYYY-MM-DD HH:mm:ss');

View File

@@ -1,5 +1,6 @@
import dotenv from 'dotenv';
import path from 'path';
import { castCommaListEnvVarToArray, parseBoolean } from '@/utils';
dotenv.config();
@@ -146,6 +147,19 @@ module.exports = {
},
},
/**
* Sign-up restrictions
*/
signupRestrictions: {
disabled: parseBoolean<boolean>(process.env.SIGNUP_DISABLED, false),
allowedDomains: castCommaListEnvVarToArray(
process.env.SIGNUP_ALLOWED_DOMAINS
),
allowedEmails: castCommaListEnvVarToArray(
process.env.SIGNUP_ALLOWED_EMAILS
),
},
/**
* Puppeteer remote browserless connection.
*/

View File

@@ -42,6 +42,7 @@ export enum AccountNormal {
export interface IAccountsTransactionsFilter {
accountId?: number;
limit?: number;
}
export interface IAccountTransaction {
@@ -78,9 +79,15 @@ export interface IAccountTransaction {
}
export interface IAccountResponse extends IAccount {}
export enum IAccountsStructureType {
Tree = 'tree',
Flat = 'flat',
}
export interface IAccountsFilter extends IDynamicListFilterDTO {
stringifiedFilterRoles?: string;
onlyInactive: boolean;
structure?: IAccountsStructureType;
}
export interface IAccountType {

View File

@@ -74,4 +74,8 @@ export interface IAuthSendingResetPassword {
export interface IAuthSendedResetPassword {
user: ISystemUser,
token: string;
}
export interface IAuthGetMetaPOJO {
signupDisabled: boolean;
}

View File

@@ -1,6 +1,7 @@
import { AnyObject } from '@casl/ability/dist/types/types';
import { ITenant } from '@/interfaces';
import { Model } from 'objection';
import { Tenant } from '@/system/models';
export interface ISystemUser extends Model {
id: number;
@@ -54,20 +55,52 @@ export interface IUserInvite {
export interface IInviteUserService {
acceptInvite(token: string, inviteUserInput: IInviteUserInput): Promise<void>;
/**
* Re-send user invite.
* @param {number} tenantId -
* @param {string} email -
* @return {Promise<{ invite: IUserInvite }>}
*/
resendInvite(
tenantId: number,
userId: number,
authorizedUser: ISystemUser
): Promise<{
invite: IUserInvite;
user: ITenantUser;
}>;
/**
* Sends invite mail to the given email from the given tenant and user.
* @param {number} tenantId -
* @param {string} email -
* @param {IUser} authorizedUser -
* @return {Promise<IUserInvite>}
*/
sendInvite(
tenantId: number,
email: string,
sendInviteDTO: IUserSendInviteDTO,
authorizedUser: ISystemUser
): Promise<{
invite: IUserInvite;
invitedUser: ITenantUser;
}>;
}
export interface IAcceptInviteUserService {
/**
* Accept the received invite.
* @param {string} token
* @param {IInviteUserInput} inviteUserInput
* @throws {ServiceErrors}
* @returns {Promise<void>}
*/
acceptInvite(token: string, inviteUserDTO: IInviteUserInput): Promise<void>;
/**
* Validate the given invite token.
* @param {string} token - the given token string.
* @throws {ServiceError}
*/
checkInvite(
token: string
): Promise<{ inviteToken: IUserInvite; orgName: object }>;
@@ -121,7 +154,7 @@ export interface IUserInvitedEventPayload {
tenantId: number;
user: ITenantUser;
}
export interface IUserInviteTenantSyncedEventPayload{
export interface IUserInviteTenantSyncedEventPayload {
invite: IUserInvite;
authorizedUser: ISystemUser;
tenantId: number;
@@ -143,10 +176,10 @@ export interface IAcceptInviteEventPayload {
export interface ICheckInviteEventPayload {
inviteToken: IUserInvite;
tenant: ITenant
tenant: Tenant;
}
export interface IUserSendInviteDTO {
email: string;
roleId: number;
}
}

View File

@@ -1,5 +1,6 @@
import { Container, Inject } from 'typedi';
import InviteUserService from '@/services/InviteUsers/AcceptInviteUser';
import SendInviteUsersMailMessage from '@/services/InviteUsers/SendInviteUsersMailMessage';
export default class UserInviteMailJob {
/**
@@ -21,24 +22,17 @@ export default class UserInviteMailJob {
*/
public async handler(job, done: Function): Promise<void> {
const { invite, authorizedUser, tenantId } = job.attrs.data;
const Logger = Container.get('logger');
const inviteUsersService = Container.get(InviteUserService);
Logger.info(`Send invite user mail - started: ${job.attrs.data}`);
const sendInviteMailMessage = Container.get(SendInviteUsersMailMessage);
try {
await inviteUsersService.mailMessages.sendInviteMail(
await sendInviteMailMessage.sendInviteMail(
tenantId,
authorizedUser,
invite
);
Logger.info(`Send invite user mail - finished: ${job.attrs.data}`);
done();
} catch (error) {
Logger.info(
`Send invite user mail - error: ${job.attrs.data}, error: ${error}`
);
console.log(error);
done(error);
}
}

View File

@@ -109,7 +109,7 @@ export default class Mail {
* Retrieve view content from the view directory.
*/
private getViewContent(): string {
const filePath = path.join(global.__root_dir, `../views/${this.view}`);
const filePath = path.join(global.__views_dir, `/${this.view}`);
return fs.readFileSync(filePath, 'utf8');
}
}

View File

@@ -2,6 +2,7 @@ import moment from 'moment';
import * as R from 'ramda';
import { includes, isFunction, isObject, isUndefined, omit } from 'lodash';
import { formatNumber } from 'utils';
import { isArrayLikeObject } from 'lodash/fp';
export class Transformer {
public context: any;
@@ -39,12 +40,33 @@ export class Transformer {
return object;
};
/**
*
* @param object
* @returns
*/
protected preCollectionTransform = (object: any) => {
return object;
};
/**
*
* @param object
* @returns
*/
protected postCollectionTransform = (object: any) => {
return object;
};
/**
*
*/
public work = (object: any) => {
if (Array.isArray(object)) {
return object.map(this.getTransformation);
const preTransformed = this.preCollectionTransform(object);
const transformed = preTransformed.map(this.getTransformation);
return this.postCollectionTransform(transformed);
} else if (isObject(object)) {
return this.getTransformation(object);
}

View File

@@ -22,9 +22,10 @@ import SaleInvoiceAutoIncrementSubscriber from '@/subscribers/SaleInvoices/AutoI
import SaleInvoiceConvertFromEstimateSubscriber from '@/subscribers/SaleInvoices/ConvertFromEstimate';
import PaymentReceiveAutoSerialSubscriber from '@/subscribers/PaymentReceive/AutoSerialIncrement';
import SyncSystemSendInvite from '@/services/InviteUsers/SyncSystemSendInvite';
import InviteSendMainNotification from '@/services/InviteUsers/InviteSendMailNotification';
import InviteSendMainNotification from '@/services/InviteUsers/InviteSendMailNotificationSubscribe';
import SyncTenantAcceptInvite from '@/services/InviteUsers/SyncTenantAcceptInvite';
import SyncTenantUserMutate from '@/services/Users/SyncTenantUserSaved';
import { SyncTenantUserDelete } from '@/services/Users/SyncTenantUserDeleted';
import OrgSyncTenantAdminUserSubscriber from '@/subscribers/Organization/SyncTenantAdminUser';
import OrgBuildSmsNotificationSubscriber from '@/subscribers/Organization/BuildSmsNotification';
import PurgeUserAbilityCache from '@/services/Users/PurgeUserAbilityCache';
@@ -113,6 +114,7 @@ export const susbcribers = () => {
SyncTenantAcceptInvite,
InviteSendMainNotification,
SyncTenantUserMutate,
SyncTenantUserDelete,
OrgSyncTenantAdminUserSubscriber,
OrgBuildSmsNotificationSubscriber,
PurgeUserAbilityCache,

View File

@@ -106,7 +106,7 @@ export default class AccountTransactionTransformer extends Transformer {
* @returns {string}
*/
protected formattedFcCredit(transaction: IAccountTransaction) {
return this.formatMoney(this.fcDebit(transaction), {
return this.formatMoney(this.fcCredit(transaction), {
currencyCode: transaction.currencyCode,
excerptZero: true,
});
@@ -117,7 +117,7 @@ export default class AccountTransactionTransformer extends Transformer {
* @returns {string}
*/
protected formattedFcDebit(transaction: IAccountTransaction) {
return this.formatMoney(this.fcCredit(transaction), {
return this.formatMoney(this.fcDebit(transaction), {
currencyCode: transaction.currencyCode,
excerptZero: true,
});

View File

@@ -1,6 +1,11 @@
import { IAccount } from '@/interfaces';
import { IAccount, IAccountsStructureType } from '@/interfaces';
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils';
import {
assocDepthLevelToObjectTree,
flatToNestedArray,
formatNumber,
nestedArrayToFlatten,
} from 'utils';
export class AccountTransformer extends Transformer {
/**
@@ -8,7 +13,23 @@ export class AccountTransformer extends Transformer {
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['formattedAmount'];
return ['formattedAmount', 'flattenName'];
};
/**
* Retrieves the flatten name with all dependants accounts names.
* @param {IAccount} account -
* @returns {string}
*/
public flattenName = (account: IAccount): string => {
const parentDependantsIds = this.options.accountsGraph.dependantsOf(
account.id
);
const prefixAccounts = parentDependantsIds.map((dependId) => {
const node = this.options.accountsGraph.getNodeData(dependId);
return `${node.name}: `;
});
return `${prefixAccounts}${account.name}`;
};
/**
@@ -17,8 +38,28 @@ export class AccountTransformer extends Transformer {
* @returns {string}
*/
protected formattedAmount = (account: IAccount): string => {
return formatNumber(account.amount, {
currencyCode: account.currencyCode,
return formatNumber(account.amount, { currencyCode: account.currencyCode });
};
/**
* Transformes the accounts collection to flat or nested array.
* @param {IAccount[]}
* @returns {IAccount[]}
*/
protected postCollectionTransform = (accounts: IAccount[]) => {
// Transfom the flatten to accounts tree.
const transformed = flatToNestedArray(accounts, {
id: 'id',
parentId: 'parentAccountId',
});
// Associate `accountLevel` attr to indicate object depth.
const transformed2 = assocDepthLevelToObjectTree(
transformed,
1,
'accountLevel'
);
return this.options.structure === IAccountsStructureType.Flat
? nestedArrayToFlatten(transformed2)
: transformed2;
};
}

View File

@@ -22,15 +22,19 @@ export class GetAccount {
*/
public getAccount = async (tenantId: number, accountId: number) => {
const { Account } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
// Find the given account or throw not found error.
const account = await Account.query().findById(accountId).throwIfNotFound();
const accountsGraph = await accountRepository.getDependencyGraph();
// Transformes the account model to POJO.
const transformed = await this.transformer.transform(
tenantId,
account,
new AccountTransformer()
new AccountTransformer(),
{ accountsGraph }
);
return this.i18nService.i18nApply(
[['accountTypeLabel'], ['accountNormalFormatted']],

View File

@@ -1,6 +1,11 @@
import { Inject, Service } from 'typedi';
import * as R from 'ramda';
import { IAccountsFilter, IAccountResponse, IFilterMeta } from '@/interfaces';
import {
IAccountsFilter,
IAccountResponse,
IFilterMeta,
IAccountsStructureType,
} from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService';
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import { AccountTransformer } from './AccountTransform';
@@ -38,6 +43,7 @@ export class GetAccounts {
filterDTO: IAccountsFilter
): Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }> => {
const { Account } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
// Parses the stringified filter roles.
const filter = this.parseListFilterDTO(filterDTO);
@@ -53,17 +59,16 @@ export class GetAccounts {
dynamicList.buildQuery()(builder);
builder.modify('inactiveMode', filter.inactiveMode);
});
// Retrievs the formatted accounts collection.
const preTransformedAccounts = await this.transformer.transform(
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieves the transformed accounts collection.
const transformedAccounts = await this.transformer.transform(
tenantId,
accounts,
new AccountTransformer()
new AccountTransformer(),
{ accountsGraph, structure: filterDTO.structure }
);
// Transform accounts to nested array.
const transformedAccounts = flatToNestedArray(preTransformedAccounts, {
id: 'id',
parentId: 'parentAccountId',
});
return {
accounts: transformedAccounts,

View File

@@ -1,8 +1,14 @@
import { Service, Inject, Container } from 'typedi';
import { IRegisterDTO, ISystemUser, IPasswordReset } from '@/interfaces';
import {
IRegisterDTO,
ISystemUser,
IPasswordReset,
IAuthGetMetaPOJO,
} from '@/interfaces';
import { AuthSigninService } from './AuthSignin';
import { AuthSignupService } from './AuthSignup';
import { AuthSendResetPassword } from './AuthSendResetPassword';
import { GetAuthMeta } from './GetAuthMeta';
@Service()
export default class AuthenticationApplication {
@@ -15,6 +21,9 @@ export default class AuthenticationApplication {
@Inject()
private authResetPasswordService: AuthSendResetPassword;
@Inject()
private authGetMeta: GetAuthMeta;
/**
* Signin and generates JWT token.
* @throws {ServiceError}
@@ -53,4 +62,12 @@ export default class AuthenticationApplication {
public async resetPassword(token: string, password: string): Promise<void> {
return this.authResetPasswordService.resetPassword(token, password);
}
/**
* Retrieves the authentication meta for SPA.
* @returns {Promise<IAuthGetMetaPOJO>}
*/
public async getAuthMeta(): Promise<IAuthGetMetaPOJO> {
return this.authGetMeta.getAuthMeta();
}
}

View File

@@ -1,4 +1,4 @@
import { omit } from 'lodash';
import { isEmpty, omit } from 'lodash';
import moment from 'moment';
import { ServiceError } from '@/exceptions';
import {
@@ -13,6 +13,7 @@ import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import TenantsManagerService from '../Tenancy/TenantsManager';
import events from '@/subscribers/events';
import { hashPassword } from '@/utils';
import config from '@/config';
export class AuthSignupService {
@Inject()
@@ -33,6 +34,9 @@ export class AuthSignupService {
public async signUp(signupDTO: IRegisterDTO): Promise<ISystemUser> {
const { systemUserRepository } = this.sysRepositories;
// Validates the signup disable restrictions.
await this.validateSignupRestrictions(signupDTO.email);
// Validates the given email uniqiness.
await this.validateEmailUniqiness(signupDTO.email);
@@ -74,4 +78,34 @@ export class AuthSignupService {
throw new ServiceError(ERRORS.EMAIL_EXISTS);
}
}
/**
* Validate sign-up disable restrictions.
* @param {string} email
*/
private async validateSignupRestrictions(email: string) {
// Can't continue if the signup is not disabled.
if (!config.signupRestrictions.disabled) return;
// Validate the allowed email addresses and domains.
if (
!isEmpty(config.signupRestrictions.allowedEmails) ||
!isEmpty(config.signupRestrictions.allowedDomains)
) {
const emailDomain = email.split('@').pop();
const isAllowedEmail =
config.signupRestrictions.allowedEmails.indexOf(email) !== -1;
const isAllowedDomain = config.signupRestrictions.allowedDomains.some(
(domain) => emailDomain === domain
);
if (!isAllowedEmail && !isAllowedDomain) {
throw new ServiceError(ERRORS.SIGNUP_RESTRICTED_NOT_ALLOWED);
}
// Throw error if the signup is disabled with no exceptions.
} else {
throw new ServiceError(ERRORS.SIGNUP_RESTRICTED);
}
}
}

View File

@@ -0,0 +1,16 @@
import { Service } from 'typedi';
import { IAuthGetMetaPOJO } from '@/interfaces';
import config from '@/config';
@Service()
export class GetAuthMeta {
/**
* Retrieves the authentication meta for SPA.
* @returns {Promise<IAuthGetMetaPOJO>}
*/
public async getAuthMeta(): Promise<IAuthGetMetaPOJO> {
return {
signupDisabled: config.signupRestrictions.disabled,
};
}
}

View File

@@ -7,4 +7,6 @@ export const ERRORS = {
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS',
EMAIL_EXISTS: 'EMAIL_EXISTS',
SIGNUP_RESTRICTED_NOT_ALLOWED: 'SIGNUP_RESTRICTED_NOT_ALLOWED',
SIGNUP_RESTRICTED: 'SIGNUP_RESTRICTED',
};

View File

@@ -1,18 +1,15 @@
import { Inject, Service } from 'typedi';
import { uniq } from 'lodash';
import {
ICurrencyEditDTO,
ICurrencyDTO,
ICurrenciesService,
ICurrency,
} from '@/interfaces';
import {
EventDispatcher,
EventDispatcherInterface,
} from 'decorators/eventDispatcher';
import { ServiceError } from '@/exceptions';
import TenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { CurrencyTransformer } from './CurrencyTransformer';
const ERRORS = {
CURRENCY_NOT_FOUND: 'currency_not_found',
@@ -23,14 +20,11 @@ const ERRORS = {
@Service()
export default class CurrenciesService implements ICurrenciesService {
@Inject('logger')
logger: any;
@EventDispatcher()
eventDispatcher: EventDispatcherInterface;
@Inject()
private tenancy: TenancyService;
@Inject()
tenancy: TenancyService;
private transformer: TransformerInjectable;
/**
* Retrieve currency by given currency code or throw not found error.
@@ -105,7 +99,7 @@ export default class CurrenciesService implements ICurrenciesService {
*/
public async newCurrency(tenantId: number, currencyDTO: ICurrencyDTO) {
const { Currency } = this.tenancy.models(tenantId);
// Validate currency code uniquiness.
await this.validateCurrencyCodeUniquiness(
tenantId,
@@ -141,13 +135,15 @@ export default class CurrenciesService implements ICurrenciesService {
* @param {number} tenantId
* @param {string} currencyCode
*/
validateCannotDeleteBaseCurrency(tenantId: number, currencyCode: string) {
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
if (baseCurrency === currencyCode) {
private async validateCannotDeleteBaseCurrency(
tenantId: number,
currencyCode: string
) {
const tenant = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
if (tenant.metadata.baseCurrency === currencyCode) {
throw new ServiceError(ERRORS.CANNOT_DELETE_BASE_CURRENCY);
}
}
@@ -156,7 +152,7 @@ export default class CurrenciesService implements ICurrenciesService {
* Delete the given currency code.
* @param {number} tenantId
* @param {string} currencyCode
* @return {Promise<}
* @return {Promise<void>}
*/
public async deleteCurrency(
tenantId: number,
@@ -180,19 +176,13 @@ export default class CurrenciesService implements ICurrenciesService {
public async listCurrencies(tenantId: number): Promise<ICurrency[]> {
const { Currency } = this.tenancy.models(tenantId);
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({
group: 'organization',
key: 'base_currency',
});
const currencies = await Currency.query().onBuild((query) => {
query.orderBy('createdAt', 'ASC');
});
const formattedCurrencies = currencies.map((currency) => ({
isBaseCurrency: baseCurrency === currency.currencyCode,
...currency,
}));
return formattedCurrencies;
return this.transformer.transform(
tenantId,
currencies,
new CurrencyTransformer()
);
}
}

View File

@@ -0,0 +1,19 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class CurrencyTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['isBaseCurrency'];
};
/**
* Detarmines whether the currency is base currency.
* @returns {boolean}
*/
public isBaseCurrency(currency): boolean {
return this.context.organization.baseCurrency === currency.currencyCode;
}
}

View File

@@ -5,7 +5,7 @@ import { ICashflowAccountTransactionsQuery, IPaginationMeta } from '@/interfaces
@Service()
export default class CashflowAccountTransactionsRepo {
@Inject()
tenancy: HasTenancyService;
private tenancy: HasTenancyService;
/**
* Retrieve the cashflow account transactions.

View File

@@ -12,9 +12,12 @@ import {
} from '@/interfaces';
import { ERRORS } from './constants';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { IAcceptInviteUserService } from '@/interfaces';
@Service()
export default class AcceptInviteUserService {
export default class AcceptInviteUserService
implements IAcceptInviteUserService
{
@Inject()
private eventPublisher: EventPublisher;

View File

@@ -1,14 +1,11 @@
import {
IUserInvitedEventPayload,
IUserInviteTenantSyncedEventPayload,
} from '@/interfaces';
import { IUserInviteTenantSyncedEventPayload } from '@/interfaces';
import events from '@/subscribers/events';
import { Inject, Service } from 'typedi';
@Service()
export default class InviteSendMainNotificationSubscribe {
@Inject('agenda')
agenda: any;
private agenda: any;
/**
* Attaches events with handlers.

View File

@@ -1,12 +1,12 @@
import path from 'path';
import { ISystemUser } from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService';
import Mail from '@/lib/Mail';
import { Service, Container } from 'typedi';
import config from '@/config';
import { Service } from 'typedi';
import { Tenant } from '@/system/models';
import config from '@/config';
@Service()
export default class InviteUsersMailMessages {
export default class SendInviteUsersMailMessage {
/**
* Sends invite mail to the given email.
* @param user
@@ -18,7 +18,7 @@ export default class InviteUsersMailMessages {
.findById(tenantId)
.withGraphFetched('metadata');
const root = __dirname + '/../../../views/images/bigcapital.png';
const root = path.join(global.__views_dir, '/images/bigcapital.png');
const mail = new Mail()
.setSubject(`${fromUser.firstName} has invited you to join a Bigcapital`)

View File

@@ -8,7 +8,7 @@ import { IAcceptInviteEventPayload } from '@/interfaces';
@Service()
export default class SyncTenantAcceptInvite {
@Inject()
tenancy: HasTenancyService;
private tenancy: HasTenancyService;
/**
* Attaches events with handlers.

View File

@@ -3,7 +3,6 @@ import uniqid from 'uniqid';
import moment from 'moment';
import { ServiceError } from '@/exceptions';
import TenancyService from '@/services/Tenancy/TenancyService';
import InviteUsersMailMessages from '@/services/InviteUsers/InviteUsersMailMessages';
import events from '@/subscribers/events';
import {
ISystemUser,
@@ -13,7 +12,6 @@ import {
IUserInvitedEventPayload,
IUserInviteResendEventPayload,
} from '@/interfaces';
import TenantsManagerService from '@/services/Tenancy/TenantsManager';
import { ERRORS } from './constants';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import RolesService from '@/services/Roles/RolesService';
@@ -21,25 +19,13 @@ import RolesService from '@/services/Roles/RolesService';
@Service()
export default class InviteTenantUserService implements IInviteUserService {
@Inject()
eventPublisher: EventPublisher;
private eventPublisher: EventPublisher;
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
private tenancy: TenancyService;
@Inject()
mailMessages: InviteUsersMailMessages;
@Inject('repositories')
sysRepositories: any;
@Inject()
tenantsManager: TenantsManagerService;
@Inject()
rolesService: RolesService;
private rolesService: RolesService;
/**
* Sends invite mail to the given email from the given tenant and user.
@@ -88,19 +74,15 @@ export default class InviteTenantUserService implements IInviteUserService {
/**
* Re-send user invite.
* @param {number} tenantId -
* @param {string} email -
* @param {number} tenantId -
* @param {string} email -
* @return {Promise<{ invite: IUserInvite }>}
*/
public async resendInvite(
tenantId: number,
userId: number,
authorizedUser: ISystemUser
): Promise<{
user: ITenantUser;
}> {
const { User } = this.tenancy.models(tenantId);
): Promise<{ user: ITenantUser }> {
// Retrieve the user by id or throw not found service error.
const user = await this.getUserByIdOrThrowError(tenantId, userId);

View File

@@ -10,18 +10,18 @@ export class RoleTransformer extends Transformer {
};
/**
*
* Retrieves the localized role name if is predefined or stored name.
* @param role
* @returns
* @returns {string}
*/
public name(role) {
return role.predefined ? this.context.i18n.__(role.name) : role.name;
}
/**
*
* Retrieves the localized role description if is predefined or stored description.
* @param role
* @returns
* @returns {string}
*/
public description(role) {
return role.predefined

View File

@@ -0,0 +1,26 @@
import events from '@/subscribers/events';
import { ITenantUserDeletedPayload } from '@/interfaces';
import { SystemUser } from '@/system/models';
export class SyncTenantUserDelete {
/**
* Attaches events with handlers.
* @param bus
*/
public attach(bus) {
bus.subscribe(
events.tenantUser.onDeleted,
this.syncSystemUserOnceUserDeleted
);
}
/**
* Deletes the system user once tenant user be deleted.
* @param {ITenantUserDeletedPayload} payload -
*/
private syncSystemUserOnceUserDeleted = async ({
tenantUser,
}: ITenantUserDeletedPayload) => {
await SystemUser.query().where('id', tenantUser.systemUserId).delete();
};
}

View File

@@ -0,0 +1,50 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class UserTransformer extends Transformer {
/**
* Exclude these attributes from user object.
* @returns {Array}
*/
public excludeAttributes = (): string[] => {
return ['role'];
};
/**
* Includeded attributes.
* @returns {string[]}
*/
public includeAttributes = (): string[] => {
return ['roleName', 'roleDescription', 'roleSlug'];
};
/**
* Retrieves the localized role name if is predefined or stored name.
* @param role
* @returns {string}
*/
public roleName(user) {
return user.role.predefined
? this.context.i18n.__(user.role.name)
: user.role.name;
}
/**
* Retrieves the localized role description if is predefined or stored description.
* @param user
* @returns {string}
*/
public roleDescription(user) {
return user.role.predefined
? this.context.i18n.__(user.role.description)
: user.role.description;
}
/**
* Retrieves the role slug.
* @param user
* @returns {string}
*/
public roleSlug(user) {
return user.role.slug;
}
}

View File

@@ -14,12 +14,11 @@ import RolesService from '@/services/Roles/RolesService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ERRORS } from './constants';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { UserTransformer } from './UserTransformer';
@Service()
export default class UsersService {
@Inject('repositories')
private repositories: any;
@Inject()
private rolesService: RolesService;
@@ -29,6 +28,9 @@ export default class UsersService {
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private transformer: TransformerInjectable;
/**
* Creates a new user.
* @param {number} tenantId - Tenant id.
@@ -91,9 +93,10 @@ export default class UsersService {
// Retrieve user details or throw not found service error.
const tenantUser = await this.getTenantUserOrThrowError(tenantId, userId);
// Validate the delete user should not be the last user.
await this.validateNotLastUserDelete(tenantId);
// Validate the delete user should not be the last active user.
if (tenantUser.isInviteAccepted) {
await this.validateNotLastUserDelete(tenantId);
}
// Delete user from the storage.
await User.query().findById(userId).delete();
@@ -183,7 +186,7 @@ export default class UsersService {
const users = await User.query().withGraphFetched('role');
return users;
return this.transformer.transform(tenantId, users, new UserTransformer());
}
/**
@@ -223,11 +226,13 @@ export default class UsersService {
* @param {number} tenantId
*/
private async validateNotLastUserDelete(tenantId: number) {
const { systemUserRepository } = this.repositories;
const { User } = this.tenancy.models(tenantId);
const usersFound = await systemUserRepository.find({ tenantId });
const inviteAcceptedUsers = await User.query()
.select(['id'])
.whereNotNull('invite_accepted_at');
if (usersFound.length === 1) {
if (inviteAcceptedUsers.length === 1) {
throw new ServiceError(ERRORS.CANNOT_DELETE_LAST_USER);
}
}
@@ -291,9 +296,9 @@ export default class UsersService {
/**
* Validate the authorized user cannot mutate its role.
* @param {ITenantUser} oldTenantUser
* @param {IEditUserDTO} editUserDTO
* @param {ISystemUser} authorizedUser
* @param {ITenantUser} oldTenantUser
* @param {IEditUserDTO} editUserDTO
* @param {ISystemUser} authorizedUser
*/
validateMutateRoleNotAuthorizedUser(
oldTenantUser: ITenantUser,
@@ -307,5 +312,4 @@ export default class UsersService {
throw new ServiceError(ERRORS.CANNOT_AUTHORIZED_USER_MUTATE_ROLE);
}
}
}

View File

@@ -419,6 +419,58 @@ 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 {
templateRender,
accumSum,
@@ -449,4 +501,7 @@ export {
dateRangeFromToCollection,
transformToMapKeyValue,
mergeObjectsBykey,
nestedArrayToFlatten,
assocDepthLevelToObjectTree,
castCommaListEnvVarToArray
};

View File

@@ -1227,9 +1227,9 @@
}
},
"@blueprintjs-formik/select": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@blueprintjs-formik/select/-/select-0.1.5.tgz",
"integrity": "sha512-EqGbuoiS1VrWpzjd39uVhBAmfVobdpgqalGcpODyGA+XAYoft1UU12yzTzrEOwBZpQKiC12UQwekUPspYBsVKA==",
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@blueprintjs-formik/select/-/select-0.2.3.tgz",
"integrity": "sha512-j/zkX0B9wgtoHgK6Z/rlowB7F7zemrAajBU+d3caCoEYMMqwAI0XA++GytqrIhv5fEGjkZ1hkxS9j8eqX8vtjA==",
"requires": {
"lodash.get": "^4.4.2",
"lodash.keyby": "^4.6.0",

View File

@@ -5,7 +5,7 @@
"dependencies": {
"@blueprintjs-formik/core": "^0.2.1",
"@blueprintjs-formik/datetime": "^0.3.4",
"@blueprintjs-formik/select": "^0.1.4",
"@blueprintjs-formik/select": "^0.2.3",
"@blueprintjs/core": "^3.50.2",
"@blueprintjs/datetime": "^3.23.12",
"@blueprintjs/popover2": "^0.11.1",

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Some files were not shown because too many files have changed in this diff Show More