Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
fe90bda6fb | |||
d4b29ff11c | |||
a0a26cfa58 | |||
1610150427 | |||
cff8acd7b1 | |||
0f36d6cbdb | |||
046e28b521 | |||
aba562cb35 | |||
03f2f33344 | |||
a996dd7ed5 | |||
002b883668 | |||
0b06823893 | |||
2dfd779444 | |||
1824413379 | |||
3332ade3d3 | |||
8d2e110e3d | |||
a8fcf09380 | |||
1071f446a8 | |||
03b050d1ac | |||
58eeff7001 | |||
76fb8825e4 | |||
0f9d142afe | |||
bd33855a27 | |||
5329e45e2c | |||
e990ecd12c | |||
a4fcf64f13 | |||
557e3a0676 | |||
2abe399ebd | |||
74fe90906a | |||
4cb9a3b142 | |||
0da9368e0c | |||
d2f8e3d645 | |||
5263fba64e | |||
e3689c48f8 | |||
787efdb33b | |||
e63578d8ce | |||
7cf0cdc4ce | |||
14a0eeab29 | |||
6774c48dff | |||
565947e752 | |||
2cc7c6fa1c | |||
023a7147e2 |
1
.env
1
.env
@ -14,4 +14,3 @@ ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
|||||||
ALPHA_VANTAGE_API_KEY=
|
ALPHA_VANTAGE_API_KEY=
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
PORT=3333
|
|
||||||
|
94
CHANGELOG.md
94
CHANGELOG.md
@ -5,6 +5,100 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.162.0 - 18.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a _Privacy Policy_ page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the header
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ILA` to `ILS`)
|
||||||
|
|
||||||
|
## 1.161.1 - 16.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the vertical hover line to inspect data points in the performance chart on the home page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the landing page
|
||||||
|
- Upgraded `angular` from version `13.3.6` to `14.0.2`
|
||||||
|
- Upgraded `Nx` from version `14.1.4` to `14.3.5`
|
||||||
|
- Upgraded `storybook` from version `6.4.22` to `6.5.9`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the error handling of missing market prices
|
||||||
|
|
||||||
|
## 1.160.0 - 15.06.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the `No data provider has been found` error in the search (regression after `envalid` upgrade to `7.3.1` in Ghostfolio `1.157.0`)
|
||||||
|
|
||||||
|
## 1.159.0 - 15.06.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the default `HOST` to `0.0.0.0`
|
||||||
|
- Refactored the endpoint of the public page (filter by equity)
|
||||||
|
|
||||||
|
## 1.158.1 - 12.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the queue jobs view in the admin control panel by a data dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Exposed the environment variable `HOST`
|
||||||
|
- Decreased the number of attempts of queue jobs from `20` to `10` (fail earlier)
|
||||||
|
- Improved the message for data provider errors in the client
|
||||||
|
- Changed the label from _Balance_ to _Cash Balance_ in the account dialog
|
||||||
|
- Restructured the documentation for self-hosting
|
||||||
|
|
||||||
|
## 1.157.0 - 11.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the queue jobs view in the admin control panel by the number of attempts and the status
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated the historical market data gathering to the queue design pattern
|
||||||
|
- Refreshed the cryptocurrencies list to support more coins by default
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 180 days
|
||||||
|
- Upgraded `chart.js` from version `3.7.0` to `3.8.0`
|
||||||
|
- Upgraded `envalid` from version `7.2.1` to `7.3.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reloaded the accounts of a user after creating, editing or deleting one
|
||||||
|
- Excluded empty items in the activities filter
|
||||||
|
|
||||||
|
## 1.156.0 - 05.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the user id to the account page
|
||||||
|
- Added a new view with jobs of the queue to the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the features page
|
||||||
|
- Restructured the _FIRE_ section
|
||||||
|
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the `docker-compose` files to resolve variables correctly
|
||||||
|
|
||||||
## 1.155.0 - 29.05.2022
|
## 1.155.0 - 29.05.2022
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -22,7 +22,7 @@ RUN node decorate-angular-cli.js
|
|||||||
COPY ./angular.json angular.json
|
COPY ./angular.json angular.json
|
||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.js replace.build.js
|
COPY ./replace.build.js replace.build.js
|
||||||
COPY ./jest.preset.ts jest.preset.ts
|
COPY ./jest.preset.js jest.preset.js
|
||||||
COPY ./jest.config.ts jest.config.ts
|
COPY ./jest.config.ts jest.config.ts
|
||||||
COPY ./tsconfig.base.json tsconfig.base.json
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
|
43
README.md
43
README.md
@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -79,47 +79,50 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Run with Docker (self-hosting)
|
## Self-hosting
|
||||||
|
|
||||||
### Prerequisites
|
### Run with Docker Compose
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
#### Prerequisites
|
||||||
- A local copy of this Git repository (clone)
|
|
||||||
|
|
||||||
### a. Run environment
|
- Basic knowledge of Docker
|
||||||
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
- Local copy of this Git repository (clone)
|
||||||
|
|
||||||
|
#### a. Run environment
|
||||||
|
|
||||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
##### Setup Database
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### b. Build and run environment
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.build.yml build
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker-compose -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
##### Setup Database
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fetch Historical Data
|
#### Fetch Historical Data
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
@ -127,13 +130,13 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
||||||
|
|
||||||
## Run with _Unraid_ (self-hosting)
|
### Run with _Unraid_ (unofficial)
|
||||||
|
|
||||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
@ -149,7 +152,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
|
28
angular.json
28
angular.json
@ -2,6 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"projects": {
|
"projects": {
|
||||||
"api": {
|
"api": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/api",
|
"root": "apps/api",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -56,6 +57,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -189,6 +191,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
"sourceRoot": "apps/client-e2e/src",
|
"sourceRoot": "apps/client-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -211,6 +214,7 @@
|
|||||||
"implicitDependencies": ["client"]
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
"sourceRoot": "libs/common/src",
|
"sourceRoot": "libs/common/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
@ -233,6 +237,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -258,14 +263,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storybook": {
|
"storybook": {
|
||||||
"builder": "@nrwl/storybook:storybook",
|
"builder": "@storybook/angular:start-storybook",
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
|
||||||
"port": 4400,
|
"port": 4400,
|
||||||
"config": {
|
"configDir": "libs/ui/.storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"browserTarget": "ui:build-storybook",
|
||||||
},
|
"compodoc": false
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -274,15 +277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build-storybook": {
|
"build-storybook": {
|
||||||
"builder": "@nrwl/storybook:build",
|
"builder": "@storybook/angular:build-storybook",
|
||||||
"outputs": ["{options.outputPath}"],
|
"outputs": ["{options.outputPath}"],
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
"outputDir": "dist/storybook/ui",
|
||||||
"outputPath": "dist/storybook/ui",
|
"configDir": "libs/ui/.storybook",
|
||||||
"config": {
|
"browserTarget": "ui:build-storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"compodoc": false
|
||||||
},
|
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -294,6 +295,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui-e2e": {
|
"ui-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/ui-e2e",
|
"root": "apps/ui-e2e",
|
||||||
"sourceRoot": "apps/ui-e2e/src",
|
"sourceRoot": "apps/ui-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
|
|
||||||
globals: {
|
globals: {
|
||||||
@ -13,5 +13,5 @@ module.exports = {
|
|||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: '../../jest.preset.ts'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -2,8 +2,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
|||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
@ -12,7 +12,6 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -28,7 +27,6 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { Queue } from 'bull';
|
|
||||||
import { isDate } from 'date-fns';
|
import { isDate } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -39,8 +37,6 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
|
|||||||
export class AdminController {
|
export class AdminController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
|
||||||
private readonly dataGatheringQueue: Queue,
|
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
@ -64,6 +60,24 @@ export class AdminController {
|
|||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gather7Days(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gather7Days();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
@ -82,10 +96,14 @@ export class AdminController {
|
|||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
|
await this.dataGatheringService.addJobToQueue(
|
||||||
dataSource,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
symbol
|
{
|
||||||
});
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
@ -109,10 +127,14 @@ export class AdminController {
|
|||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
|
await this.dataGatheringService.addJobToQueue(
|
||||||
dataSource,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
symbol
|
{
|
||||||
});
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,10 +156,14 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
|
await this.dataGatheringService.addJobToQueue(
|
||||||
dataSource,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
symbol
|
{
|
||||||
});
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
|
@ -11,6 +11,7 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { QueueModule } from './queue/queue.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
QueueModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
|
@ -42,8 +42,6 @@ export class AdminService {
|
|||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
dataGatheringProgress:
|
|
||||||
await this.dataGatheringService.getDataGatheringProgress(),
|
|
||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
@ -60,7 +58,6 @@ export class AdminService {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
@ -161,30 +158,11 @@ export class AdminService {
|
|||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
if (lastDataGathering) {
|
|
||||||
return lastDataGathering;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataGatheringInProgress =
|
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (dataGatheringInProgress) {
|
|
||||||
return 'IN_PROGRESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
|
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { JobStatus } from 'bull';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Controller('admin/queue')
|
||||||
|
export class QueueController {
|
||||||
|
public constructor(
|
||||||
|
private readonly queueService: QueueService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Delete('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.deleteJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<AdminJobs> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.getJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('job/:id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.queueService.deleteJob(id);
|
||||||
|
}
|
||||||
|
}
|
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueueController } from './queue.controller';
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [QueueController],
|
||||||
|
imports: [DataGatheringModule],
|
||||||
|
providers: [QueueService]
|
||||||
|
})
|
||||||
|
export class QueueModule {}
|
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
DATA_GATHERING_QUEUE,
|
||||||
|
QUEUE_JOB_STATUS_LIST
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QueueService {
|
||||||
|
public constructor(
|
||||||
|
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||||
|
private readonly dataGatheringQueue: Queue
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async deleteJob(aId: string) {
|
||||||
|
return (await this.dataGatheringQueue.getJob(aId))?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteJobs({
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
status?: JobStatus[];
|
||||||
|
}) {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(error, 'QueueService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getJobs({
|
||||||
|
limit = 1000,
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
limit?: number;
|
||||||
|
status?: JobStatus[];
|
||||||
|
}): Promise<AdminJobs> {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
const jobsWithState = await Promise.all(
|
||||||
|
jobs.slice(0, limit).map(async (job) => {
|
||||||
|
return {
|
||||||
|
attemptsMade: job.attemptsMade + 1,
|
||||||
|
data: job.data,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs: jobsWithState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +1,6 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
public constructor(
|
public constructor() {}
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly redisCacheService: RedisCacheService
|
|
||||||
) {
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initialize() {
|
|
||||||
this.redisCacheService.reset();
|
|
||||||
|
|
||||||
const isDataGatheringInProgress =
|
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (isDataGatheringInProgress) {
|
|
||||||
// Prepare for automatical data gathering, if hung up in progress state
|
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
30
apps/api/src/app/cache/cache.controller.ts
vendored
30
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,25 +1,39 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {
|
) {}
|
||||||
this.redisCacheService.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
this.redisCacheService.reset();
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.cacheService.flush();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5
apps/api/src/app/cache/cache.module.ts
vendored
5
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,4 +1,3 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
@ -11,7 +10,6 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [CacheService],
|
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -21,7 +19,6 @@ import { CacheController } from './cache.controller';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
]
|
||||||
providers: [CacheService]
|
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
15
apps/api/src/app/cache/cache.service.ts
vendored
15
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,15 +0,0 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CacheService {
|
|
||||||
public constructor(
|
|
||||||
private readonly dataGaterhingService: DataGatheringService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async flush(): Promise<void> {
|
|
||||||
await this.dataGaterhingService.reset();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
@ -106,7 +106,6 @@ export class InfoService {
|
|||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions(),
|
subscriptions: await this.getSubscriptions(),
|
||||||
tags: await this.tagService.get()
|
tags: await this.tagService.get()
|
||||||
@ -215,13 +214,6 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
return lastDataGathering ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
@ -21,7 +19,6 @@ import {
|
|||||||
Type as TypeOfOrder
|
Type as TypeOfOrder
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { Queue } from 'bull';
|
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -32,11 +29,8 @@ import { Activity } from './interfaces/activities.interface';
|
|||||||
export class OrderService {
|
export class OrderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
|
||||||
private readonly dataGatheringQueue: Queue,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
@ -120,10 +114,14 @@ export class OrderService {
|
|||||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
|
await this.dataGatheringService.addJobToQueue(
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
{
|
||||||
});
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
@ -138,8 +136,6 @@ export class OrderService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
@ -330,8 +326,6 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
|
||||||
|
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
delete data.currency;
|
delete data.currency;
|
||||||
|
@ -56,7 +56,7 @@ export class PortfolioCalculator {
|
|||||||
this.currentRateService = currentRateService;
|
this.currentRateService = currentRateService;
|
||||||
this.orders = orders;
|
this.orders = orders;
|
||||||
|
|
||||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
this.orders.sort((a, b) => a.date?.localeCompare(b.date));
|
||||||
}
|
}
|
||||||
|
|
||||||
public computeTransactionPoints() {
|
public computeTransactionPoints() {
|
||||||
@ -125,7 +125,7 @@ export class PortfolioCalculator {
|
|||||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||||
);
|
);
|
||||||
newItems.push(currentTransactionPointItem);
|
newItems.push(currentTransactionPointItem);
|
||||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
newItems.sort((a, b) => a.symbol?.localeCompare(b.symbol));
|
||||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||||
lastTransactionPoint = {
|
lastTransactionPoint = {
|
||||||
date: currentDate,
|
date: currentDate,
|
||||||
|
@ -316,7 +316,9 @@ export class PortfolioController {
|
|||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
access.userId,
|
access.userId,
|
||||||
access.userId
|
access.userId,
|
||||||
|
'1d',
|
||||||
|
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||||
);
|
);
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
@ -325,9 +327,6 @@ export class PortfolioController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.filter((holding) => {
|
|
||||||
return holding.assetClass === 'EQUITY';
|
|
||||||
})
|
|
||||||
.map((portfolioPosition) => {
|
.map((portfolioPosition) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
@ -338,17 +337,15 @@ export class PortfolioController {
|
|||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
currency: portfolioPosition.currency,
|
||||||
currency: portfolioPosition.currency,
|
markets: portfolioPosition.markets,
|
||||||
markets: portfolioPosition.markets,
|
name: portfolioPosition.name,
|
||||||
name: portfolioPosition.name,
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
value: portfolioPosition.value / totalValue
|
||||||
value: portfolioPosition.value / totalValue
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioPublicDetails;
|
return portfolioPublicDetails;
|
||||||
|
@ -273,7 +273,6 @@ export class PortfolioService {
|
|||||||
.filter((timelineItem) => timelineItem !== null)
|
.filter((timelineItem) => timelineItem !== null)
|
||||||
.map((timelineItem) => ({
|
.map((timelineItem) => ({
|
||||||
date: timelineItem.date,
|
date: timelineItem.date,
|
||||||
marketPrice: timelineItem.value,
|
|
||||||
value: timelineItem.netPerformance.toNumber()
|
value: timelineItem.netPerformance.toNumber()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -394,7 +393,7 @@ export class PortfolioService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = item.quantity.mul(item.marketPrice);
|
const value = item.quantity.mul(item.marketPrice ?? 0);
|
||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
|
|
||||||
@ -658,7 +657,7 @@ export class PortfolioService {
|
|||||||
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
||||||
quantity: quantity.toNumber(),
|
quantity: quantity.toNumber(),
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
quantity.mul(marketPrice).toNumber(),
|
quantity.mul(marketPrice ?? 0).toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
import { Prisma, Role, User, ViewMode } from '@prisma/client';
|
||||||
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
import { UserSettings } from './interfaces/user-settings.interface';
|
||||||
@ -185,6 +186,9 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.Account = sortBy(user.Account, (account) => {
|
||||||
|
return account.name;
|
||||||
|
});
|
||||||
user.permissions = currentPermissions.sort();
|
user.permissions = currentPermissions.sort();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
8460
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
8460
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
File diff suppressed because it is too large
Load Diff
4
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
4
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"LUNA1": "Terra",
|
||||||
|
"UNI1": "Uniswap"
|
||||||
|
}
|
@ -20,10 +20,11 @@ async function bootstrap() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
const port = process.env.PORT || 3333;
|
const port = process.env.PORT || 3333;
|
||||||
await app.listen(port, () => {
|
await app.listen(port, host, () => {
|
||||||
logLogo();
|
logLogo();
|
||||||
Logger.log(`Listening at http://localhost:${port}`);
|
Logger.log(`Listening at http://${host}:${port}`);
|
||||||
Logger.log('');
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ export class ConfigurationService {
|
|||||||
BASE_CURRENCY: str({ default: 'USD' }),
|
BASE_CURRENCY: str({ default: 'USD' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
@ -31,12 +31,13 @@ export class ConfigurationService {
|
|||||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||||
GOOGLE_SHEETS_ID: str({ default: '' }),
|
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||||
|
HOST: host({ default: '0.0.0.0' }),
|
||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: host({ default: 'localhost' }),
|
||||||
REDIS_PASSWORD: str({ default: '' }),
|
REDIS_PASSWORD: str({ default: '' }),
|
||||||
REDIS_PORT: port({ default: 6379 }),
|
REDIS_PORT: port({ default: 6379 }),
|
||||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { Queue } from 'bull';
|
|
||||||
|
|
||||||
import { DataGatheringService } from './data-gathering.service';
|
import { DataGatheringService } from './data-gathering.service';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
@ -14,15 +12,13 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService {
|
export class CronService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
|
||||||
private readonly dataGatheringQueue: Queue,
|
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly twitterBotService: TwitterBotService
|
private readonly twitterBotService: TwitterBotService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
public async runEveryMinute() {
|
public async runEveryHour() {
|
||||||
await this.dataGatheringService.gather7Days();
|
await this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,10 +37,14 @@ export class CronService {
|
|||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
|
await this.dataGatheringService.addJobToQueue(
|
||||||
dataSource,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
symbol
|
{
|
||||||
});
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
const cryptocurrencies = require('cryptocurrencies');
|
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
|
||||||
|
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
|
||||||
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CryptocurrencyService {
|
export class CryptocurrencyService {
|
||||||
@ -18,7 +17,7 @@ export class CryptocurrencyService {
|
|||||||
private getCryptocurrencies() {
|
private getCryptocurrencies() {
|
||||||
if (!this.combinedCryptocurrencies) {
|
if (!this.combinedCryptocurrencies) {
|
||||||
this.combinedCryptocurrencies = [
|
this.combinedCryptocurrencies = [
|
||||||
...cryptocurrencies.symbols(),
|
...Object.keys(cryptocurrencies),
|
||||||
...Object.keys(customCryptocurrencies)
|
...Object.keys(customCryptocurrencies)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"1INCH": "1inch",
|
|
||||||
"ALGO": "Algorand",
|
|
||||||
"ATOM": "Cosmos",
|
|
||||||
"AVAX": "Avalanche",
|
|
||||||
"DOT": "Polkadot",
|
|
||||||
"LUNA1": "Terra",
|
|
||||||
"MATIC": "Polygon",
|
|
||||||
"MINA": "Mina Protocol",
|
|
||||||
"RUNE": "THORChain",
|
|
||||||
"SHIB": "Shiba Inu",
|
|
||||||
"SOL": "Solana",
|
|
||||||
"UNI3": "Uniswap"
|
|
||||||
}
|
|
@ -6,6 +6,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|||||||
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
|
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
import { DataGatheringProcessor } from './data-gathering.processor';
|
import { DataGatheringProcessor } from './data-gathering.processor';
|
||||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||||
@ -14,6 +15,10 @@ import { SymbolProfileModule } from './symbol-profile.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
|
limiter: {
|
||||||
|
duration: ms('5 seconds'),
|
||||||
|
max: 1
|
||||||
|
},
|
||||||
name: DATA_GATHERING_QUEUE
|
name: DATA_GATHERING_QUEUE
|
||||||
}),
|
}),
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
|
@ -1,19 +1,34 @@
|
|||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE,
|
DATA_GATHERING_QUEUE,
|
||||||
GATHER_ASSET_PROFILE_PROCESS
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bull';
|
import { Job } from 'bull';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
getDate,
|
||||||
|
getMonth,
|
||||||
|
getYear,
|
||||||
|
isBefore,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
|
|
||||||
import { DataGatheringService } from './data-gathering.service';
|
import { DataGatheringService } from './data-gathering.service';
|
||||||
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@Processor(DATA_GATHERING_QUEUE)
|
@Processor(DATA_GATHERING_QUEUE)
|
||||||
export class DataGatheringProcessor {
|
export class DataGatheringProcessor {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataGatheringService: DataGatheringService
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Process(GATHER_ASSET_PROFILE_PROCESS)
|
@Process(GATHER_ASSET_PROFILE_PROCESS)
|
||||||
@ -21,7 +36,93 @@ export class DataGatheringProcessor {
|
|||||||
try {
|
try {
|
||||||
await this.dataGatheringService.gatherAssetProfiles([job.data]);
|
await this.dataGatheringService.gatherAssetProfiles([job.data]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'DataGatheringProcessor');
|
Logger.error(
|
||||||
|
error,
|
||||||
|
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Process(GATHER_HISTORICAL_MARKET_DATA_PROCESS)
|
||||||
|
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
|
||||||
|
try {
|
||||||
|
const { dataSource, date, symbol } = job.data;
|
||||||
|
|
||||||
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
|
[{ dataSource, symbol }],
|
||||||
|
parseISO(<string>(<unknown>date)),
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentDate = parseISO(<string>(<unknown>date));
|
||||||
|
let lastMarketPrice: number;
|
||||||
|
|
||||||
|
while (
|
||||||
|
isBefore(
|
||||||
|
currentDate,
|
||||||
|
new Date(
|
||||||
|
Date.UTC(
|
||||||
|
getYear(new Date()),
|
||||||
|
getMonth(new Date()),
|
||||||
|
getDate(new Date()),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||||
|
?.marketPrice
|
||||||
|
) {
|
||||||
|
lastMarketPrice =
|
||||||
|
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
||||||
|
?.marketPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMarketPrice) {
|
||||||
|
try {
|
||||||
|
await this.prismaService.marketData.create({
|
||||||
|
data: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: new Date(
|
||||||
|
Date.UTC(
|
||||||
|
getYear(currentDate),
|
||||||
|
getMonth(currentDate),
|
||||||
|
getDate(currentDate),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
marketPrice: lastMarketPrice
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count month one up for iteration
|
||||||
|
currentDate = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
getYear(currentDate),
|
||||||
|
getMonth(currentDate),
|
||||||
|
getDate(currentDate) + 1,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
`Historical market data gathering has been completed for ${symbol} (${dataSource}).`,
|
||||||
|
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
error,
|
||||||
|
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,17 @@
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
PROPERTY_LAST_DATA_GATHERING,
|
DATA_GATHERING_QUEUE,
|
||||||
PROPERTY_LOCKED_DATA_GATHERING
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||||
|
QUEUE_JOB_STATUS_LIST
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import { JobOptions, Queue } from 'bull';
|
||||||
differenceInHours,
|
import { format, subDays } from 'date-fns';
|
||||||
format,
|
|
||||||
getDate,
|
|
||||||
getMonth,
|
|
||||||
getYear,
|
|
||||||
isBefore,
|
|
||||||
subDays
|
|
||||||
} from 'date-fns';
|
|
||||||
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
@ -25,167 +21,48 @@ import { PrismaService } from './prisma.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataGatheringService {
|
export class DataGatheringService {
|
||||||
private dataGatheringProgress: number;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject('DataEnhancers')
|
@Inject('DataEnhancers')
|
||||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
|
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||||
|
private readonly dataGatheringQueue: Queue,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async gather7Days() {
|
public async addJobToQueue(name: string, data: any, options?: JobOptions) {
|
||||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
const hasJob = await this.hasJob(name, data);
|
||||||
|
|
||||||
if (isDataGatheringNeeded) {
|
|
||||||
Logger.log('7d data gathering has been started.', 'DataGatheringService');
|
|
||||||
console.time('data-gathering-7d');
|
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
|
||||||
data: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const symbols = await this.getSymbols7D();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.gatherSymbols(symbols);
|
|
||||||
|
|
||||||
await this.prismaService.property.upsert({
|
|
||||||
create: {
|
|
||||||
key: PROPERTY_LAST_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
},
|
|
||||||
update: { value: new Date().toISOString() },
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
|
||||||
where: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (hasJob) {
|
||||||
Logger.log(
|
Logger.log(
|
||||||
'7d data gathering has been completed.',
|
`Job ${name} with data ${JSON.stringify(data)} already exists.`,
|
||||||
'DataGatheringService'
|
'DataGatheringService'
|
||||||
);
|
);
|
||||||
console.timeEnd('data-gathering-7d');
|
} else {
|
||||||
|
return this.dataGatheringQueue.add(name, data, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async gather7Days() {
|
||||||
|
const dataGatheringItems = await this.getSymbols7D();
|
||||||
|
await this.gatherSymbols(dataGatheringItems);
|
||||||
|
}
|
||||||
|
|
||||||
public async gatherMax() {
|
public async gatherMax() {
|
||||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
const dataGatheringItems = await this.getSymbolsMax();
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
await this.gatherSymbols(dataGatheringItems);
|
||||||
});
|
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
|
||||||
Logger.log(
|
|
||||||
'Max data gathering has been started.',
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.time('data-gathering-max');
|
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
|
||||||
data: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const symbols = await this.getSymbolsMax();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.gatherSymbols(symbols);
|
|
||||||
|
|
||||||
await this.prismaService.property.upsert({
|
|
||||||
create: {
|
|
||||||
key: PROPERTY_LAST_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
},
|
|
||||||
update: { value: new Date().toISOString() },
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
|
||||||
where: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Logger.log(
|
|
||||||
'Max data gathering has been completed.',
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.timeEnd('data-gathering-max');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
return (
|
||||||
|
dataGatheringItem.dataSource === dataSource &&
|
||||||
|
dataGatheringItem.symbol === symbol
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
await this.gatherSymbols(symbols);
|
||||||
if (!isDataGatheringLocked) {
|
|
||||||
Logger.log(
|
|
||||||
`Symbol data gathering for ${symbol} has been started.`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.time('data-gathering-symbol');
|
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
|
||||||
data: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const symbols = (await this.getSymbolsMax()).filter(
|
|
||||||
(dataGatheringItem) => {
|
|
||||||
return (
|
|
||||||
dataGatheringItem.dataSource === dataSource &&
|
|
||||||
dataGatheringItem.symbol === symbol
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.gatherSymbols(symbols);
|
|
||||||
|
|
||||||
await this.prismaService.property.upsert({
|
|
||||||
create: {
|
|
||||||
key: PROPERTY_LAST_DATA_GATHERING,
|
|
||||||
value: new Date().toISOString()
|
|
||||||
},
|
|
||||||
update: { value: new Date().toISOString() },
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
|
||||||
where: {
|
|
||||||
key: PROPERTY_LOCKED_DATA_GATHERING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Logger.log(
|
|
||||||
`Symbol data gathering for ${symbol} has been completed.`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
console.timeEnd('data-gathering-symbol');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbolForDate({
|
public async gatherSymbolForDate({
|
||||||
@ -235,15 +112,6 @@ export class DataGatheringService {
|
|||||||
uniqueAssets = await this.getUniqueAssets();
|
uniqueAssets = await this.getUniqueAssets();
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.log(
|
|
||||||
`Asset profile data gathering has been started for ${uniqueAssets
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
return `${symbol} (${dataSource})`;
|
|
||||||
})
|
|
||||||
.join(',')}.`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
|
|
||||||
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
||||||
uniqueAssets
|
uniqueAssets
|
||||||
);
|
);
|
||||||
@ -334,136 +202,21 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
let hasError = false;
|
|
||||||
let symbolCounter = 0;
|
|
||||||
|
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
if (dataSource === 'MANUAL') {
|
if (dataSource === 'MANUAL') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
|
await this.addJobToQueue(
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
try {
|
{
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
dataSource,
|
||||||
[{ dataSource, symbol }],
|
|
||||||
date,
|
date,
|
||||||
new Date()
|
symbol
|
||||||
);
|
},
|
||||||
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
|
||||||
let currentDate = date;
|
);
|
||||||
let lastMarketPrice: number;
|
|
||||||
|
|
||||||
while (
|
|
||||||
isBefore(
|
|
||||||
currentDate,
|
|
||||||
new Date(
|
|
||||||
Date.UTC(
|
|
||||||
getYear(new Date()),
|
|
||||||
getMonth(new Date()),
|
|
||||||
getDate(new Date()),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
|
||||||
?.marketPrice
|
|
||||||
) {
|
|
||||||
lastMarketPrice =
|
|
||||||
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
|
|
||||||
?.marketPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastMarketPrice) {
|
|
||||||
try {
|
|
||||||
await this.prismaService.marketData.create({
|
|
||||||
data: {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: new Date(
|
|
||||||
Date.UTC(
|
|
||||||
getYear(currentDate),
|
|
||||||
getMonth(currentDate),
|
|
||||||
getDate(currentDate),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
),
|
|
||||||
marketPrice: lastMarketPrice
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
} else {
|
|
||||||
Logger.warn(
|
|
||||||
`Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
|
|
||||||
currentDate,
|
|
||||||
DATE_FORMAT
|
|
||||||
)}.`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count month one up for iteration
|
|
||||||
currentDate = new Date(
|
|
||||||
Date.UTC(
|
|
||||||
getYear(currentDate),
|
|
||||||
getMonth(currentDate),
|
|
||||||
getDate(currentDate) + 1,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
hasError = true;
|
|
||||||
Logger.error(error, 'DataGatheringService');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
|
|
||||||
Logger.log(
|
|
||||||
`Data gathering progress: ${(
|
|
||||||
this.dataGatheringProgress * 100
|
|
||||||
).toFixed(2)}%`,
|
|
||||||
'DataGatheringService'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
symbolCounter += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.exchangeRateDataService.initialize();
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
throw '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDataGatheringProgress() {
|
|
||||||
const isInProgress = await this.getIsInProgress();
|
|
||||||
|
|
||||||
if (isInProgress) {
|
|
||||||
return this.dataGatheringProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getIsInProgress() {
|
|
||||||
return await this.prismaService.property.findUnique({
|
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getLastDataGathering() {
|
|
||||||
const lastDataGathering = await this.prismaService.property.findUnique({
|
|
||||||
where: { key: PROPERTY_LAST_DATA_GATHERING }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lastDataGathering?.value) {
|
|
||||||
return new Date(lastDataGathering.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||||
@ -534,19 +287,6 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reset() {
|
|
||||||
Logger.log('Data gathering has been reset.', 'DataGatheringService');
|
|
||||||
|
|
||||||
await this.prismaService.property.deleteMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ key: PROPERTY_LAST_DATA_GATHERING },
|
|
||||||
{ key: PROPERTY_LOCKED_DATA_GATHERING }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
@ -610,15 +350,17 @@ export class DataGatheringService {
|
|||||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isDataGatheringNeeded() {
|
private async hasJob(name: string, data: any) {
|
||||||
const lastDataGathering = await this.getLastDataGathering();
|
const jobs = await this.dataGatheringQueue.getJobs(
|
||||||
|
QUEUE_JOB_STATUS_LIST.filter((status) => {
|
||||||
|
return status !== 'completed';
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
return jobs.some((job) => {
|
||||||
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
|
return (
|
||||||
|
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const diffInHours = differenceInHours(new Date(), lastDataGathering);
|
|
||||||
|
|
||||||
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
import { format, isAfter, isBefore, parse } from 'date-fns';
|
||||||
|
|
||||||
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -76,9 +76,12 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'AlphaVantageService');
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
return {};
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,10 +72,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
{ [aSymbol]: {} }
|
{ [aSymbol]: {} }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'EodHistoricalDataService');
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
|
@ -87,10 +87,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GhostfolioScraperApiService');
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
|
@ -71,10 +71,13 @@ export class GoogleSheetsService implements DataProviderInterface {
|
|||||||
[symbol]: historicalData
|
[symbol]: historicalData
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GoogleSheetsService');
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
|
@ -90,7 +90,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -131,7 +131,13 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
if (url) {
|
if (url) {
|
||||||
response.url = url;
|
response.url = url;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
|
||||||
|
error.name
|
||||||
|
}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -175,6 +181,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
if (symbol === 'USDGBp') {
|
if (symbol === 'USDGBp') {
|
||||||
// Convert GPB to GBp (pence)
|
// Convert GPB to GBp (pence)
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
|
} else if (symbol === 'USDILA') {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
@ -185,12 +194,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.warn(
|
throw new Error(
|
||||||
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`,
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
'YahooFinanceService'
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,6 +246,18 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
|
} else if (
|
||||||
|
symbol === 'USDILS' &&
|
||||||
|
yahooFinanceSymbols.includes('USDILA=X')
|
||||||
|
) {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
response['USDILA'] = {
|
||||||
|
...response[symbol],
|
||||||
|
currency: 'ILA',
|
||||||
|
marketPrice: new Big(response[symbol].marketPrice)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
BASE_CURRENCY: string;
|
BASE_CURRENCY: string;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
DATA_SOURCE_PRIMARY: string;
|
DATA_SOURCE_PRIMARY: string;
|
||||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
DATA_SOURCES: string[];
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
ENABLE_FEATURE_BLOG: boolean;
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { MarketState } from '@ghostfolio/common/types';
|
import { MarketState } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
@ -32,8 +33,6 @@ export interface IDataProviderResponse {
|
|||||||
marketState: MarketState;
|
marketState: MarketState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDataGatheringItem {
|
export interface IDataGatheringItem extends UniqueAsset {
|
||||||
dataSource: DataSource;
|
|
||||||
date?: Date;
|
date?: Date;
|
||||||
symbol: string;
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'client',
|
displayName: 'client',
|
||||||
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||||
@ -18,5 +18,5 @@ module.exports = {
|
|||||||
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
|
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
|
||||||
preset: '../../jest.preset.ts'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -16,6 +16,13 @@ const routes: Routes = [
|
|||||||
(m) => m.ChangelogPageModule
|
(m) => m.ChangelogPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'about/privacy-policy',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
|
||||||
|
(m) => m.PrivacyPolicyPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
|
||||||
|
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
|
||||||
|
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { JobStatus } from 'bull';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-admin-jobs',
|
||||||
|
styleUrls: ['./admin-jobs.scss'],
|
||||||
|
templateUrl: './admin-jobs.html'
|
||||||
|
})
|
||||||
|
export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||||
|
public defaultDateTimeFormat: string;
|
||||||
|
public filterForm: FormGroup;
|
||||||
|
public jobs: AdminJobs['jobs'] = [];
|
||||||
|
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.defaultDateTimeFormat = getDateWithTimeFormatString(
|
||||||
|
this.user.settings.locale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.filterForm = this.formBuilder.group({
|
||||||
|
status: []
|
||||||
|
});
|
||||||
|
|
||||||
|
this.filterForm.valueChanges
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
const currentFilter = this.filterForm.get('status').value;
|
||||||
|
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fetchJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteJob(aId: string) {
|
||||||
|
this.adminService
|
||||||
|
.deleteJob(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.fetchJobs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteJobs() {
|
||||||
|
const currentFilter = this.filterForm.get('status').value;
|
||||||
|
|
||||||
|
this.adminService
|
||||||
|
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
|
||||||
|
alert(JSON.stringify(aData, null, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
|
||||||
|
alert(JSON.stringify(aStacktrace, null, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchJobs(aStatus?: JobStatus[]) {
|
||||||
|
this.adminService
|
||||||
|
.fetchJobs({ status: aStatus })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ jobs }) => {
|
||||||
|
this.jobs = jobs;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
130
apps/client/src/app/components/admin-jobs/admin-jobs.html
Normal file
130
apps/client/src/app/components/admin-jobs/admin-jobs.html
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
||||||
|
<mat-form-field appearance="outline" class="flex-grow-1">
|
||||||
|
<mat-select formControlName="status">
|
||||||
|
<mat-option></mat-option>
|
||||||
|
<mat-option
|
||||||
|
*ngFor="let statusFilterOption of statusFilterOptions"
|
||||||
|
[value]="statusFilterOption"
|
||||||
|
>{{ statusFilterOption }}</mat-option
|
||||||
|
>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
class="ml-1"
|
||||||
|
color="warn"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onDeleteJobs()"
|
||||||
|
>
|
||||||
|
<span i18n>Delete Jobs</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<table class="gf-table w-100">
|
||||||
|
<thead>
|
||||||
|
<tr class="mat-header-row">
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<ng-container *ngFor="let job of jobs">
|
||||||
|
<tr class="mat-row">
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<span class="align-items-center d-flex">
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="arrow-down-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
|
||||||
|
<span i18n>Asset Profile</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container
|
||||||
|
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
|
||||||
|
>
|
||||||
|
<span i18n>Historical Market Data</span>
|
||||||
|
</ng-container>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ job.attemptsMade }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
{{ job.timestamp | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
{{ job.finishedOn | date: defaultDateTimeFormat }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'active'"
|
||||||
|
name="play-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'completed'"
|
||||||
|
class="text-success"
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'delayed'"
|
||||||
|
name="time-outline"
|
||||||
|
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'failed'"
|
||||||
|
class="text-danger"
|
||||||
|
name="alert-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'paused'"
|
||||||
|
name="pause-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="job.state === 'waiting'"
|
||||||
|
name="cafe-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
|
<button i18n mat-menu-item (click)="onViewData(job.data)">
|
||||||
|
View Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="job.stacktrace?.length <= 0"
|
||||||
|
(click)="onViewStacktrace(job.stacktrace)"
|
||||||
|
>
|
||||||
|
View Stacktrace
|
||||||
|
</button>
|
||||||
|
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
|
||||||
|
Delete Job
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-container>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,22 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
|
import { AdminJobsComponent } from './admin-jobs.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminJobsComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminJobsModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -15,7 +15,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
|||||||
import {
|
import {
|
||||||
differenceInSeconds,
|
differenceInSeconds,
|
||||||
formatDistanceToNowStrict,
|
formatDistanceToNowStrict,
|
||||||
isValid,
|
|
||||||
parseISO
|
parseISO
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
@ -32,14 +31,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public couponDuration: StringValue = '30 days';
|
public couponDuration: StringValue = '30 days';
|
||||||
public coupons: Coupon[];
|
public coupons: Coupon[];
|
||||||
public customCurrencies: string[];
|
public customCurrencies: string[];
|
||||||
public dataGatheringInProgress: boolean;
|
|
||||||
public dataGatheringProgress: number;
|
|
||||||
public exchangeRates: { label1: string; label2: string; value: number }[];
|
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionForSystemMessage: boolean;
|
public hasPermissionForSystemMessage: boolean;
|
||||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public lastDataGathering: string;
|
|
||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
public userCount: number;
|
public userCount: number;
|
||||||
public user: User;
|
public user: User;
|
||||||
@ -128,7 +124,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public onDeleteCoupon(aCouponCode: string) {
|
public onDeleteCoupon(aCouponCode: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this coupon?');
|
const confirmation = confirm('Do you really want to delete this coupon?');
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation === true) {
|
||||||
const coupons = this.coupons.filter((coupon) => {
|
const coupons = this.coupons.filter((coupon) => {
|
||||||
return coupon.code !== aCouponCode;
|
return coupon.code !== aCouponCode;
|
||||||
});
|
});
|
||||||
@ -139,7 +135,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
public onDeleteCurrency(aCurrency: string) {
|
public onDeleteCurrency(aCurrency: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this currency?');
|
const confirmation = confirm('Do you really want to delete this currency?');
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation === true) {
|
||||||
const currencies = this.customCurrencies.filter((currency) => {
|
const currencies = this.customCurrencies.filter((currency) => {
|
||||||
return currency !== aCurrency;
|
return currency !== aCurrency;
|
||||||
});
|
});
|
||||||
@ -152,8 +148,23 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onFlushCache() {
|
public onFlushCache() {
|
||||||
this.cacheService
|
const confirmation = confirm('Do you really want to flush the cache?');
|
||||||
.flush()
|
|
||||||
|
if (confirmation === true) {
|
||||||
|
this.cacheService
|
||||||
|
.flush()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGather7Days() {
|
||||||
|
this.adminService
|
||||||
|
.gather7Days()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -163,20 +174,14 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onGatherMax() {
|
public onGatherMax() {
|
||||||
const confirmation = confirm(
|
this.adminService
|
||||||
'This action may take some time. Do you want to proceed?'
|
.gatherMax()
|
||||||
);
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
if (confirmation === true) {
|
setTimeout(() => {
|
||||||
this.adminService
|
window.location.reload();
|
||||||
.gatherMax()
|
}, 300);
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
});
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onGatherProfileData() {
|
public onGatherProfileData() {
|
||||||
@ -207,39 +212,15 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.dataService
|
this.dataService
|
||||||
.fetchAdminData()
|
.fetchAdminData()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(
|
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
|
||||||
({
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
dataGatheringProgress,
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
exchangeRates,
|
this.exchangeRates = exchangeRates;
|
||||||
lastDataGathering,
|
this.transactionCount = transactionCount;
|
||||||
settings,
|
this.userCount = userCount;
|
||||||
transactionCount,
|
|
||||||
userCount
|
|
||||||
}) => {
|
|
||||||
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
|
||||||
this.dataGatheringProgress = dataGatheringProgress;
|
|
||||||
this.exchangeRates = exchangeRates;
|
|
||||||
|
|
||||||
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
this.changeDetectorRef.markForCheck();
|
||||||
this.lastDataGathering = formatDistanceToNowStrict(
|
});
|
||||||
new Date(lastDataGathering),
|
|
||||||
{
|
|
||||||
addSuffix: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (lastDataGathering === 'IN_PROGRESS') {
|
|
||||||
this.dataGatheringInProgress = true;
|
|
||||||
} else {
|
|
||||||
this.lastDataGathering = 'Starting soon...';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.transactionCount = transactionCount;
|
|
||||||
this.userCount = userCount;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateCouponCode(aLength: number) {
|
private generateCouponCode(aLength: number) {
|
||||||
|
@ -19,37 +19,30 @@
|
|||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Data Gathering</div>
|
<div class="w-50" i18n>Data Gathering</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<div>
|
<div class="overflow-hidden">
|
||||||
<ng-container *ngIf="lastDataGathering"
|
|
||||||
>{{ lastDataGathering }}</ng-container
|
|
||||||
>
|
|
||||||
<ng-container *ngIf="dataGatheringInProgress" i18n
|
|
||||||
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
|
|
||||||
}})</ng-container
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 overflow-hidden">
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<button
|
<button
|
||||||
color="accent"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
(click)="onFlushCache()"
|
(click)="onGather7Days()"
|
||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
class="mr-1"
|
class="mr-1"
|
||||||
name="close-circle-outline"
|
name="cloud-download-outline"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
<span i18n>Reset Data Gathering</span>
|
<span i18n>Gather Recent Data</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<button
|
<button
|
||||||
color="warn"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onGatherMax()"
|
(click)="onGatherMax()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="cloud-download-outline"
|
||||||
|
></ion-icon>
|
||||||
<span i18n>Gather All Data</span>
|
<span i18n>Gather All Data</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -58,7 +51,6 @@
|
|||||||
class="mb-2 mr-2"
|
class="mb-2 mr-2"
|
||||||
color="accent"
|
color="accent"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onGatherProfileData()"
|
(click)="onGatherProfileData()"
|
||||||
>
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
@ -97,7 +89,6 @@
|
|||||||
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
*ngIf="customCurrencies.includes(exchangeRate.label2)"
|
||||||
class="mini-icon mx-1 no-min-width px-2"
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onDeleteCurrency(exchangeRate.label2)"
|
(click)="onDeleteCurrency(exchangeRate.label2)"
|
||||||
>
|
>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<ion-icon name="trash-outline"></ion-icon>
|
||||||
@ -109,7 +100,6 @@
|
|||||||
<button
|
<button
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onAddCurrency()"
|
(click)="onAddCurrency()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
||||||
@ -126,7 +116,6 @@
|
|||||||
<button
|
<button
|
||||||
class="mini-icon mx-1 no-min-width px-2"
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onDeleteSystemMessage()"
|
(click)="onDeleteSystemMessage()"
|
||||||
>
|
>
|
||||||
<ion-icon name="trash-outline"></ion-icon>
|
<ion-icon name="trash-outline"></ion-icon>
|
||||||
@ -197,6 +186,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Housekeeping</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<button color="warn" mat-flat-button (click)="onFlushCache()">
|
||||||
|
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon>
|
||||||
|
<span i18n>Flush Cache</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -66,7 +66,9 @@
|
|||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionForSubscription"
|
*ngIf="
|
||||||
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
|
"
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
@ -203,7 +205,9 @@
|
|||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionForSubscription"
|
*ngIf="
|
||||||
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
|
"
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
@ -229,13 +233,7 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo
|
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
|
||||||
[hideName]="
|
|
||||||
!currentRoute ||
|
|
||||||
currentRoute === 'register' ||
|
|
||||||
currentRoute === 'start'
|
|
||||||
"
|
|
||||||
></gf-logo>
|
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
|
@ -25,7 +25,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
public historicalData: HistoricalDataItem[];
|
public historicalData: HistoricalDataItem[];
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
public readonly numberOfDays = 90;
|
public readonly numberOfDays = 180;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
@ -4,7 +4,9 @@
|
|||||||
<div class="row w-100">
|
<div class="row w-100">
|
||||||
<div class="chart-container col">
|
<div class="chart-container col">
|
||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
|
class="position-absolute"
|
||||||
symbol="Performance"
|
symbol="Performance"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||||
|
@ -25,10 +25,8 @@
|
|||||||
gf-line-chart {
|
gf-line-chart {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: -1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -81,9 +81,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onShowErrors() {
|
public onShowErrors() {
|
||||||
const errorMessageParts = this.errors.map((error) => {
|
const errorMessageParts = ['Data Provider Errors for'];
|
||||||
return `${error.symbol} (${error.dataSource})`;
|
|
||||||
});
|
for (const error of this.errors) {
|
||||||
|
errorMessageParts.push(`${error.symbol} (${error.dataSource})`);
|
||||||
|
}
|
||||||
|
|
||||||
alert(errorMessageParts.join('\n'));
|
alert(errorMessageParts.join('\n'));
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ export class ToggleComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
@Output() change = new EventEmitter<Pick<ToggleOption, 'value'>>();
|
@Output() change = new EventEmitter<Pick<ToggleOption, 'value'>>();
|
||||||
|
|
||||||
public option = new FormControl();
|
public option = new FormControl<string>(undefined);
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
private static PUBLIC_PAGE_ROUTES = [
|
private static PUBLIC_PAGE_ROUTES = [
|
||||||
'/about',
|
'/about',
|
||||||
'/about/changelog',
|
'/about/changelog',
|
||||||
|
'/about/privacy-policy',
|
||||||
'/blog',
|
'/blog',
|
||||||
'/de/blog',
|
'/de/blog',
|
||||||
'/en/blog',
|
'/en/blog',
|
||||||
|
@ -4,11 +4,11 @@
|
|||||||
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
|
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
|
||||||
<div class="about-container">
|
<div class="about-container">
|
||||||
<p>
|
<p>
|
||||||
<strong>Ghostfolio</strong> is a lightweight wealth management
|
Ghostfolio is a lightweight wealth management application for
|
||||||
application for individuals to keep track of stocks, ETFs or
|
individuals to keep track of stocks, ETFs or cryptocurrencies and make
|
||||||
cryptocurrencies and make solid, data-driven investment decisions. The
|
solid, data-driven investment decisions. The source code is fully
|
||||||
source code is fully available as open source software (OSS). The
|
available as open source software (OSS). The project has been
|
||||||
project has been initiated by
|
initiated by
|
||||||
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
|
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
|
||||||
>Thomas Kaul</a
|
>Thomas Kaul</a
|
||||||
>
|
>
|
||||||
@ -174,8 +174,8 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div
|
<div
|
||||||
class="col-md-6 col-xs-12 my-2"
|
class="col-md-4 col-xs-12 my-2"
|
||||||
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
|
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-2 w-100"
|
||||||
@ -186,7 +186,17 @@
|
|||||||
>Changelog & License</a
|
>Changelog & License</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
|
<div *ngIf="hasPermissionForSubscription" class="col-md-4 col-xs-12 my-2">
|
||||||
|
<a
|
||||||
|
class="py-2 w-100"
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-stroked-button
|
||||||
|
[routerLink]="['/about', 'privacy-policy']"
|
||||||
|
>Privacy Policy</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="hasPermissionForBlog" class="col-md-4 col-xs-12 my-2">
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-2 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: '', component: PrivacyPolicyPageComponent, canActivate: [AuthGuard] }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
exports: [RouterModule],
|
||||||
|
imports: [RouterModule.forChild(routes)]
|
||||||
|
})
|
||||||
|
export class PrivacyPolicyPageRoutingModule {}
|
@ -0,0 +1,22 @@
|
|||||||
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'page' },
|
||||||
|
selector: 'gf-privacy-policy-page',
|
||||||
|
styleUrls: ['./privacy-policy-page.scss'],
|
||||||
|
templateUrl: './privacy-policy-page.html'
|
||||||
|
})
|
||||||
|
export class PrivacyPolicyPageComponent implements OnDestroy {
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="mb-5 row">
|
||||||
|
<div class="col">
|
||||||
|
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
|
||||||
|
<markdown [src]="'assets/privacy-policy.md'"></markdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,17 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MarkdownModule } from 'ngx-markdown';
|
||||||
|
|
||||||
|
import { PrivacyPolicyPageRoutingModule } from './privacy-policy-page-routing.module';
|
||||||
|
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [PrivacyPolicyPageComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MarkdownModule.forChild(),
|
||||||
|
PrivacyPolicyPageRoutingModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class PrivacyPolicyPageModule {}
|
@ -0,0 +1,21 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
markdown {
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(var(--palette-primary-300), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -16,7 +16,9 @@
|
|||||||
<div class="pr-1 w-50" i18n>Membership</div>
|
<div class="pr-1 w-50" i18n>Membership</div>
|
||||||
<div class="pl-1 w-50">
|
<div class="pl-1 w-50">
|
||||||
<div class="align-items-center d-flex mb-1">
|
<div class="align-items-center d-flex mb-1">
|
||||||
{{ user?.subscription?.type }}
|
<a [routerLink]="['/pricing']"
|
||||||
|
>{{ user?.subscription?.type }}</a
|
||||||
|
>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
*ngIf="user?.subscription?.type === 'Premium'"
|
*ngIf="user?.subscription?.type === 'Premium'"
|
||||||
class="ml-1 text-muted"
|
class="ml-1 text-muted"
|
||||||
@ -169,6 +171,10 @@
|
|||||||
></mat-slide-toggle>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="align-items-center d-flex mt-4 py-1">
|
||||||
|
<div class="pr-1 w-50" i18n>ID</div>
|
||||||
|
<div class="pl-1 w-50">{{ user?.id }}</div>
|
||||||
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,15 +2,6 @@
|
|||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgba(var(--palette-primary-500), 1);
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: rgba(var(--palette-primary-300), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gf-access-table {
|
gf-access-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
|
@ -133,6 +133,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
this.fetchAccounts();
|
this.fetchAccounts();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -179,6 +184,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
this.fetchAccounts();
|
this.fetchAccounts();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -220,6 +230,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
this.userService
|
||||||
|
.get(true)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
this.fetchAccounts();
|
this.fetchAccounts();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Balance</mat-label>
|
<mat-label i18n>Cash Balance</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
name="balance"
|
name="balance"
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AdminJobsComponent } from '@ghostfolio/client/components/admin-jobs/admin-jobs.component';
|
||||||
import { AdminMarketDataComponent } from '@ghostfolio/client/components/admin-market-data/admin-market-data.component';
|
import { AdminMarketDataComponent } from '@ghostfolio/client/components/admin-market-data/admin-market-data.component';
|
||||||
import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-overview/admin-overview.component';
|
import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-overview/admin-overview.component';
|
||||||
import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component';
|
import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component';
|
||||||
@ -14,6 +15,7 @@ const routes: Routes = [
|
|||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
||||||
|
{ path: 'jobs', component: AdminJobsComponent },
|
||||||
{ path: 'market-data', component: AdminMarketDataComponent },
|
{ path: 'market-data', component: AdminMarketDataComponent },
|
||||||
{ path: 'overview', component: AdminOverviewComponent },
|
{ path: 'overview', component: AdminOverviewComponent },
|
||||||
{ path: 'users', component: AdminUsersComponent }
|
{ path: 'users', component: AdminUsersComponent }
|
||||||
|
@ -5,7 +5,8 @@
|
|||||||
*ngFor="let link of [
|
*ngFor="let link of [
|
||||||
{ iconName: 'reader-outline', path: 'overview' },
|
{ iconName: 'reader-outline', path: 'overview' },
|
||||||
{ iconName: 'people-outline', path: 'users' },
|
{ iconName: 'people-outline', path: 'users' },
|
||||||
{ iconName: 'server-outline', path: 'market-data' }
|
{ iconName: 'server-outline', path: 'market-data' },
|
||||||
|
{ iconName: 'flash-outline', path: 'jobs' }
|
||||||
]"
|
]"
|
||||||
#rla="routerLinkActive"
|
#rla="routerLinkActive"
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
|
@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
|
import { GfAdminJobsModule } from '@ghostfolio/client/components/admin-jobs/admin-jobs.module';
|
||||||
import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module';
|
import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module';
|
||||||
import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
|
import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
|
||||||
import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
|
import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
|
||||||
@ -19,6 +20,7 @@ import { AdminPageComponent } from './admin-page.component';
|
|||||||
imports: [
|
imports: [
|
||||||
AdminPageRoutingModule,
|
AdminPageRoutingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfAdminJobsModule,
|
||||||
GfAdminMarketDataModule,
|
GfAdminMarketDataModule,
|
||||||
GfAdminOverviewModule,
|
GfAdminOverviewModule,
|
||||||
GfAdminUsersModule,
|
GfAdminUsersModule,
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
padding-bottom: constant(safe-area-inset-bottom);
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
|
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
|
gf-admin-jobs,
|
||||||
gf-admin-market-data,
|
gf-admin-market-data,
|
||||||
gf-admin-overview,
|
gf-admin-overview,
|
||||||
gf-admin-users {
|
gf-admin-users {
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<div class="h6 m-0 text-truncate">
|
<div class="h6 m-0 text-truncate">
|
||||||
First months in Open Source
|
First months in Open Source
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex text-muted">05.01.2021</div>
|
<div class="d-flex text-muted">05.01.2022</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="align-items-center d-flex">
|
<div class="align-items-center d-flex">
|
||||||
<ion-icon
|
<ion-icon
|
||||||
|
@ -4,14 +4,12 @@
|
|||||||
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
||||||
Features
|
Features
|
||||||
</h3>
|
</h3>
|
||||||
<mat-card class="mb-4">
|
<div class="mb-4">
|
||||||
<mat-card-content>
|
<p>
|
||||||
<p>
|
Check out the numerous features of <strong>Ghostfolio</strong> to
|
||||||
Check out the numerous features of <strong>Ghostfolio</strong> to
|
manage your wealth.
|
||||||
manage your wealth.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-12 col-md-4 mb-3">
|
<div class="col-xs-12 col-md-4 mb-3">
|
||||||
<mat-card class="d-flex flex-column h-100">
|
<mat-card class="d-flex flex-column h-100">
|
||||||
|
@ -1,19 +1,28 @@
|
|||||||
<div class="intro-container mb-5">
|
<div class="container">
|
||||||
<div class="intro-inner-container mx-auto">
|
<div class="row">
|
||||||
<div class="h-100 intro w-100"></div>
|
<div class="col text-center">
|
||||||
|
<h1 class="font-weight-bold intro my-5" i18n>
|
||||||
|
Manage your wealth like a boss
|
||||||
|
</h1>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
|
||||||
|
target="_blank"
|
||||||
|
title="Watch the Ghostfol.io Trailer on YouTube"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Ghostfol.io Trailer"
|
||||||
|
class="rounded video"
|
||||||
|
src="./assets/images/video-preview.jpg"
|
||||||
|
style="max-width: 100%; width: 40rem"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
|
||||||
<div
|
|
||||||
class="align-items-center d-flex flex-column justify-content-center w-100"
|
|
||||||
>
|
|
||||||
<gf-logo size="large"></gf-logo>
|
|
||||||
<p class="lead m-0">Wealth Management Software</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="button-container row">
|
<div class="button-container row">
|
||||||
<div class="align-items-center col d-flex justify-content-center">
|
<div class="align-items-center col d-flex justify-content-center">
|
||||||
<div class="py-5 text-center">
|
<div class="py-5 text-center">
|
||||||
@ -43,25 +52,12 @@
|
|||||||
<div class="row my-5">
|
<div class="row my-5">
|
||||||
<div class="col text-center">
|
<div class="col text-center">
|
||||||
<h2 class="h4 mb-1 text-center">
|
<h2 class="h4 mb-1 text-center">
|
||||||
Protect your <strong>wealth</strong>. Refine your
|
Protect your <strong>assets</strong>. Refine your
|
||||||
<strong>personal investment strategy</strong>.
|
<strong>personal investment strategy</strong>.
|
||||||
</h2>
|
</h2>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
<strong>Ghostfolio</strong> empowers busy people to keep track of
|
Ghostfolio empowers busy people to keep track of stocks, ETFs or
|
||||||
stocks, ETFs or cryptocurrencies and make solid, data-driven investment
|
cryptocurrencies and make solid, data-driven investment decisions.
|
||||||
decisions.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
|
|
||||||
title="Watch the Ghostfol.io Trailer on YouTube"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Ghostfol.io Trailer"
|
|
||||||
src="./assets/images/video-preview.jpg"
|
|
||||||
style="max-width: 100%; width: 40rem"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -198,3 +194,19 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-block row">
|
||||||
|
<div class="outro-inner-container mx-auto">
|
||||||
|
<div class="h-100 w-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div
|
||||||
|
class="align-items-center d-flex flex-column justify-content-center w-100"
|
||||||
|
>
|
||||||
|
<gf-logo size="medium"></gf-logo>
|
||||||
|
<div>Wealth Management Software</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
@ -13,19 +15,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-container {
|
.intro {
|
||||||
margin-top: -5rem;
|
font-size: 4vw;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
.intro-inner-container {
|
@media (max-width: 575.98px) {
|
||||||
aspect-ratio: 16 / 9;
|
font-size: 10vw;
|
||||||
max-height: 66vh;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.intro {
|
.outro-inner-container {
|
||||||
background-image: url('/assets/intro.jpg');
|
aspect-ratio: 16 / 9;
|
||||||
background-position: top left;
|
max-height: 66vh;
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: contain;
|
div {
|
||||||
}
|
background-image: url('/assets/intro.jpg');
|
||||||
|
background-position: top left;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
border: 1px solid rgba(var(--dark-dividers));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--palette-primary-500), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,9 +52,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro-container {
|
.outro-inner-container {
|
||||||
.intro {
|
div {
|
||||||
background-image: url('/assets/intro-dark.jpg') !important;
|
background-image: url('/assets/intro-dark.jpg') !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
border-color: rgba(var(--light-dividers));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,68 +1,68 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row mb-5">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
|
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
|
||||||
<div class="mb-5">
|
<div>
|
||||||
<h4 i18n>4% Rule</h4>
|
<h4 class="mb-3" i18n>Calculator</h4>
|
||||||
<div *ngIf="isLoading">
|
<gf-fire-calculator
|
||||||
<ngx-skeleton-loader
|
[currency]="user?.settings?.baseCurrency"
|
||||||
animation="pulse"
|
[deviceType]="deviceType"
|
||||||
class="my-1"
|
[fireWealth]="fireWealth?.toNumber()"
|
||||||
[theme]="{
|
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
|
||||||
height: '1rem',
|
[locale]="user?.settings?.locale"
|
||||||
width: '100%'
|
[savingsRate]="user?.settings?.savingsRate"
|
||||||
}"
|
(savingsRateChanged)="onSavingsRateChange($event)"
|
||||||
></ngx-skeleton-loader>
|
></gf-fire-calculator>
|
||||||
<ngx-skeleton-loader
|
|
||||||
animation="pulse"
|
|
||||||
[theme]="{
|
|
||||||
height: '1rem',
|
|
||||||
width: '10rem'
|
|
||||||
}"
|
|
||||||
></ngx-skeleton-loader>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="!isLoading">
|
|
||||||
If you retire today, you would be able to withdraw
|
|
||||||
<span class="font-weight-bold"
|
|
||||||
><gf-value
|
|
||||||
class="d-inline-block"
|
|
||||||
[currency]="user?.settings?.baseCurrency"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[value]="withdrawalRatePerYear?.toNumber()"
|
|
||||||
></gf-value>
|
|
||||||
per year</span
|
|
||||||
>
|
|
||||||
or
|
|
||||||
<span class="font-weight-bold"
|
|
||||||
><gf-value
|
|
||||||
class="d-inline-block"
|
|
||||||
[currency]="user?.settings?.baseCurrency"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[value]="withdrawalRatePerMonth?.toNumber()"
|
|
||||||
></gf-value>
|
|
||||||
per month</span
|
|
||||||
>, based on your total assets of
|
|
||||||
<gf-value
|
|
||||||
class="d-inline-block"
|
|
||||||
[currency]="user?.settings?.baseCurrency"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[value]="fireWealth?.toNumber()"
|
|
||||||
></gf-value>
|
|
||||||
and a withdrawal rate of 4%.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-3" i18n>Calculator</h4>
|
<h4 i18n>4% Rule</h4>
|
||||||
<gf-fire-calculator
|
<div *ngIf="isLoading">
|
||||||
[currency]="user?.settings?.baseCurrency"
|
<ngx-skeleton-loader
|
||||||
[deviceType]="deviceType"
|
animation="pulse"
|
||||||
[fireWealth]="fireWealth?.toNumber()"
|
class="my-1"
|
||||||
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
|
[theme]="{
|
||||||
[locale]="user?.settings?.locale"
|
height: '1rem',
|
||||||
[savingsRate]="user?.settings?.savingsRate"
|
width: '100%'
|
||||||
(savingsRateChanged)="onSavingsRateChange($event)"
|
}"
|
||||||
></gf-fire-calculator>
|
></ngx-skeleton-loader>
|
||||||
|
<ngx-skeleton-loader
|
||||||
|
animation="pulse"
|
||||||
|
[theme]="{
|
||||||
|
height: '1rem',
|
||||||
|
width: '10rem'
|
||||||
|
}"
|
||||||
|
></ngx-skeleton-loader>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!isLoading">
|
||||||
|
If you retire today, you would be able to withdraw
|
||||||
|
<span class="font-weight-bold"
|
||||||
|
><gf-value
|
||||||
|
class="d-inline-block"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="withdrawalRatePerYear?.toNumber()"
|
||||||
|
></gf-value>
|
||||||
|
per year</span
|
||||||
|
>
|
||||||
|
or
|
||||||
|
<span class="font-weight-bold"
|
||||||
|
><gf-value
|
||||||
|
class="d-inline-block"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="withdrawalRatePerMonth?.toNumber()"
|
||||||
|
></gf-value>
|
||||||
|
per month</span
|
||||||
|
>, based on your total assets of
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="fireWealth?.toNumber()"
|
||||||
|
></gf-value>
|
||||||
|
and a withdrawal rate of 4%.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,15 +6,14 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p>
|
<p>
|
||||||
Our official
|
Our official Ghostfolio Premium cloud offering is the easiest way to
|
||||||
<strong>Ghostfolio Premium</strong> cloud offering is the easiest way
|
get started. Due to the time it saves, this will be the best option
|
||||||
to get started. Due to the time it saves, this will be the best option
|
|
||||||
for most people. The revenue is used for covering the hosting costs.
|
for most people. The revenue is used for covering the hosting costs.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
If you prefer to run <strong>Ghostfolio</strong> on your own
|
If you prefer to run Ghostfolio on your own infrastructure, please
|
||||||
infrastructure, please find the source code and further instructions
|
find the source code and further instructions on
|
||||||
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -21,8 +21,4 @@
|
|||||||
|
|
||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
color: rgb(var(--light-primary-text));
|
color: rgb(var(--light-primary-text));
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgb(var(--light-primary-text));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
AdminJobs,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
import { JobStatus } from 'bull';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
import { Observable, map } from 'rxjs';
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
@ -17,6 +19,22 @@ import { Observable, map } from 'rxjs';
|
|||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(private http: HttpClient) {}
|
public constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
public deleteJob(aId: string) {
|
||||||
|
return this.http.delete<void>(`/api/v1/admin/queue/job/${aId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteJobs({ status }: { status: JobStatus[] }) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
|
||||||
|
if (status?.length > 0) {
|
||||||
|
params = params.append('status', status.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.delete<void>('/api/v1/admin/queue/job', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
return this.http.delete<void>(
|
return this.http.delete<void>(
|
||||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`
|
`/api/v1/admin/profile-data/${dataSource}/${symbol}`
|
||||||
@ -42,12 +60,28 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchJobs({ status }: { status?: JobStatus[] }) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
|
||||||
|
if (status?.length > 0) {
|
||||||
|
params = params.append('status', status.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<AdminJobs>('/api/v1/admin/queue/job', {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public gather7Days() {
|
||||||
|
return this.http.post<void>('/api/v1/admin/gather', {});
|
||||||
|
}
|
||||||
|
|
||||||
public gatherMax() {
|
public gatherMax() {
|
||||||
return this.http.post<void>(`/api/v1/admin/gather/max`, {});
|
return this.http.post<void>('/api/v1/admin/gather/max', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public gatherProfileData() {
|
public gatherProfileData() {
|
||||||
return this.http.post<void>(`/api/v1/admin/gather/profile-data`, {});
|
return this.http.post<void>('/api/v1/admin/gather/profile-data', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export enum UserStoreActions {
|
export enum UserStoreActions {
|
||||||
GetUser = 'GET_USER',
|
GetUser = 'GET_USER',
|
||||||
|
Initialize = 'INITIALIZE',
|
||||||
RemoveUser = 'REMOVE_USER'
|
RemoveUser = 'REMOVE_USER'
|
||||||
}
|
}
|
||||||
|
@ -16,13 +16,13 @@ export class UserService extends ObservableStore<UserStoreState> {
|
|||||||
public constructor(private http: HttpClient) {
|
public constructor(private http: HttpClient) {
|
||||||
super({ trackStateHistory: true });
|
super({ trackStateHistory: true });
|
||||||
|
|
||||||
this.setState({ user: undefined }, 'INIT_STATE');
|
this.setState({ user: undefined }, UserStoreActions.Initialize);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get() {
|
public get(force = false) {
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
|
|
||||||
if (state?.user) {
|
if (state?.user && force !== true) {
|
||||||
// Get from cache
|
// Get from cache
|
||||||
return of(state.user);
|
return of(state.user);
|
||||||
} else {
|
} else {
|
||||||
|
80
apps/client/src/assets/privacy-policy.md
Normal file
80
apps/client/src/assets/privacy-policy.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
Last updated: June 18, 2022
|
||||||
|
|
||||||
|
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Service and tells You about Your privacy rights and how the law protects You.
|
||||||
|
|
||||||
|
We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.
|
||||||
|
|
||||||
|
## Interpretation and Definitions
|
||||||
|
|
||||||
|
### Interpretation
|
||||||
|
|
||||||
|
The words of which the initial letter is capitalized have meanings defined under the following conditions. The following definitions shall have the same meaning regardless of whether they appear in singular or in plural.
|
||||||
|
|
||||||
|
### Definitions
|
||||||
|
|
||||||
|
For the purposes of this Privacy Policy:
|
||||||
|
|
||||||
|
- **Account** means a unique account created for You to access our Service or parts of our Service.
|
||||||
|
- **Application** means the software program provided by the Company downloaded by You on any electronic device, named Ghostfolio App.
|
||||||
|
- **Company** (referred to as either "the Company", "We", "Us" or "Our" in this Agreement) refers to Ghostfolio.
|
||||||
|
- **Country** refers to: Switzerland
|
||||||
|
- **Device** means any device that can access the Service such as a computer, a cellphone or a digital tablet.
|
||||||
|
- **Personal Data** is any information that relates to an identified or identifiable individual.
|
||||||
|
- **Service** refers to the Application.
|
||||||
|
- **Service Provider** means any natural or legal person who processes the data on behalf of the Company. It refers to third-party companies or individuals employed by the Company to facilitate the Service, to provide the Service on behalf of the Company, to perform services related to the Service or to assist the Company in analyzing how the Service is used.
|
||||||
|
- **Usage Data** refers to data collected automatically, either generated by the use of the Service or from the Service infrastructure itself (for example, the duration of a page visit).
|
||||||
|
- **You** means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
|
||||||
|
|
||||||
|
## Collecting and Using Your Personal Data
|
||||||
|
|
||||||
|
### Types of Data Collected
|
||||||
|
|
||||||
|
#### Personal Data
|
||||||
|
|
||||||
|
While using Our Service, We may ask You to provide Us with certain personally identifiable information that can be used to identify You. Personally identifiable information may include, but is not limited to:
|
||||||
|
|
||||||
|
- Usage Data
|
||||||
|
- User Id
|
||||||
|
|
||||||
|
#### Usage Data
|
||||||
|
|
||||||
|
Usage Data is collected automatically when using the Service.
|
||||||
|
|
||||||
|
Usage Data may include information such as the time and date of Your visit, the unique user identifier and other diagnostic data.
|
||||||
|
|
||||||
|
When You access the Service by or through a mobile device, We may collect certain information automatically, including, but not limited to, the unique user identifier and other diagnostic data.
|
||||||
|
|
||||||
|
### Use of Your Personal Data
|
||||||
|
|
||||||
|
The Company may use Personal Data for the following purposes:
|
||||||
|
|
||||||
|
- **To provide and maintain our Service**, including to monitor the usage of our Service.
|
||||||
|
- **For other purposes**: We may use Your information for other purposes, such as data analysis, identifying usage trends, determining the effectiveness of our promotional campaigns and to evaluate and improve our Service, products, services, marketing and your experience.
|
||||||
|
|
||||||
|
### Retention of Your Personal Data
|
||||||
|
|
||||||
|
The Company will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies.
|
||||||
|
|
||||||
|
The Company will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period of time, except when this data is used to strengthen the security or to improve the functionality of Our Service, or We are legally obligated to retain this data for longer time periods.
|
||||||
|
|
||||||
|
### Disclosure of Your Personal Data
|
||||||
|
|
||||||
|
#### Security of Your Personal Data
|
||||||
|
|
||||||
|
The security of Your Personal Data is important to Us, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to store no personal data at all to protect Your Personal Data, We cannot guarantee its absolute security.
|
||||||
|
|
||||||
|
## Links to Other Websites
|
||||||
|
|
||||||
|
Our Service may contain links to other websites that are not operated by Us. If You click on a third party link, You will be directed to that third party's site. We strongly advise You to review the Privacy Policy of every site You visit.
|
||||||
|
|
||||||
|
We have no control over and assume no responsibility for the content, privacy policies or practices of any third party sites or services.
|
||||||
|
|
||||||
|
## Changes to this Privacy Policy
|
||||||
|
|
||||||
|
We may update Our Privacy Policy from time to time.
|
||||||
|
|
||||||
|
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.
|
||||||
|
|
||||||
|
## Contact Us
|
||||||
|
|
||||||
|
If you have any questions about this Privacy Policy, You can contact us [here](https://ghostfol.io/about).
|
@ -1,5 +1,6 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
Disallow: /about/privacy-policy
|
||||||
Disallow: /p/*
|
Disallow: /p/*
|
||||||
|
|
||||||
Sitemap: https://ghostfol.io/sitemap.xml
|
Sitemap: https://ghostfol.io/sitemap.xml
|
||||||
|
@ -16,5 +16,8 @@
|
|||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"strictInjectionParameters": true,
|
"strictInjectionParameters": true,
|
||||||
"strictTemplates": false
|
"strictTemplates": false
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=prefer
|
||||||
REDIS_HOST: 'redis'
|
REDIS_HOST: 'redis'
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
|
@ -5,7 +5,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=prefer
|
||||||
REDIS_HOST: 'redis'
|
REDIS_HOST: 'redis'
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
const { getJestProjects } = require('@nrwl/jest');
|
const { getJestProjects } = require('@nrwl/jest');
|
||||||
|
|
||||||
module.exports = { projects: getJestProjects() };
|
export default { projects: getJestProjects() };
|
||||||
|
3
jest.preset.js
Normal file
3
jest.preset.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const nxPreset = require('@nrwl/jest/preset').default;
|
||||||
|
|
||||||
|
module.exports = { ...nxPreset };
|
@ -1,3 +0,0 @@
|
|||||||
const nxPreset = require('@nrwl/jest/preset');
|
|
||||||
|
|
||||||
module.exports = { ...nxPreset };
|
|
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'common',
|
displayName: 'common',
|
||||||
|
|
||||||
globals: {
|
globals: {
|
||||||
@ -9,5 +9,5 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
coverageDirectory: '../../coverage/libs/common',
|
coverageDirectory: '../../coverage/libs/common',
|
||||||
preset: '../../jest.preset.ts'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { JobOptions, JobStatus } from 'bull';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
import { ToggleOption } from './types';
|
import { ToggleOption } from './types';
|
||||||
|
|
||||||
@ -43,19 +45,52 @@ export const warnColorRgb = {
|
|||||||
export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND';
|
export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND';
|
||||||
|
|
||||||
export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE';
|
export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE';
|
||||||
|
export const DATA_GATHERING_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER;
|
||||||
|
export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1;
|
||||||
|
|
||||||
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
|
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
|
||||||
|
|
||||||
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
|
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
|
||||||
|
export const GATHER_ASSET_PROFILE_PROCESS_OPTIONS: JobOptions = {
|
||||||
|
attempts: 10,
|
||||||
|
backoff: {
|
||||||
|
delay: ms('1 minute'),
|
||||||
|
type: 'exponential'
|
||||||
|
},
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
|
removeOnComplete: {
|
||||||
|
age: ms('2 weeks') / 1000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS =
|
||||||
|
'GATHER_HISTORICAL_MARKET_DATA';
|
||||||
|
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS: JobOptions = {
|
||||||
|
attempts: 10,
|
||||||
|
backoff: {
|
||||||
|
delay: ms('1 minute'),
|
||||||
|
type: 'exponential'
|
||||||
|
},
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||||
|
removeOnComplete: {
|
||||||
|
age: ms('2 weeks') / 1000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const PROPERTY_BENCHMARKS = 'BENCHMARKS';
|
export const PROPERTY_BENCHMARKS = 'BENCHMARKS';
|
||||||
export const PROPERTY_COUPONS = 'COUPONS';
|
export const PROPERTY_COUPONS = 'COUPONS';
|
||||||
export const PROPERTY_CURRENCIES = 'CURRENCIES';
|
export const PROPERTY_CURRENCIES = 'CURRENCIES';
|
||||||
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
|
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
|
||||||
export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING';
|
|
||||||
export const PROPERTY_LOCKED_DATA_GATHERING = 'LOCKED_DATA_GATHERING';
|
|
||||||
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
|
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
|
||||||
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
|
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
|
||||||
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';
|
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';
|
||||||
|
|
||||||
|
export const QUEUE_JOB_STATUS_LIST = <JobStatus[]>[
|
||||||
|
'active',
|
||||||
|
'completed',
|
||||||
|
'delayed',
|
||||||
|
'failed',
|
||||||
|
'paused',
|
||||||
|
'waiting'
|
||||||
|
];
|
||||||
|
|
||||||
export const UNKNOWN_KEY = 'UNKNOWN';
|
export const UNKNOWN_KEY = 'UNKNOWN';
|
||||||
|
@ -77,6 +77,10 @@ export function getDateFormatString(aLocale?: string) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDateWithTimeFormatString(aLocale?: string) {
|
||||||
|
return `${getDateFormatString(aLocale)}, HH:mm:ss`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getLocale() {
|
export function getLocale() {
|
||||||
return navigator.languages?.length
|
return navigator.languages?.length
|
||||||
? navigator.languages[0]
|
? navigator.languages[0]
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
export interface AdminData {
|
export interface AdminData {
|
||||||
dataGatheringProgress?: number;
|
|
||||||
exchangeRates: { label1: string; label2: string; value: number }[];
|
exchangeRates: { label1: string; label2: string; value: number }[];
|
||||||
lastDataGathering?: Date | 'IN_PROGRESS';
|
|
||||||
settings: { [key: string]: boolean | object | string | string[] };
|
settings: { [key: string]: boolean | object | string | string[] };
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
|
16
libs/common/src/lib/interfaces/admin-jobs.interface.ts
Normal file
16
libs/common/src/lib/interfaces/admin-jobs.interface.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Job, JobStatus } from 'bull';
|
||||||
|
|
||||||
|
export interface AdminJobs {
|
||||||
|
jobs: (Pick<
|
||||||
|
Job<any>,
|
||||||
|
| 'attemptsMade'
|
||||||
|
| 'data'
|
||||||
|
| 'finishedOn'
|
||||||
|
| 'id'
|
||||||
|
| 'name'
|
||||||
|
| 'stacktrace'
|
||||||
|
| 'timestamp'
|
||||||
|
> & {
|
||||||
|
state: JobStatus | 'stuck';
|
||||||
|
})[];
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { Access } from './access.interface';
|
import { Access } from './access.interface';
|
||||||
import { Accounts } from './accounts.interface';
|
import { Accounts } from './accounts.interface';
|
||||||
import { AdminData } from './admin-data.interface';
|
import { AdminData } from './admin-data.interface';
|
||||||
|
import { AdminJobs } from './admin-jobs.interface';
|
||||||
import { AdminMarketDataDetails } from './admin-market-data-details.interface';
|
import { AdminMarketDataDetails } from './admin-market-data-details.interface';
|
||||||
import {
|
import {
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -40,6 +41,7 @@ export {
|
|||||||
Access,
|
Access,
|
||||||
Accounts,
|
Accounts,
|
||||||
AdminData,
|
AdminData,
|
||||||
|
AdminJobs,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'ui',
|
displayName: 'ui',
|
||||||
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||||
@ -18,5 +18,5 @@ module.exports = {
|
|||||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||||
'jest-preset-angular/build/serializers/html-comment'
|
'jest-preset-angular/build/serializers/html-comment'
|
||||||
],
|
],
|
||||||
preset: '../../jest.preset.ts'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -41,7 +41,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
public filterGroups$: Subject<FilterGroup[]> = new BehaviorSubject([]);
|
public filterGroups$: Subject<FilterGroup[]> = new BehaviorSubject([]);
|
||||||
public filters$: Subject<Filter[]> = new BehaviorSubject([]);
|
public filters$: Subject<Filter[]> = new BehaviorSubject([]);
|
||||||
public filters: Observable<Filter[]> = this.filters$.asObservable();
|
public filters: Observable<Filter[]> = this.filters$.asObservable();
|
||||||
public searchControl = new FormControl();
|
public searchControl = new FormControl<Filter | string>(undefined);
|
||||||
public selectedFilters: Filter[] = [];
|
public selectedFilters: Filter[] = [];
|
||||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
public constructor() {
|
public constructor() {
|
||||||
this.searchControl.valueChanges
|
this.searchControl.valueChanges
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((filterOrSearchTerm: Filter | string) => {
|
.subscribe((filterOrSearchTerm) => {
|
||||||
if (filterOrSearchTerm) {
|
if (filterOrSearchTerm) {
|
||||||
const searchTerm =
|
const searchTerm =
|
||||||
typeof filterOrSearchTerm === 'string'
|
typeof filterOrSearchTerm === 'string'
|
||||||
@ -80,7 +80,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.searchControl.setValue(null);
|
this.searchControl.setValue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onRemoveFilter(aFilter: Filter): void {
|
public onRemoveFilter(aFilter: Filter): void {
|
||||||
@ -99,7 +99,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
);
|
);
|
||||||
this.updateFilters();
|
this.updateFilters();
|
||||||
this.searchInput.nativeElement.value = '';
|
this.searchInput.nativeElement.value = '';
|
||||||
this.searchControl.setValue(null);
|
this.searchControl.setValue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
@ -126,7 +126,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
.sort((a, b) => a.label?.localeCompare(b.label)),
|
||||||
(filter) => {
|
(filter) => {
|
||||||
return filter.type;
|
return filter.type;
|
||||||
}
|
}
|
||||||
@ -142,7 +142,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return filterGroups
|
return filterGroups
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name?.localeCompare(b.name))
|
||||||
.map((filterGroup) => {
|
.map((filterGroup) => {
|
||||||
return {
|
return {
|
||||||
...filterGroup,
|
...filterGroup,
|
||||||
|
@ -322,7 +322,7 @@
|
|||||||
(click)="onImport()"
|
(click)="onImport()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||||
<span i18n>Import</span>
|
<span i18n>Import Activities</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
*ngIf="hasPermissionToExportActivities"
|
*ngIf="hasPermissionToExportActivities"
|
||||||
@ -332,7 +332,7 @@
|
|||||||
(click)="onExport()"
|
(click)="onExport()"
|
||||||
>
|
>
|
||||||
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
|
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
|
||||||
<span i18n>Export</span>
|
<span i18n>Export Activities</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
*ngIf="hasPermissionToExportActivities"
|
*ngIf="hasPermissionToExportActivities"
|
||||||
|
@ -8,7 +8,6 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormControl } from '@angular/forms';
|
|
||||||
import { MatSort } from '@angular/material/sort';
|
import { MatSort } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@ -62,7 +61,6 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
public isUUID = isUUID;
|
public isUUID = isUUID;
|
||||||
public placeholder = '';
|
public placeholder = '';
|
||||||
public routeQueryParams: Subscription;
|
public routeQueryParams: Subscription;
|
||||||
public searchControl = new FormControl();
|
|
||||||
public searchKeywords: string[] = [];
|
public searchKeywords: string[] = [];
|
||||||
public totalFees: number;
|
public totalFees: number;
|
||||||
public totalValue: number;
|
public totalValue: number;
|
||||||
@ -192,11 +190,13 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
|||||||
activity: OrderWithAccount,
|
activity: OrderWithAccount,
|
||||||
fieldValueMap: { [id: string]: Filter } = {}
|
fieldValueMap: { [id: string]: Filter } = {}
|
||||||
): Filter[] {
|
): Filter[] {
|
||||||
fieldValueMap[activity.Account?.id] = {
|
if (activity.Account?.id) {
|
||||||
id: activity.Account?.id,
|
fieldValueMap[activity.Account.id] = {
|
||||||
label: activity.Account?.name,
|
id: activity.Account.id,
|
||||||
type: 'ACCOUNT'
|
label: activity.Account.name,
|
||||||
};
|
type: 'ACCOUNT'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fieldValueMap[activity.SymbolProfile.currency] = {
|
fieldValueMap[activity.SymbolProfile.currency] = {
|
||||||
id: activity.SymbolProfile.currency,
|
id: activity.SymbolProfile.currency,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<form class="" [formGroup]="calculatorForm">
|
<form class="mb-4" [formGroup]="calculatorForm">
|
||||||
<!--<mat-form-field appearance="outline">
|
<!--<mat-form-field appearance="outline">
|
||||||
<input formControlName="principalInvestmentAmount" matInput />
|
<input formControlName="principalInvestmentAmount" matInput />
|
||||||
</mat-form-field>-->
|
</mat-form-field>-->
|
||||||
|
@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
import { locale } from '@ghostfolio/common/config';
|
||||||
import { Meta, Story, moduleMetadata } from '@storybook/angular';
|
import { Meta, Story, moduleMetadata } from '@storybook/angular';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ const Template: Story<FireCalculatorComponent> = (
|
|||||||
|
|
||||||
export const Simple = Template.bind({});
|
export const Simple = Template.bind({});
|
||||||
Simple.args = {
|
Simple.args = {
|
||||||
currency: baseCurrency,
|
currency: 'USD',
|
||||||
fireWealth: 0,
|
fireWealth: 0,
|
||||||
locale: locale
|
locale: locale
|
||||||
};
|
};
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user