Compare commits
37 Commits
Author | SHA1 | Date | |
---|---|---|---|
7425ba94f1 | |||
b9522307c4 | |||
e01e039a00 | |||
6d3513c17f | |||
d60b444324 | |||
2873130259 | |||
d999a27159 | |||
b6902e10ea | |||
7f3f75386d | |||
678544748a | |||
632f3e3872 | |||
87301ddbd5 | |||
7d03c373ac | |||
edb66bb166 | |||
54bbc8446b | |||
9933967e42 | |||
5618513d07 | |||
1397cd62a8 | |||
e7fb31d1a6 | |||
51e7b94ad0 | |||
9133ea38f3 | |||
a864c617b9 | |||
442df9d6f8 | |||
2de0e75cb8 | |||
1296f95602 | |||
e98dff877a | |||
964b37af30 | |||
2d89d12d31 | |||
19e2d54791 | |||
e24e5e1c44 | |||
a0d4ff7920 | |||
099ad18aaf | |||
e3cd99f5d2 | |||
6dea9093ba | |||
43104f81d0 | |||
6d2e3b6e40 | |||
1d9a31dbb8 |
@ -1 +1,2 @@
|
|||||||
/dist
|
/dist
|
||||||
|
/test/import
|
||||||
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
|||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
]
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
120
CHANGELOG.md
120
CHANGELOG.md
@ -5,6 +5,114 @@ 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.73.0 - 10.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the info messages to add the first transaction
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the skeleton loader of the portfolio holdings
|
||||||
|
|
||||||
|
## 1.72.0 - 08.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Cached the statistics section on the about page
|
||||||
|
|
||||||
|
## 1.71.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the logger from `console.log()` to `Logger.log()`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an exception in the scraper configuration
|
||||||
|
|
||||||
|
## 1.70.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the validation of `json` files in the import functionality for transactions
|
||||||
|
- Moved the scraper configuration to the symbol profile model
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.69.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the symbol mapping attribute to the symbol profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the registration page
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.68.0 - 01.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Prettified the generic scraper symbols in the portfolio proportion chart component
|
||||||
|
- Extended the statistics section on the about page by the active users count (7d)
|
||||||
|
- Extended the statistics section on the about page by the new users count
|
||||||
|
|
||||||
|
## 1.67.0 - 31.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added more details to the public page (currencies, sectors, continents and regions)
|
||||||
|
- Added a `Dockerfile` and documentation to build a _Docker_ image
|
||||||
|
|
||||||
|
## 1.66.0 - 30.10.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the landing page
|
||||||
|
- Ordered the granted accesses by type
|
||||||
|
|
||||||
|
## 1.65.0 - 25.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the user interface for granting and revoking public access to share the portfolio
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
|
||||||
|
- Changed the profile data gathering from every 12 hours to once every weekend
|
||||||
|
|
||||||
|
## 1.64.0 - 21.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the data provider service to handle a dynamic list of services
|
||||||
|
|
||||||
|
## 1.63.0 - 19.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a public page to share the portfolio
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.62.0 - 17.10.2021
|
## 1.62.0 - 17.10.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -101,7 +209,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.55.0 - 20.09.2021
|
## 1.55.0 - 20.09.2021
|
||||||
|
|
||||||
@ -116,7 +224,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.54.0 - 18.09.2021
|
## 1.54.0 - 18.09.2021
|
||||||
|
|
||||||
@ -137,7 +245,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.53.0 - 13.09.2021
|
## 1.53.0 - 13.09.2021
|
||||||
|
|
||||||
@ -259,7 +367,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.41.0 - 21.08.2021
|
## 1.41.0 - 21.08.2021
|
||||||
|
|
||||||
@ -312,7 +420,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.38.0 - 14.08.2021
|
## 1.38.0 - 14.08.2021
|
||||||
|
|
||||||
@ -372,7 +480,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.34.0 - 07.08.2021
|
## 1.34.0 - 07.08.2021
|
||||||
|
|
||||||
|
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
FROM node:14-alpine as builder
|
||||||
|
|
||||||
|
# Build application and add additional files
|
||||||
|
|
||||||
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
|
# layers when files (package.json etc.) have not changed
|
||||||
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
|
COPY ./LICENSE LICENSE
|
||||||
|
COPY ./package.json package.json
|
||||||
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
|
RUN yarn
|
||||||
|
|
||||||
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
|
RUN node decorate-angular-cli.js
|
||||||
|
|
||||||
|
COPY ./angular.json angular.json
|
||||||
|
COPY ./nx.json nx.json
|
||||||
|
COPY ./replace.build.js replace.build.js
|
||||||
|
COPY ./jest.preset.js jest.preset.js
|
||||||
|
COPY ./jest.config.js jest.config.js
|
||||||
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
|
COPY ./libs libs
|
||||||
|
COPY ./apps apps
|
||||||
|
|
||||||
|
RUN yarn build:all
|
||||||
|
|
||||||
|
# Prepare the dist image with additional node_modules
|
||||||
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
|
# package.json was generated by the build process, however the original
|
||||||
|
# yarn.lock needs to be used to ensure the same versions
|
||||||
|
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||||
|
|
||||||
|
RUN yarn
|
||||||
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
|
# Overwrite the generated package.json with the original one to ensure having
|
||||||
|
# all the scripts
|
||||||
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
|
# Image to run, copy everything needed from builder
|
||||||
|
FROM node:14-alpine
|
||||||
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
|
WORKDIR /ghostfolio/apps/api
|
||||||
|
EXPOSE 3333
|
||||||
|
CMD [ "node", "main" ]
|
57
README.md
57
README.md
@ -34,7 +34,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, please find the source code and further instructions here on _GitHub_ or use the [setup](https://github.com/psychowood/ghostfolio-docker) by [psychowood](https://github.com/psychowood).
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -81,13 +81,52 @@ 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).
|
||||||
|
|
||||||
## Getting Started
|
## Run with Docker
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
|
||||||
|
### Setup Docker Image
|
||||||
|
|
||||||
|
Run the following commands to build and start the Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml build
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup Database
|
||||||
|
|
||||||
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch Historical Data
|
||||||
|
|
||||||
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
|
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_
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@ -95,24 +134,20 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
1. Run `cd docker`
|
1. Run `cd docker`
|
||||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `cd -` to go back to the project root directory
|
1. Run `cd -` to go back to the project root directory
|
||||||
1. Run `yarn setup:database` 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 server and client (see [_Development_](#Development))
|
1. Start server and client (see [_Development_](#Development))
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
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_
|
||||||
|
|
||||||
## Development
|
### Start Server
|
||||||
|
|
||||||
Please make sure you have completed the instructions from [_Setup_](#Setup).
|
|
||||||
|
|
||||||
### Start server
|
|
||||||
|
|
||||||
<ol type="a">
|
<ol type="a">
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
|
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
<li>Serve: Run <code>yarn start:server</code></li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
### Start client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client`
|
Run `yarn start:client`
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"generatePackageJson": true,
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
"inspect": false,
|
"inspect": false,
|
||||||
@ -99,12 +100,12 @@
|
|||||||
{
|
{
|
||||||
"glob": "CHANGELOG.md",
|
"glob": "CHANGELOG.md",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./"
|
"output": "./assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "LICENSE",
|
"glob": "LICENSE",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./"
|
"output": "./assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "robots.txt",
|
"glob": "robots.txt",
|
||||||
|
@ -1,10 +1,29 @@
|
|||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
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 { Access as AccessModel } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { AccessModule } from './access.module';
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
import { CreateAccessDto } from './create-access.dto';
|
||||||
|
|
||||||
@Controller('access')
|
@Controller('access')
|
||||||
export class AccessController {
|
export class AccessController {
|
||||||
@ -20,13 +39,69 @@ export class AccessController {
|
|||||||
include: {
|
include: {
|
||||||
GranteeUser: true
|
GranteeUser: true
|
||||||
},
|
},
|
||||||
|
orderBy: { granteeUserId: 'asc' },
|
||||||
where: { userId: this.request.user.id }
|
where: { userId: this.request.user.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
|
if (access.GranteeUser) {
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser.alias
|
granteeAlias: access.GranteeUser?.alias,
|
||||||
|
id: access.id,
|
||||||
|
type: 'RESTRICTED_VIEW'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
granteeAlias: 'Public',
|
||||||
|
id: access.id,
|
||||||
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async createAccess(
|
||||||
|
@Body() data: CreateAccessDto
|
||||||
|
): Promise<AccessModel> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.createAccess
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accessService.createAccess({
|
||||||
|
User: { connect: { id: this.request.user.id } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.deleteAccess
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accessService.deleteAccess({
|
||||||
|
id_userId: {
|
||||||
|
id,
|
||||||
|
userId: this.request.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
|
|||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
|
exports: [AccessService],
|
||||||
|
imports: [],
|
||||||
providers: [AccessService, PrismaService]
|
providers: [AccessService, PrismaService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessService {
|
export class AccessService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async access(
|
||||||
|
accessWhereInput: Prisma.AccessWhereInput
|
||||||
|
): Promise<AccessWithGranteeUser | null> {
|
||||||
|
return this.prismaService.access.findFirst({
|
||||||
|
include: {
|
||||||
|
GranteeUser: true
|
||||||
|
},
|
||||||
|
where: accessWhereInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async accesses(params: {
|
public async accesses(params: {
|
||||||
include?: Prisma.AccessInclude;
|
include?: Prisma.AccessInclude;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
@ -26,4 +37,18 @@ export class AccessService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
|
||||||
|
return this.prismaService.access.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAccess(
|
||||||
|
where: Prisma.AccessWhereUniqueInput
|
||||||
|
): Promise<Access> {
|
||||||
|
return this.prismaService.access.delete({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
1
apps/api/src/app/access/create-access.dto.ts
Normal file
1
apps/api/src/app/access/create-access.dto.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class CreateAccessDto {}
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { Strategy } from 'passport-google-oauth20';
|
import { Strategy } from 'passport-google-oauth20';
|
||||||
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
done(null, user);
|
done(null, user);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error(err);
|
Logger.error(error);
|
||||||
done(err, false);
|
done(error, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
|||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException
|
InternalServerErrorException,
|
||||||
|
Logger
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
@ -94,7 +95,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = await verifyRegistrationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException(error.message);
|
throw new InternalServerErrorException(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,7 +193,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = verifyAuthenticationResponse(opts);
|
verification = verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
10
apps/api/src/app/cache/cache.module.ts
vendored
10
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,16 +1,24 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DataProviderModule, ExchangeRateDataModule, RedisCacheModule],
|
imports: [
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
providers: [
|
providers: [
|
||||||
CacheService,
|
CacheService,
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -39,7 +40,7 @@ export class ImportController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -11,12 +14,15 @@ import { InfoService } from './info.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
})
|
}),
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [InfoController],
|
controllers: [InfoController],
|
||||||
providers: [
|
providers: [
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
@ -14,6 +16,7 @@ import { subDays } from 'date-fns';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||||
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@ -21,7 +24,8 @@ export class InfoService {
|
|||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly redisCacheService: RedisCacheService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
@ -109,7 +113,7 @@ export class InfoService {
|
|||||||
const contributors = await get();
|
const contributors = await get();
|
||||||
return contributors?.length;
|
return contributors?.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -130,12 +134,34 @@ export class InfoService {
|
|||||||
const { stargazers_count } = await get();
|
const { stargazers_count } = await get();
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countNewUsers(aDays: number) {
|
||||||
|
return await this.prismaService.user.count({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: {
|
||||||
|
gt: subDays(new Date(), aDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: InfoService.DEMO_USER_ID
|
id: InfoService.DEMO_USER_ID
|
||||||
@ -154,17 +180,40 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let statistics: Statistics;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statistics = JSON.parse(
|
||||||
|
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statistics) {
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
|
const activeUsers7d = await this.countActiveUsers(7);
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
|
|
||||||
return {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
|
activeUsers7d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers
|
gitHubStargazers,
|
||||||
|
newUsers30d
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
InfoService.CACHE_KEY_STATISTICS,
|
||||||
|
JSON.stringify(statistics)
|
||||||
|
);
|
||||||
|
|
||||||
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<Subscription[]> {
|
||||||
|
@ -72,15 +72,7 @@ describe('CurrentRateService', () => {
|
|||||||
let marketDataService: MarketDataService;
|
let marketDataService: MarketDataService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(
|
dataProviderService = new DataProviderService(null, [], null);
|
||||||
null,
|
|
||||||
null,
|
|
||||||
[],
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import { OrderType } from '@ghostfolio/api/models/order-type';
|
|||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@ -236,7 +237,7 @@ export class PortfolioCalculator {
|
|||||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
console.error(
|
Logger.error(
|
||||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -269,7 +270,7 @@ export class PortfolioCalculator {
|
|||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
console.error(
|
Logger.error(
|
||||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -480,7 +481,7 @@ export class PortfolioCalculator {
|
|||||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
console.error(
|
Logger.error(
|
||||||
`Initial value is missing for symbol ${currentPosition.symbol}`
|
`Initial value is missing for symbol ${currentPosition.symbol}`
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
@ -546,7 +547,7 @@ export class PortfolioCalculator {
|
|||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
@ -5,9 +6,11 @@ import {
|
|||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -39,6 +42,7 @@ import { PortfolioService } from './portfolio.service';
|
|||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -145,7 +149,11 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(impersonationId, range);
|
await this.portfolioService.getDetails(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
res.status(StatusCodes.ACCEPTED);
|
||||||
@ -252,6 +260,65 @@ export class PortfolioController {
|
|||||||
return <any>res.json(result);
|
return <any>res.json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('public/:accessId')
|
||||||
|
public async getPublic(
|
||||||
|
@Param('accessId') accessId,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioPublicDetails> {
|
||||||
|
const access = await this.accessService.access({ id: accessId });
|
||||||
|
const user = await this.userService.user({
|
||||||
|
id: access.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
res.status(StatusCodes.NOT_FOUND);
|
||||||
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasDetails = true;
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
|
access.userId,
|
||||||
|
access.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
|
hasDetails,
|
||||||
|
holdings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalValue = Object.values(holdings)
|
||||||
|
.filter((holding) => {
|
||||||
|
return holding.assetClass === 'EQUITY';
|
||||||
|
})
|
||||||
|
.map((portfolioPosition) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
|
portfolioPosition.currency,
|
||||||
|
this.request.user?.Settings?.currency ?? baseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||||
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
|
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||||
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: portfolioPosition.currency,
|
||||||
|
name: portfolioPosition.name,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
|
value: portfolioPosition.value / totalValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(portfolioPublicDetails);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getSummary(
|
public async getSummary(
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
@ -7,7 +8,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -18,6 +19,7 @@ import { RulesService } from './rules.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
@ -25,6 +27,7 @@ import { RulesService } from './rules.service';
|
|||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
@ -33,8 +36,7 @@ import { RulesService } from './rules.service';
|
|||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
MarketDataService,
|
MarketDataService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
RulesService,
|
RulesService
|
||||||
SymbolProfileService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PortfolioModule {}
|
export class PortfolioModule {}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// TODO ///////////
|
||||||
|
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
@ -21,7 +23,11 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
|||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
|
import {
|
||||||
|
UNKNOWN_KEY,
|
||||||
|
baseCurrency,
|
||||||
|
ghostfolioCashSymbol
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -78,7 +84,7 @@ export class PortfolioService {
|
|||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
aImpersonationId: string
|
aImpersonationId: string
|
||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -106,7 +112,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<HistoricalDataItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -148,11 +154,12 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
|
aUserId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
userCurrency
|
userCurrency
|
||||||
@ -265,7 +272,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||||
(order) => order.symbol === aSymbol
|
(order) => order.symbol === aSymbol
|
||||||
@ -484,7 +491,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -555,7 +562,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -628,8 +635,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const userId = await this.getUserId(impersonationId);
|
const currency = this.request.user.Settings.currency;
|
||||||
const baseCurrency = this.request.user.Settings.currency;
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||||
userId
|
userId
|
||||||
@ -643,7 +650,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
this.request.user.Settings.currency
|
currency
|
||||||
);
|
);
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
@ -659,7 +666,7 @@ export class PortfolioService {
|
|||||||
const accounts = await this.getAccounts(
|
const accounts = await this.getAccounts(
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
baseCurrency,
|
currency,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@ -679,7 +686,7 @@ export class PortfolioService {
|
|||||||
accounts
|
accounts
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
currencyClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -700,7 +707,7 @@ export class PortfolioService {
|
|||||||
currentPositions
|
currentPositions
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
fees: await this.rulesService.evaluate(
|
fees: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -710,7 +717,7 @@ export class PortfolioService {
|
|||||||
this.getFees(orders)
|
this.getFees(orders)
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -718,7 +725,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||||
|
|
||||||
@ -820,7 +827,7 @@ export class PortfolioService {
|
|||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
dataSource: order.dataSource,
|
dataSource: order.dataSource,
|
||||||
@ -920,14 +927,14 @@ export class PortfolioService {
|
|||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string) {
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
aImpersonationId,
|
aImpersonationId,
|
||||||
this.request.user.id
|
aUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
return impersonationUserId || this.request.user.id;
|
return impersonationUserId || aUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
|
@ -21,9 +21,9 @@ export class RedisCacheService {
|
|||||||
await this.cache.reset();
|
await this.cache.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(key, value, {
|
||||||
ttl: this.configurationService.get('CACHE_TTL')
|
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
@ -46,7 +47,7 @@ export class SubscriptionController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
import { addDays, isBefore } from 'date-fns';
|
import { addDays, isBefore } from 'date-fns';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
@ -85,7 +85,7 @@ export class SubscriptionService {
|
|||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
@ -67,7 +67,7 @@ export class SymbolService {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,11 @@ export class CronService {
|
|||||||
|
|
||||||
@Cron(CronExpression.EVERY_12_HOURS)
|
@Cron(CronExpression.EVERY_12_HOURS)
|
||||||
public async runEveryTwelveHours() {
|
public async runEveryTwelveHours() {
|
||||||
await this.dataGatheringService.gatherProfileData();
|
|
||||||
await this.exchangeRateDataService.loadCurrencies();
|
await this.exchangeRateDataService.loadCurrencies();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_WEEKEND)
|
||||||
|
public async runEveryWeekend() {
|
||||||
|
await this.dataGatheringService.gatherProfileData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CryptocurrencyService } from './cryptocurrency.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [CryptocurrencyService],
|
||||||
|
exports: [CryptocurrencyService]
|
||||||
|
})
|
||||||
|
export class CryptocurrencyModule {}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
const cryptocurrencies = require('cryptocurrencies');
|
||||||
|
|
||||||
|
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CryptocurrencyService {
|
||||||
|
private combinedCryptocurrencies: string[];
|
||||||
|
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public isCrypto(aSymbol = '') {
|
||||||
|
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
||||||
|
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCryptocurrencies() {
|
||||||
|
if (!this.combinedCryptocurrencies) {
|
||||||
|
this.combinedCryptocurrencies = [
|
||||||
|
...cryptocurrencies.symbols(),
|
||||||
|
...Object.keys(customCryptocurrencies)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.combinedCryptocurrencies;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"1INCH": "1inch",
|
||||||
|
"AVAX": "Avalanche",
|
||||||
|
"MATIC": "Polygon",
|
||||||
|
"SHIB": "Shiba Inu"
|
||||||
|
}
|
@ -1,19 +1,23 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||||
|
import { SymbolProfileModule } from './symbol-profile.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
|
DataEnhancerModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
PrismaModule
|
PrismaModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [DataGatheringService],
|
providers: [DataGatheringService],
|
||||||
exports: [DataGatheringService]
|
exports: [DataEnhancerModule, DataGatheringService]
|
||||||
})
|
})
|
||||||
export class DataGatheringModule {}
|
export class DataGatheringModule {}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
benchmarks,
|
benchmarks,
|
||||||
ghostfolioFearAndGreedIndexSymbol
|
ghostfolioFearAndGreedIndexSymbol
|
||||||
} 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 { Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
import { ConfigurationService } from './configuration.service';
|
import { ConfigurationService } from './configuration.service';
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
@ -26,17 +28,20 @@ import { PrismaService } from './prisma.service';
|
|||||||
export class DataGatheringService {
|
export class DataGatheringService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
@Inject('DataEnhancers')
|
||||||
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async gather7Days() {
|
public async gather7Days() {
|
||||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
||||||
|
|
||||||
if (isDataGatheringNeeded) {
|
if (isDataGatheringNeeded) {
|
||||||
console.log('7d data gathering has been started.');
|
Logger.log('7d data gathering has been started.');
|
||||||
console.time('data-gathering-7d');
|
console.time('data-gathering-7d');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
@ -60,7 +65,7 @@ export class DataGatheringService {
|
|||||||
where: { key: 'LAST_DATA_GATHERING' }
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -69,7 +74,7 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('7d data gathering has been completed.');
|
Logger.log('7d data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering-7d');
|
console.timeEnd('data-gathering-7d');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,7 +85,7 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
if (!isDataGatheringLocked) {
|
||||||
console.log('Max data gathering has been started.');
|
Logger.log('Max data gathering has been started.');
|
||||||
console.time('data-gathering-max');
|
console.time('data-gathering-max');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
@ -104,7 +109,7 @@ export class DataGatheringService {
|
|||||||
where: { key: 'LAST_DATA_GATHERING' }
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -113,13 +118,13 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Max data gathering has been completed.');
|
Logger.log('Max data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering-max');
|
console.timeEnd('data-gathering-max');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||||
console.log('Profile data gathering has been started.');
|
Logger.log('Profile data gathering has been started.');
|
||||||
console.time('data-gathering-profile');
|
console.time('data-gathering-profile');
|
||||||
|
|
||||||
let dataGatheringItems = aDataGatheringItems;
|
let dataGatheringItems = aDataGatheringItems;
|
||||||
@ -129,10 +134,29 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
||||||
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
dataGatheringItems.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
for (const [
|
for (const [symbol, response] of Object.entries(currentData)) {
|
||||||
symbol,
|
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||||
{
|
return symbolProfile.symbol === symbol;
|
||||||
|
})?.symbolMapping;
|
||||||
|
|
||||||
|
for (const dataEnhancer of this.dataEnhancers) {
|
||||||
|
try {
|
||||||
|
currentData[symbol] = await dataEnhancer.enhance({
|
||||||
|
response,
|
||||||
|
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
@ -140,8 +164,8 @@ export class DataGatheringService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
sectors
|
sectors
|
||||||
}
|
} = currentData[symbol];
|
||||||
] of Object.entries(currentData)) {
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.symbolProfile.upsert({
|
await this.prismaService.symbolProfile.upsert({
|
||||||
create: {
|
create: {
|
||||||
@ -170,11 +194,11 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${symbol}: ${error?.meta?.cause}`);
|
Logger.error(`${symbol}: ${error?.meta?.cause}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Profile data gathering has been completed.');
|
Logger.log('Profile data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering-profile');
|
console.timeEnd('data-gathering-profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,7 +261,7 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,21 +272,6 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCustomSymbolsToGather(
|
|
||||||
startDate?: Date
|
|
||||||
): Promise<IDataGatheringItem[]> {
|
|
||||||
const scraperConfigurations =
|
|
||||||
await this.ghostfolioScraperApi.getScraperConfigurations();
|
|
||||||
|
|
||||||
return scraperConfigurations.map((scraperConfiguration) => {
|
|
||||||
return {
|
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
|
||||||
date: startDate,
|
|
||||||
symbol: scraperConfiguration.symbol
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getIsInProgress() {
|
public async getIsInProgress() {
|
||||||
return await this.prismaService.property.findUnique({
|
return await this.prismaService.property.findUnique({
|
||||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||||
@ -282,7 +291,7 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async reset() {
|
public async reset() {
|
||||||
console.log('Data gathering has been reset.');
|
Logger.log('Data gathering has been reset.');
|
||||||
|
|
||||||
await this.prismaService.property.deleteMany({
|
await this.prismaService.property.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@ -319,6 +328,7 @@ export class DataGatheringService {
|
|||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -339,12 +349,8 @@ export class DataGatheringService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const customSymbolsToGather =
|
|
||||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...this.getBenchmarksToGather(startDate),
|
...this.getBenchmarksToGather(startDate),
|
||||||
...customSymbolsToGather,
|
|
||||||
...currencyPairsToGather,
|
...currencyPairsToGather,
|
||||||
...symbolProfilesToGather
|
...symbolProfilesToGather
|
||||||
];
|
];
|
||||||
@ -358,9 +364,6 @@ export class DataGatheringService {
|
|||||||
})
|
})
|
||||||
)?.date ?? new Date();
|
)?.date ?? new Date();
|
||||||
|
|
||||||
const customSymbolsToGather =
|
|
||||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
|
||||||
|
|
||||||
const currencyPairsToGather = this.exchangeRateDataService
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
@ -381,20 +384,19 @@ export class DataGatheringService {
|
|||||||
select: { date: true },
|
select: { date: true },
|
||||||
take: 1
|
take: 1
|
||||||
},
|
},
|
||||||
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((item) => {
|
).map((symbolProfile) => {
|
||||||
return {
|
return {
|
||||||
dataSource: item.dataSource,
|
...symbolProfile,
|
||||||
date: item.Order?.[0]?.date ?? startDate,
|
date: symbolProfile.Order?.[0]?.date ?? startDate
|
||||||
symbol: item.symbol
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...this.getBenchmarksToGather(startDate),
|
...this.getBenchmarksToGather(startDate),
|
||||||
...customSymbolsToGather,
|
|
||||||
...currencyPairsToGather,
|
...currencyPairsToGather,
|
||||||
...symbolProfilesToGather
|
...symbolProfilesToGather
|
||||||
];
|
];
|
||||||
|
@ -2,7 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
import { isAfter, isBefore, parse } from 'date-fns';
|
||||||
|
|
||||||
@ -78,12 +78,16 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error, symbol);
|
Logger.error(error, symbol);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.ALPHA_VANTAGE;
|
||||||
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
const result = await this.alphaVantage.data.search(aSymbol);
|
const result = await this.alphaVantage.data.search(aSymbol);
|
||||||
|
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
inject: [TrackinsightDataEnhancerService],
|
||||||
|
provide: 'DataEnhancers',
|
||||||
|
useFactory: (trackinsight) => [trackinsight]
|
||||||
|
},
|
||||||
|
TrackinsightDataEnhancerService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class DataEnhancerModule {}
|
@ -70,4 +70,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
return Promise.resolve(response);
|
return Promise.resolve(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getName() {
|
||||||
|
return 'TRACKINSIGHT';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,47 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
||||||
import { DataProviderService } from './data-provider.service';
|
import { DataProviderService } from './data-provider.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigurationModule, PrismaModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
CryptocurrencyModule,
|
||||||
|
PrismaModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
TrackinsightDataEnhancerService,
|
|
||||||
YahooFinanceService,
|
YahooFinanceService,
|
||||||
{
|
{
|
||||||
inject: [TrackinsightDataEnhancerService],
|
inject: [
|
||||||
provide: 'DataEnhancers',
|
AlphaVantageService,
|
||||||
useFactory: (trackinsight) => [trackinsight]
|
GhostfolioScraperApiService,
|
||||||
|
RakutenRapidApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
],
|
||||||
|
provide: 'DataProviderInterfaces',
|
||||||
|
useFactory: (
|
||||||
|
alphaVantageService,
|
||||||
|
ghostfolioScraperApiService,
|
||||||
|
rakutenRapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
) => [
|
||||||
|
alphaVantageService,
|
||||||
|
ghostfolioScraperApiService,
|
||||||
|
rakutenRapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
@ -9,30 +9,19 @@ import {
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
|
||||||
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataProviderService {
|
export class DataProviderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly alphaVantageService: AlphaVantageService,
|
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@Inject('DataEnhancers')
|
@Inject('DataProviderInterfaces')
|
||||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
private readonly dataProviderInterfaces: DataProviderInterface[],
|
||||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
private readonly prismaService: PrismaService
|
||||||
private readonly prismaService: PrismaService,
|
) {}
|
||||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
|
||||||
private readonly yahooFinanceService: YahooFinanceService
|
|
||||||
) {
|
|
||||||
this.rakutenRapidApiService?.setPrisma(this.prismaService);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get(items: IDataGatheringItem[]): Promise<{
|
public async get(items: IDataGatheringItem[]): Promise<{
|
||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
@ -50,20 +39,7 @@ export class DataProviderService {
|
|||||||
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
for (const symbol of Object.keys(response)) {
|
for (const symbol of Object.keys(response)) {
|
||||||
let promise = Promise.resolve(response[symbol]);
|
const promise = Promise.resolve(response[symbol]);
|
||||||
for (const dataEnhancer of this.dataEnhancers) {
|
|
||||||
promise = promise.then((currentResponse) =>
|
|
||||||
dataEnhancer
|
|
||||||
.enhance({ symbol, response: currentResponse })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(
|
|
||||||
`Failed to enhance data for symbol ${symbol}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
return currentResponse;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
promises.push(
|
promises.push(
|
||||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||||
);
|
);
|
||||||
@ -133,7 +109,7 @@ export class DataProviderService {
|
|||||||
return r;
|
return r;
|
||||||
}, {});
|
}, {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -204,17 +180,11 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getDataProvider(providerName: DataSource) {
|
private getDataProvider(providerName: DataSource) {
|
||||||
switch (providerName) {
|
for (const dataProviderInterface of this.dataProviderInterfaces) {
|
||||||
case DataSource.ALPHA_VANTAGE:
|
if (dataProviderInterface.getName() === providerName) {
|
||||||
return this.alphaVantageService;
|
return dataProviderInterface;
|
||||||
case DataSource.GHOSTFOLIO:
|
}
|
||||||
return this.ghostfolioScraperApiService;
|
}
|
||||||
case DataSource.RAKUTEN:
|
|
||||||
return this.rakutenRapidApiService;
|
|
||||||
case DataSource.YAHOO:
|
|
||||||
return this.yahooFinanceService;
|
|
||||||
default:
|
|
||||||
throw new Error('No data provider has been found.');
|
throw new Error('No data provider has been found.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@ -1,31 +1,33 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
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 {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
isGhostfolioScraperApiSymbol
|
isGhostfolioScraperApiSymbol
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||||
|
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return isGhostfolioScraperApiSymbol(symbol);
|
return isGhostfolioScraperApiSymbol(symbol);
|
||||||
@ -39,9 +41,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const [symbol] = aSymbols;
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
|
[symbol]
|
||||||
|
);
|
||||||
|
|
||||||
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -55,37 +58,18 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
[symbol]: {
|
[symbol]: {
|
||||||
marketPrice,
|
marketPrice,
|
||||||
currency: scraperConfig?.currency,
|
currency: symbolProfile?.currency,
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
dataSource: DataSource.GHOSTFOLIO,
|
||||||
marketState: MarketState.delayed
|
marketState: MarketState.delayed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCustomSymbolsToGather(
|
|
||||||
startDate?: Date
|
|
||||||
): Promise<IDataGatheringItem[]> {
|
|
||||||
const ghostfolioSymbolProfiles =
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
where: {
|
|
||||||
dataSource: DataSource.GHOSTFOLIO
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbols: string[],
|
aSymbols: string[],
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -99,11 +83,11 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const [symbol] = aSymbols;
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
const scraperConfiguration = await this.getScraperConfigurationBySymbol(
|
[symbol]
|
||||||
symbol
|
|
||||||
);
|
);
|
||||||
|
const scraperConfiguration = symbolProfile?.scraperConfiguration;
|
||||||
|
|
||||||
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
||||||
|
|
||||||
@ -122,26 +106,14 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
public getName(): DataSource {
|
||||||
try {
|
return DataSource.GHOSTFOLIO;
|
||||||
const { value: scraperConfigString } =
|
|
||||||
await this.prismaService.property.findFirst({
|
|
||||||
select: {
|
|
||||||
value: true
|
|
||||||
},
|
|
||||||
where: { key: 'SCRAPER_CONFIG' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.parse(scraperConfigString);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
@ -158,11 +130,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getScraperConfigurationBySymbol(aSymbol: string) {
|
|
||||||
const scraperConfigurations = await this.getScraperConfigurations();
|
|
||||||
return scraperConfigurations.find((scraperConfiguration) => {
|
|
||||||
return scraperConfiguration.symbol === aSymbol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
export interface ScraperConfig {
|
|
||||||
currency: string;
|
|
||||||
selector: string;
|
|
||||||
symbol: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface ScraperConfiguration {
|
||||||
|
selector: string;
|
||||||
|
url: string;
|
||||||
|
}
|
@ -8,4 +8,6 @@ export interface DataEnhancerInterface {
|
|||||||
response: IDataProviderResponse;
|
response: IDataProviderResponse;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<IDataProviderResponse>;
|
}): Promise<IDataProviderResponse>;
|
||||||
|
|
||||||
|
getName(): string;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
@ -20,5 +21,7 @@ export interface DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
getName(): DataSource;
|
||||||
|
|
||||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
isRakutenRapidApiSymbol
|
isRakutenRapidApiSymbol
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||||
@ -25,10 +25,9 @@ import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
|||||||
export class RakutenRapidApiService implements DataProviderInterface {
|
export class RakutenRapidApiService implements DataProviderInterface {
|
||||||
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
||||||
|
|
||||||
private prismaService: PrismaService;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
@ -62,7 +61,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@ -134,12 +133,12 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public getName(): DataSource {
|
||||||
return { items: [] };
|
return DataSource.RAKUTEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPrisma(aPrismaService: PrismaService) {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
this.prismaService = aPrismaService;
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getFearAndGreedIndex(): Promise<{
|
private async getFearAndGreedIndex(): Promise<{
|
||||||
@ -167,7 +166,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
const { fgi } = await get();
|
const { fgi } = await get();
|
||||||
return fgi;
|
return fgi;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, isCrypto, isCurrency } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -26,7 +27,9 @@ import {
|
|||||||
export class YahooFinanceService implements DataProviderInterface {
|
export class YahooFinanceService implements DataProviderInterface {
|
||||||
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
|
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
|
||||||
|
|
||||||
public constructor() {}
|
public constructor(
|
||||||
|
private readonly cryptocurrencyService: CryptocurrencyService
|
||||||
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return true;
|
return true;
|
||||||
@ -65,7 +68,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
dataSource: DataSource.YAHOO,
|
dataSource: DataSource.YAHOO,
|
||||||
exchange: this.parseExchange(value.price?.exchangeName),
|
exchange: this.parseExchange(value.price?.exchangeName),
|
||||||
marketState:
|
marketState:
|
||||||
value.price?.marketState === 'REGULAR' || isCrypto(symbol)
|
value.price?.marketState === 'REGULAR' ||
|
||||||
|
this.cryptocurrencyService.isCrypto(symbol)
|
||||||
? MarketState.open
|
? MarketState.open
|
||||||
: MarketState.closed,
|
: MarketState.closed,
|
||||||
marketPrice: value.price?.regularMarketPrice || 0,
|
marketPrice: value.price?.regularMarketPrice || 0,
|
||||||
@ -113,7 +117,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -165,12 +169,16 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.YAHOO;
|
||||||
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
const items: LookupItem[] = [];
|
const items: LookupItem[] = [];
|
||||||
|
|
||||||
@ -245,7 +253,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
) {
|
) {
|
||||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||||
return `${aSymbol}=X`;
|
return `${aSymbol}=X`;
|
||||||
} else if (isCrypto(aSymbol) || isCrypto(aSymbol.replace('1', ''))) {
|
} else if (
|
||||||
|
this.cryptocurrencyService.isCrypto(aSymbol) ||
|
||||||
|
this.cryptocurrencyService.isCrypto(aSymbol.replace('1', ''))
|
||||||
|
) {
|
||||||
// Add a dash before the last three characters
|
// Add a dash before the last three characters
|
||||||
// BTCUSD -> BTC-USD
|
// BTCUSD -> BTC-USD
|
||||||
// DOGEUSD -> DOGE-USD
|
// DOGEUSD -> DOGE-USD
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isEmpty, isNumber, uniq } from 'lodash';
|
import { isEmpty, isNumber, uniq } from 'lodash';
|
||||||
@ -140,7 +140,7 @@ export class ExchangeRateDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback with error, if currencies are not available
|
// Fallback with error, if currencies are not available
|
||||||
console.error(
|
Logger.error(
|
||||||
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
|
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
|
||||||
);
|
);
|
||||||
return aValue;
|
return aValue;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
@ -5,13 +6,15 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
|||||||
export interface EnhancedSymbolProfile {
|
export interface EnhancedSymbolProfile {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
assetSubClass: AssetSubClass;
|
assetSubClass: AssetSubClass;
|
||||||
|
countries: Country[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
updatedAt: Date;
|
scraperConfiguration?: ScraperConfiguration | null;
|
||||||
symbol: string;
|
|
||||||
countries: Country[];
|
|
||||||
sectors: Sector[];
|
sectors: Sector[];
|
||||||
|
symbol: string;
|
||||||
|
symbolMapping?: { [key: string]: string };
|
||||||
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
11
apps/api/src/services/symbol-profile.module.ts
Normal file
11
apps/api/src/services/symbol-profile.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SymbolProfileService } from './symbol-profile.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [SymbolProfileService],
|
||||||
|
exports: [SymbolProfileService]
|
||||||
|
})
|
||||||
|
export class SymbolProfileModule {}
|
@ -7,6 +7,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Prisma, SymbolProfile } from '@prisma/client';
|
import { Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { continents, countries } from 'countries-list';
|
import { continents, countries } from 'countries-list';
|
||||||
|
|
||||||
|
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
@ -29,7 +31,9 @@ export class SymbolProfileService {
|
|||||||
return symbolProfiles.map((symbolProfile) => ({
|
return symbolProfiles.map((symbolProfile) => ({
|
||||||
...symbolProfile,
|
...symbolProfile,
|
||||||
countries: this.getCountries(symbolProfile),
|
countries: this.getCountries(symbolProfile),
|
||||||
sectors: this.getSectors(symbolProfile)
|
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||||
|
sectors: this.getSectors(symbolProfile),
|
||||||
|
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +53,22 @@ export class SymbolProfileService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getScraperConfiguration(
|
||||||
|
symbolProfile: SymbolProfile
|
||||||
|
): ScraperConfiguration {
|
||||||
|
const scraperConfiguration =
|
||||||
|
symbolProfile.scraperConfiguration as Prisma.JsonObject;
|
||||||
|
|
||||||
|
if (scraperConfiguration) {
|
||||||
|
return {
|
||||||
|
selector: scraperConfiguration.selector as string,
|
||||||
|
url: scraperConfiguration.url as string
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private getSectors(symbolProfile: SymbolProfile): Sector[] {
|
private getSectors(symbolProfile: SymbolProfile): Sector[] {
|
||||||
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
|
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
|
||||||
(sector) => {
|
(sector) => {
|
||||||
@ -61,4 +81,12 @@ export class SymbolProfileService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSymbolMapping(symbolProfile: SymbolProfile) {
|
||||||
|
return (
|
||||||
|
(symbolProfile['symbolMapping'] as {
|
||||||
|
[key: string]: string;
|
||||||
|
}) ?? {}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,13 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'p',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/public/public-page.module').then(
|
||||||
|
(m) => m.PublicPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio',
|
path: 'portfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<header>
|
<header>
|
||||||
<gf-header
|
<gf-header
|
||||||
class="position-fixed px-2 w-100"
|
class="position-fixed w-100"
|
||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
|
@ -1,18 +1,52 @@
|
|||||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="granteeAlias">
|
<ng-container matColumnDef="granteeAlias">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
{{ element.granteeAlias }}
|
{{ element.granteeAlias }}
|
||||||
</td></ng-container
|
</td>
|
||||||
>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="type">
|
<ng-container matColumnDef="type">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
|
<ng-container>
|
||||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||||
Restricted View
|
Restricted View
|
||||||
</td></ng-container
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="details">
|
||||||
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
|
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||||
|
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||||
|
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank"
|
||||||
|
>{{ baseUrl }}/p/{{ element.id }}</a
|
||||||
>
|
>
|
||||||
|
</ng-container>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
||||||
|
|
||||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="transactionMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||||
|
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
|
@ -2,4 +2,12 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(var(--palette-primary-300), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit,
|
||||||
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
@ -16,17 +18,37 @@ import { Access } from '@ghostfolio/common/interfaces';
|
|||||||
})
|
})
|
||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
|
@Input() showActions: boolean;
|
||||||
|
|
||||||
|
@Output() accessDeleted = new EventEmitter<string>();
|
||||||
|
|
||||||
|
public baseUrl = window.location.origin;
|
||||||
public dataSource: MatTableDataSource<Access>;
|
public dataSource: MatTableDataSource<Access>;
|
||||||
public displayedColumns = ['granteeAlias', 'type'];
|
public displayedColumns = [];
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
|
this.displayedColumns = ['granteeAlias', 'type', 'details'];
|
||||||
|
|
||||||
|
if (this.showActions) {
|
||||||
|
this.displayedColumns.push('actions');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.accesses) {
|
if (this.accesses) {
|
||||||
this.dataSource = new MatTableDataSource(this.accesses);
|
this.dataSource = new MatTableDataSource(this.accesses);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onDeleteAccess(aId: string) {
|
||||||
|
const confirmation = confirm(
|
||||||
|
'Do you really want to revoke this granted access?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.accessDeleted.emit(aId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
|
||||||
import { AccessTableComponent } from './access-table.component';
|
import { AccessTableComponent } from './access-table.component';
|
||||||
@ -7,7 +9,7 @@ import { AccessTableComponent } from './access-table.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AccessTableComponent],
|
declarations: [AccessTableComponent],
|
||||||
exports: [AccessTableComponent],
|
exports: [AccessTableComponent],
|
||||||
imports: [CommonModule, MatTableModule],
|
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
|
||||||
providers: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
<mat-toolbar class="p-0">
|
<mat-toolbar class="px-2">
|
||||||
<ng-container *ngIf="user">
|
<ng-container *ngIf="user">
|
||||||
<a [routerLink]="['/']" class="no-min-width px-2" mat-button>
|
<a
|
||||||
|
[routerLink]="['/']"
|
||||||
|
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
|
||||||
|
mat-button
|
||||||
|
>
|
||||||
<gf-logo></gf-logo>
|
<gf-logo></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
@ -221,12 +225,17 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="user === null">
|
<ng-container *ngIf="user === null">
|
||||||
<a
|
<a
|
||||||
*ngIf="currentRoute && currentRoute !== 'start'"
|
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
|
||||||
class="mx-2 no-min-width px-2"
|
|
||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo></gf-logo>
|
<gf-logo
|
||||||
|
[hideName]="
|
||||||
|
!currentRoute ||
|
||||||
|
currentRoute === 'register' ||
|
||||||
|
currentRoute === 'start'
|
||||||
|
"
|
||||||
|
></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
|
@ -103,7 +103,9 @@
|
|||||||
></ngx-skeleton-loader>
|
></ngx-skeleton-loader>
|
||||||
|
|
||||||
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
|
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
|
||||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
<gf-no-transactions-info-indicator
|
||||||
|
[hasBorder]="false"
|
||||||
|
></gf-no-transactions-info-indicator>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -24,7 +24,9 @@
|
|||||||
></gf-position>
|
></gf-position>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div *ngIf="!hasPositions" class="p-3 text-center">
|
<div *ngIf="!hasPositions" class="p-3 text-center">
|
||||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
<gf-no-transactions-info-indicator
|
||||||
|
[hasBorder]="false"
|
||||||
|
></gf-no-transactions-info-indicator>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<mat-card *ngIf="rules === null" class="my-2 text-center">
|
<mat-card *ngIf="rules === null" class="my-2 text-center">
|
||||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
<gf-no-transactions-info-indicator
|
||||||
|
[hasBorder]="false"
|
||||||
|
></gf-no-transactions-info-indicator>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule>
|
<gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule>
|
||||||
|
@ -270,3 +270,9 @@
|
|||||||
width: '100%'
|
width: '100%'
|
||||||
}"
|
}"
|
||||||
></ngx-skeleton-loader>
|
></ngx-skeleton-loader>
|
||||||
|
|
||||||
|
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
|
||||||
|
<gf-no-transactions-info-indicator
|
||||||
|
[hasBorder]="false"
|
||||||
|
></gf-no-transactions-info-indicator>
|
||||||
|
</div>
|
||||||
|
@ -10,6 +10,7 @@ import { MatSortModule } from '@angular/material/sort';
|
|||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||||
|
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ import { TransactionsTableComponent } from './transactions-table.component';
|
|||||||
exports: [TransactionsTableComponent],
|
exports: [TransactionsTableComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfNoTransactionsInfoModule,
|
||||||
GfPositionDetailDialogModule,
|
GfPositionDetailDialogModule,
|
||||||
GfSymbolIconModule,
|
GfSymbolIconModule,
|
||||||
GfSymbolModule,
|
GfSymbolModule,
|
||||||
|
@ -18,6 +18,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
'/about',
|
'/about',
|
||||||
'/de/blog',
|
'/de/blog',
|
||||||
'/en/blog',
|
'/en/blog',
|
||||||
|
'/p',
|
||||||
'/pricing',
|
'/pricing',
|
||||||
'/register',
|
'/register',
|
||||||
'/resources'
|
'/resources'
|
||||||
|
@ -107,7 +107,7 @@
|
|||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-12 col-md-3 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
|
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
|
||||||
{{ statistics?.activeUsers1d ?? '-' }}
|
{{ statistics?.activeUsers1d ?? '-' }}
|
||||||
</h3>
|
</h3>
|
||||||
@ -117,7 +117,17 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-3 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
|
<h3 class="mb-0" [hidden]="!statistics?.activeUsers7d">
|
||||||
|
{{ statistics?.activeUsers7d ?? '-' }}
|
||||||
|
</h3>
|
||||||
|
<div class="h6 mb-0">
|
||||||
|
<span i18n>Active Users</span> <small class="text-muted"
|
||||||
|
>(Last 7 days)</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
|
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
|
||||||
{{ statistics?.activeUsers30d ?? '-' }}
|
{{ statistics?.activeUsers30d ?? '-' }}
|
||||||
</h3>
|
</h3>
|
||||||
@ -127,13 +137,23 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-3 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
|
<h3 class="mb-0" [hidden]="!statistics?.newUsers30d">
|
||||||
|
{{ statistics?.newUsers30d ?? '-' }}
|
||||||
|
</h3>
|
||||||
|
<div class="h6 mb-0">
|
||||||
|
<span i18n>New Users</span> <small class="text-muted"
|
||||||
|
>(Last 30 days)</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.gitHubContributors">
|
<h3 class="mb-0" [hidden]="!statistics?.gitHubContributors">
|
||||||
{{ statistics?.gitHubContributors ?? '-' }}
|
{{ statistics?.gitHubContributors ?? '-' }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-3 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
|
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
|
||||||
{{ statistics?.gitHubStargazers ?? '-' }}
|
{{ statistics?.gitHubStargazers ?? '-' }}
|
||||||
</h3>
|
</h3>
|
||||||
@ -198,7 +218,7 @@
|
|||||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
||||||
<mat-card class="changelog">
|
<mat-card class="changelog">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<markdown [src]="'CHANGELOG.md'"></markdown>
|
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
@ -209,7 +229,7 @@
|
|||||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
<h3 class="mb-3 text-center" i18n>License</h3>
|
||||||
<mat-card>
|
<mat-card>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<markdown [src]="'LICENSE'"></markdown>
|
<markdown [src]="'assets/LICENSE'"></markdown>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,20 +5,26 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import {
|
import {
|
||||||
MatSlideToggle,
|
MatSlideToggle,
|
||||||
MatSlideToggleChange
|
MatSlideToggleChange
|
||||||
} from '@angular/material/slide-toggle';
|
} from '@angular/material/slide-toggle';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
|
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { StripeService } from 'ngx-stripe';
|
import { StripeService } from 'ngx-stripe';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'mb-5' },
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-account-page',
|
selector: 'gf-account-page',
|
||||||
@ -35,7 +41,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public couponId: string;
|
public couponId: string;
|
||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
|
public deviceType: string;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
|
public hasPermissionToCreateAccess: boolean;
|
||||||
|
public hasPermissionToDeleteAccess: boolean;
|
||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
public price: number;
|
public price: number;
|
||||||
@ -50,6 +59,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private stripeService: StripeService,
|
private stripeService: StripeService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
public webAuthnService: WebAuthnService
|
public webAuthnService: WebAuthnService
|
||||||
@ -65,6 +78,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
permissions.enableSubscription
|
permissions.enableSubscription
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccess = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.deleteAccess
|
||||||
|
);
|
||||||
|
|
||||||
this.price = subscriptions?.[0]?.price;
|
this.price = subscriptions?.[0]?.price;
|
||||||
this.priceId = subscriptions?.[0]?.priceId;
|
this.priceId = subscriptions?.[0]?.priceId;
|
||||||
|
|
||||||
@ -74,6 +92,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToCreateAccess = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.createAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccess = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.deleteAccess
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionToUpdateUserSettings = hasPermission(
|
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||||
this.user.permissions,
|
this.user.permissions,
|
||||||
permissions.updateUserSettings
|
permissions.updateUserSettings
|
||||||
@ -87,12 +115,22 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (params['createDialog']) {
|
||||||
|
this.openCreateAccessDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the controller
|
* Initializes the controller
|
||||||
*/
|
*/
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,6 +174,17 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onDeleteAccess(aId: string) {
|
||||||
|
this.dataService
|
||||||
|
.deleteAccess(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ isRestrictedView: aEvent.checked })
|
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||||
@ -175,6 +224,38 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openCreateAccessDialog(): void {
|
||||||
|
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
|
||||||
|
data: {
|
||||||
|
access: {
|
||||||
|
type: 'PUBLIC'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data: any) => {
|
||||||
|
const access: CreateAccessDto = data?.access;
|
||||||
|
|
||||||
|
if (access) {
|
||||||
|
this.dataService
|
||||||
|
.postAccess({})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private deregisterDevice() {
|
private deregisterDevice() {
|
||||||
this.webAuthnService
|
this.webAuthnService
|
||||||
.deregister()
|
.deregister()
|
||||||
|
@ -132,10 +132,26 @@
|
|||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="accesses?.length > 0" class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
|
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
|
||||||
<gf-access-table [accesses]="accesses"></gf-access-table>
|
<gf-access-table
|
||||||
|
[accesses]="accesses"
|
||||||
|
[showActions]="hasPermissionToDeleteAccess"
|
||||||
|
(accessDeleted)="onDeleteAccess($event)"
|
||||||
|
></gf-access-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
|
||||||
|
<a
|
||||||
|
class="align-items-center d-flex justify-content-center"
|
||||||
|
color="primary"
|
||||||
|
mat-fab
|
||||||
|
[routerLink]="[]"
|
||||||
|
[queryParams]="{ createDialog: true }"
|
||||||
|
>
|
||||||
|
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,6 +12,7 @@ import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/acce
|
|||||||
|
|
||||||
import { AccountPageRoutingModule } from './account-page-routing.module';
|
import { AccountPageRoutingModule } from './account-page-routing.module';
|
||||||
import { AccountPageComponent } from './account-page.component';
|
import { AccountPageComponent } from './account-page.component';
|
||||||
|
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AccountPageComponent],
|
declarations: [AccountPageComponent],
|
||||||
@ -20,6 +21,7 @@ import { AccountPageComponent } from './account-page.component';
|
|||||||
AccountPageRoutingModule,
|
AccountPageRoutingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
GfCreateOrUpdateAccessDialogModule,
|
||||||
GfPortfolioAccessTableModule,
|
GfPortfolioAccessTableModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
|
@ -2,6 +2,26 @@
|
|||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
gf-access-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 100%;
|
||||||
|
|
||||||
|
.mat-row,
|
||||||
|
.mat-header-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-container {
|
||||||
|
position: fixed;
|
||||||
|
right: 2rem;
|
||||||
|
bottom: 2rem;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
.hint-text {
|
.hint-text {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-create-or-update-access-dialog',
|
||||||
|
styleUrls: ['./create-or-update-access-dialog.scss'],
|
||||||
|
templateUrl: 'create-or-update-access-dialog.html'
|
||||||
|
})
|
||||||
|
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {}
|
||||||
|
|
||||||
|
public onCancel(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
|
||||||
|
<h1 i18n mat-dialog-title>Grant access</h1>
|
||||||
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Type</mat-label>
|
||||||
|
<mat-select name="type" required [(value)]="data.access.type">
|
||||||
|
<mat-option i18n value="PUBLIC">Public</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[disabled]="!addAccessForm.form.valid"
|
||||||
|
[mat-dialog-close]="data"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,25 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
|
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [CreateOrUpdateAccessDialog],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
providers: []
|
||||||
|
})
|
||||||
|
export class GfCreateOrUpdateAccessDialogModule {}
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface CreateOrUpdateAccessDialogParams {
|
||||||
|
access: Access;
|
||||||
|
}
|
@ -45,7 +45,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.routeQueryParams = route.queryParams
|
this.route.queryParams
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
if (params['createDialog']) {
|
if (params['createDialog']) {
|
||||||
|
@ -12,8 +12,8 @@
|
|||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Type</mat-label>
|
<mat-label i18n>Type</mat-label>
|
||||||
<mat-select name="type" required [(value)]="data.account.accountType">
|
<mat-select name="type" required [(value)]="data.account.accountType">
|
||||||
<mat-option value="CASH" i18n>Cash</mat-option>
|
<mat-option i18n value="CASH">Cash</mat-option>
|
||||||
<mat-option value="SECURITIES" i18n>Securities</mat-option>
|
<mat-option i18n value="SECURITIES">Securities</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
@ -61,7 +61,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
|||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
public hasPositions: boolean;
|
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public isLoadingPerformance = true;
|
public isLoadingPerformance = true;
|
||||||
public isLoadingSummary = true;
|
public isLoadingSummary = true;
|
||||||
@ -225,7 +224,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((response) => {
|
.subscribe((response) => {
|
||||||
this.positions = response.positions;
|
this.positions = response.positions;
|
||||||
this.hasPositions = this.positions?.length > 0;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -92,7 +92,6 @@
|
|||||||
(change)="onChangeDateRange($event.value)"
|
(change)="onChangeDateRange($event.value)"
|
||||||
></gf-toggle>
|
></gf-toggle>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="hasPositions === true">
|
|
||||||
<mat-card class="p-0">
|
<mat-card class="p-0">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<gf-positions
|
<gf-positions
|
||||||
@ -113,13 +112,6 @@
|
|||||||
>Manage Transactions...</a
|
>Manage Transactions...</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
|
||||||
<div
|
|
||||||
*ngIf="hasPositions === false"
|
|
||||||
class="d-flex justify-content-center"
|
|
||||||
>
|
|
||||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
@ -15,7 +14,6 @@ import { Subject } from 'rxjs';
|
|||||||
export class LandingPageComponent implements OnDestroy, OnInit {
|
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||||
public currentYear = format(new Date(), 'yyyy');
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
public demoAuthToken: string;
|
public demoAuthToken: string;
|
||||||
public historicalDataItems: LineChartItem[];
|
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -23,7 +21,6 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
|||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private tokenStorageService: TokenStorageService
|
private tokenStorageService: TokenStorageService
|
||||||
@ -36,221 +33,6 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
|||||||
const { demoAuthToken } = this.dataService.fetchInfo();
|
const { demoAuthToken } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
this.demoAuthToken = demoAuthToken;
|
this.demoAuthToken = demoAuthToken;
|
||||||
|
|
||||||
this.initializeLineChart();
|
|
||||||
}
|
|
||||||
|
|
||||||
public initializeLineChart() {
|
|
||||||
this.historicalDataItems = [
|
|
||||||
{
|
|
||||||
date: '2017-01-01',
|
|
||||||
value: 2561.60376
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-02-01',
|
|
||||||
value: 2261.60376
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-03-01',
|
|
||||||
value: 2268.68157074
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-04-01',
|
|
||||||
value: 2525.2942
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-05-01',
|
|
||||||
value: 2929.3595107399997
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-06-01',
|
|
||||||
value: 3088.5172438900004
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-07-01',
|
|
||||||
value: 3281.2490946300004
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-08-01',
|
|
||||||
value: 4714.57822537
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-09-01',
|
|
||||||
value: 5717.262455259565
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-10-01',
|
|
||||||
value: 5338.742482334544
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-11-01',
|
|
||||||
value: 6361.263771554509
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2017-12-01',
|
|
||||||
value: 8373.260491904595
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-01-01',
|
|
||||||
value: 9783.208968191562
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-02-01',
|
|
||||||
value: 7841.2667100173
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-03-01',
|
|
||||||
value: 8582.133039380678
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-04-01',
|
|
||||||
value: 5901.8362986766715
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-05-01',
|
|
||||||
value: 7367.392976151925
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-06-01',
|
|
||||||
value: 6490.164314049853
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-07-01',
|
|
||||||
value: 6365.351621654618
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-08-01',
|
|
||||||
value: 6614.532706931272
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-09-01',
|
|
||||||
value: 6402.052882414409
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-10-01',
|
|
||||||
value: 15270.327917651943
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-11-01',
|
|
||||||
value: 13929.833891940816
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2018-12-01',
|
|
||||||
value: 12995.832254431414
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-01-01',
|
|
||||||
value: 11792.447013828998
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-02-01',
|
|
||||||
value: 11988.224284346446
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-03-01',
|
|
||||||
value: 13536.39667099519
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-04-01',
|
|
||||||
value: 14301.83740090022
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-05-01',
|
|
||||||
value: 14902.994910520581
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-06-01',
|
|
||||||
value: 15373.418326284132
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-07-01',
|
|
||||||
value: 17545.70705465703
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-08-01',
|
|
||||||
value: 17206.28500223782
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-09-01',
|
|
||||||
value: 17782.445200108898
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-10-01',
|
|
||||||
value: 17050.25875252655
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-11-01',
|
|
||||||
value: 18517.053521416237
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2019-12-01',
|
|
||||||
value: 17850.649021651363
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-01-01',
|
|
||||||
value: 18817.269786559067
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-02-01',
|
|
||||||
value: 22769.842312027653
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-03-01',
|
|
||||||
value: 23065.56002316582
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-04-01',
|
|
||||||
value: 19738.122641884733
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-05-01',
|
|
||||||
value: 25112.281463810643
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-06-01',
|
|
||||||
value: 28753.054132147452
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-07-01',
|
|
||||||
value: 32207.62827031543
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-08-01',
|
|
||||||
value: 37837.88816828611
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-09-01',
|
|
||||||
value: 50018.740185519295
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-10-01',
|
|
||||||
value: 46593.322295801525
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-11-01',
|
|
||||||
value: 44440.18743231742
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2020-12-01',
|
|
||||||
value: 57582.23077536893
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2021-01-01',
|
|
||||||
value: 68823.02446077733
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2021-02-01',
|
|
||||||
value: 64516.42092139593
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2021-03-01',
|
|
||||||
value: 82465.97581106682
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2021-03-18',
|
|
||||||
value: 86666.03082624623
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setToken(aToken: string) {
|
public setToken(aToken: string) {
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
|
<div class="intro-container mb-5">
|
||||||
|
<div class="intro-inner-container mx-auto">
|
||||||
|
<div class="h-100 intro w-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div
|
<div
|
||||||
class="
|
class="align-items-center d-flex flex-column justify-content-center w-100"
|
||||||
align-items-center
|
|
||||||
d-flex
|
|
||||||
flex-column
|
|
||||||
justify-content-center
|
|
||||||
mb-4
|
|
||||||
w-100
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<gf-logo size="large"></gf-logo>
|
<gf-logo size="large"></gf-logo>
|
||||||
<p class="lead m-0">Wealth Management Software</p>
|
<p class="lead m-0">Wealth Management Software</p>
|
||||||
@ -16,15 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="button-container row">
|
<div class="button-container row">
|
||||||
<div
|
<div class="align-items-center col d-flex justify-content-center">
|
||||||
class="
|
|
||||||
align-items-center
|
|
||||||
col
|
|
||||||
d-flex
|
|
||||||
justify-content-center
|
|
||||||
position-relative
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="py-5 text-center">
|
<div class="py-5 text-center">
|
||||||
<a
|
<a
|
||||||
class="d-inline-block"
|
class="d-inline-block"
|
||||||
@ -46,15 +37,6 @@
|
|||||||
<span i18n>Live Demo</span>
|
<span i18n>Live Demo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<gf-line-chart
|
|
||||||
class="position-absolute"
|
|
||||||
symbol="Performance"
|
|
||||||
[historicalDataItems]="historicalDataItems"
|
|
||||||
[showGradient]="true"
|
|
||||||
[showLoader]="false"
|
|
||||||
[showXAxis]="false"
|
|
||||||
[showYAxis]="false"
|
|
||||||
></gf-line-chart>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
|
||||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
|
|
||||||
import { LandingPageRoutingModule } from './landing-page-routing.module';
|
import { LandingPageRoutingModule } from './landing-page-routing.module';
|
||||||
@ -13,7 +12,6 @@ import { LandingPageComponent } from './landing-page.component';
|
|||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfLineChartModule,
|
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
LandingPageRoutingModule,
|
LandingPageRoutingModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
@ -2,17 +2,6 @@
|
|||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.button-container {
|
.button-container {
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
|
|
||||||
gf-line-chart {
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
opacity: 0.67;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-stroked-button {
|
.mat-stroked-button {
|
||||||
background-color: var(--light-background);
|
background-color: var(--light-background);
|
||||||
}
|
}
|
||||||
@ -23,6 +12,23 @@
|
|||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.intro-container {
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin-top: -5rem;
|
||||||
|
|
||||||
|
.intro-inner-container {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-height: 66vh;
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
background-image: url('/assets/intro.jpg');
|
||||||
|
background-position: top left;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
|
@ -4,6 +4,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
|||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
@ -246,9 +247,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (position.assetClass === AssetClass.EQUITY) {
|
if (position.assetClass === AssetClass.EQUITY) {
|
||||||
this.symbols[symbol] = {
|
this.symbols[prettifySymbol(symbol)] = {
|
||||||
symbol,
|
|
||||||
name: position.name,
|
name: position.name,
|
||||||
|
symbol: prettifySymbol(symbol),
|
||||||
value: aPeriod === 'original' ? position.investment : position.value
|
value: aPeriod === 'original' ? position.investment : position.value
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -159,9 +159,9 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<gf-portfolio-proportion-chart
|
<gf-portfolio-proportion-chart
|
||||||
[keys]="['name']"
|
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||||
|
[keys]="['name']"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[maxItems]="10"
|
[maxItems]="10"
|
||||||
[positions]="countries"
|
[positions]="countries"
|
||||||
|
@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
.mat-expansion-panel-header {
|
.mat-expansion-panel-header {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
|
&[aria-disabled='true'] {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import { User } from '@ghostfolio/common/interfaces';
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DataSource, Order as OrderModel } from '@prisma/client';
|
import { DataSource, Order as OrderModel } from '@prisma/client';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { isArray } from 'lodash';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -189,6 +190,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
|||||||
try {
|
try {
|
||||||
if (file.type === 'application/json') {
|
if (file.type === 'application/json') {
|
||||||
const content = JSON.parse(fileContent);
|
const content = JSON.parse(fileContent);
|
||||||
|
|
||||||
|
if (!isArray(content.orders)) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.importTransactionsService.importJson({
|
await this.importTransactionsService.importJson({
|
||||||
content: content.orders,
|
content: content.orders,
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { PublicPageComponent } from './public-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: ':id', component: PublicPageComponent, canActivate: [AuthGuard] }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class PublicPageRoutingModule {}
|
185
apps/client/src/app/pages/public/public-page.component.ts
Normal file
185
apps/client/src/app/pages/public/public-page.component.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
PortfolioPosition,
|
||||||
|
PortfolioPublicDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
|
selector: 'gf-public-page',
|
||||||
|
styleUrls: ['./public-page.scss'],
|
||||||
|
templateUrl: './public-page.html'
|
||||||
|
})
|
||||||
|
export class PublicPageComponent implements OnInit {
|
||||||
|
public continents: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public countries: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public deviceType: string;
|
||||||
|
public portfolioPublicDetails: PortfolioPublicDetails;
|
||||||
|
public positions: {
|
||||||
|
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name' | 'value'>;
|
||||||
|
};
|
||||||
|
public sectors: {
|
||||||
|
[name: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public symbols: {
|
||||||
|
[name: string]: { name: string; symbol: string; value: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
private id: string;
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private router: Router
|
||||||
|
) {
|
||||||
|
this.activatedRoute.params.subscribe((params) => {
|
||||||
|
this.id = params['id'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPortfolioPublic(this.id)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError((error) => {
|
||||||
|
if (error.status === StatusCodes.NOT_FOUND) {
|
||||||
|
console.error(error);
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((portfolioPublicDetails) => {
|
||||||
|
this.portfolioPublicDetails = portfolioPublicDetails;
|
||||||
|
|
||||||
|
this.initializeAnalysisData();
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public initializeAnalysisData() {
|
||||||
|
this.continents = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.countries = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.positions = {};
|
||||||
|
this.sectors = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.symbols = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
symbol: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [symbol, position] of Object.entries(
|
||||||
|
this.portfolioPublicDetails.holdings
|
||||||
|
)) {
|
||||||
|
const value = position.allocationCurrent;
|
||||||
|
|
||||||
|
this.positions[symbol] = {
|
||||||
|
value,
|
||||||
|
currency: position.currency,
|
||||||
|
name: position.name
|
||||||
|
};
|
||||||
|
|
||||||
|
if (position.countries.length > 0) {
|
||||||
|
for (const country of position.countries) {
|
||||||
|
const { code, continent, name, weight } = country;
|
||||||
|
|
||||||
|
if (this.continents[continent]?.value) {
|
||||||
|
this.continents[continent].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.continents[continent] = {
|
||||||
|
name: continent,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.countries[code]?.value) {
|
||||||
|
this.countries[code].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.countries[code] = {
|
||||||
|
name,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.continents[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
|
||||||
|
this.countries[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.sectors.length > 0) {
|
||||||
|
for (const sector of position.sectors) {
|
||||||
|
const { name, weight } = sector;
|
||||||
|
|
||||||
|
if (this.sectors[name]?.value) {
|
||||||
|
this.sectors[name].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.sectors[name] = {
|
||||||
|
name,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.sectors[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.symbols[prettifySymbol(symbol)] = {
|
||||||
|
name: position.name,
|
||||||
|
symbol: prettifySymbol(symbol),
|
||||||
|
value: position.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
102
apps/client/src/app/pages/public/public-page.html
Normal file
102
apps/client/src/app/pages/public/public-page.html
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h3 class="h4 mb-3 text-center" i18n>
|
||||||
|
Hello, someone has shared a <strong>Portfolio</strong> with you!
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proportion-charts row">
|
||||||
|
<div class="col-md-12 allocations-by-symbol">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
|
<mat-card-title class="text-truncate" i18n>Symbols</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
class="mx-auto"
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['symbol']"
|
||||||
|
[positions]="symbols"
|
||||||
|
[showLabels]="deviceType !== 'mobile'"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
|
<mat-card-title class="text-truncate" i18n>Currencies</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['currency']"
|
||||||
|
[maxItems]="10"
|
||||||
|
[positions]="positions"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
|
<mat-card-title class="text-truncate" i18n>Sectors</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['name']"
|
||||||
|
[maxItems]="10"
|
||||||
|
[positions]="sectors"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
|
<mat-card-title class="text-truncate" i18n>Continents</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['name']"
|
||||||
|
[positions]="continents"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="portfolioPublicDetails?.hasDetails" class="row world-map-chart">
|
||||||
|
<div class="col-lg">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="overflow-hidden w-100">
|
||||||
|
<mat-card-title class="text-truncate" i18n>Regions</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-world-map-chart
|
||||||
|
[countries]="countries"
|
||||||
|
[isInPercent]="true"
|
||||||
|
></gf-world-map-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row my-5">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<h2 class="h4 mb-1 text-center">
|
||||||
|
Would you like to <strong>refine</strong> your
|
||||||
|
<strong>personal investment strategy</strong>?
|
||||||
|
</h2>
|
||||||
|
<p class="lead mb-3 text-center" i18n>
|
||||||
|
Ghostfolio empowers you to keep track of your wealth.
|
||||||
|
</p>
|
||||||
|
<div class="py-2 text-center">
|
||||||
|
<a color="primary" i18n mat-flat-button [routerLink]="['/']">
|
||||||
|
Get Started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
25
apps/client/src/app/pages/public/public-page.module.ts
Normal file
25
apps/client/src/app/pages/public/public-page.module.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||||
|
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
|
|
||||||
|
import { PublicPageRoutingModule } from './public-page-routing.module';
|
||||||
|
import { PublicPageComponent } from './public-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [PublicPageComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPortfolioProportionChartModule,
|
||||||
|
GfWorldMapChartModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
PublicPageRoutingModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class PublicPageModule {}
|
22
apps/client/src/app/pages/public/public-page.scss
Normal file
22
apps/client/src/app/pages/public/public-page.scss
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.allocations-by-symbol {
|
||||||
|
gf-portfolio-proportion-chart {
|
||||||
|
max-width: 80vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-map-chart {
|
||||||
|
.mat-card {
|
||||||
|
.mat-card-content {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -6,6 +6,7 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-to
|
|||||||
export class RegisterPageComponent implements OnDestroy, OnInit {
|
export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||||
public currentYear = format(new Date(), 'yyyy');
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
public demoAuthToken: string;
|
public demoAuthToken: string;
|
||||||
|
public deviceType: string;
|
||||||
public hasPermissionForSocialLogin: boolean;
|
public hasPermissionForSocialLogin: boolean;
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
|
|
||||||
@ -29,8 +31,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
|||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private tokenStorageService: TokenStorageService
|
private tokenStorageService: TokenStorageService
|
||||||
@ -45,6 +47,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
|||||||
const { demoAuthToken, globalPermissions } = this.dataService.fetchInfo();
|
const { demoAuthToken, globalPermissions } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
this.demoAuthToken = demoAuthToken;
|
this.demoAuthToken = demoAuthToken;
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
this.hasPermissionForSocialLogin = hasPermission(
|
this.hasPermissionForSocialLogin = hasPermission(
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
permissions.enableSocialLogin
|
permissions.enableSocialLogin
|
||||||
|
@ -1,11 +1,22 @@
|
|||||||
|
<div class="intro-container mb-5">
|
||||||
|
<div class="intro-inner-container mx-auto">
|
||||||
|
<div class="h-100 intro w-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div
|
||||||
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
class="align-items-center d-flex flex-column justify-content-center w-100"
|
||||||
Create your Account
|
>
|
||||||
</h3>
|
<gf-logo size="large"></gf-logo>
|
||||||
<mat-card class="mb-4">
|
<p class="lead m-0">Wealth Management Software</p>
|
||||||
<mat-card-content class="text-center">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-container row">
|
||||||
|
<div class="align-items-center col d-flex justify-content-center">
|
||||||
|
<div class="py-5 text-center">
|
||||||
<button
|
<button
|
||||||
class="d-inline-block"
|
class="d-inline-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
@ -17,14 +28,19 @@
|
|||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
<ng-container *ngIf="hasPermissionForSocialLogin">
|
<ng-container *ngIf="hasPermissionForSocialLogin">
|
||||||
<div class="my-3 text-muted" i18n>or</div>
|
<div
|
||||||
|
class="m-3 text-muted"
|
||||||
|
i18n
|
||||||
|
[ngClass]="{'d-inline': deviceType !== 'mobile' }"
|
||||||
|
>
|
||||||
|
or
|
||||||
|
</div>
|
||||||
<a color="accent" href="/api/auth/google" mat-flat-button
|
<a color="accent" href="/api/auth/google" mat-flat-button
|
||||||
><ion-icon class="mr-1" name="logo-google"></ion-icon
|
><ion-icon class="mr-1" name="logo-google"></ion-icon
|
||||||
><span i18n>Continue with Google</span></a
|
><span i18n>Continue with Google</span></a
|
||||||
>
|
>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-card-content>
|
</div>
|
||||||
</mat-card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,3 +1,26 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
.button-container {
|
||||||
|
.mat-stroked-button {
|
||||||
|
background-color: var(--light-background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-container {
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin-top: -5rem;
|
||||||
|
|
||||||
|
.intro-inner-container {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-height: 66vh;
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
background-image: url('/assets/intro.jpg');
|
||||||
|
background-position: top left;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,7 +38,6 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
|
|||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
public hasPositions: boolean;
|
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public isLoadingPerformance = true;
|
public isLoadingPerformance = true;
|
||||||
public performance: PortfolioPerformance;
|
public performance: PortfolioPerformance;
|
||||||
@ -140,7 +139,6 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((response) => {
|
.subscribe((response) => {
|
||||||
this.positions = response.positions;
|
this.positions = response.positions;
|
||||||
this.hasPositions = this.positions?.length > 0;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -65,7 +65,6 @@
|
|||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
|
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="align-items-center col">
|
<div class="align-items-center col">
|
||||||
<ng-container *ngIf="hasPositions === true">
|
|
||||||
<mat-card class="p-0">
|
<mat-card class="p-0">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<gf-positions
|
<gf-positions
|
||||||
@ -86,15 +85,6 @@
|
|||||||
>Manage Transactions...</a
|
>Manage Transactions...</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
|
||||||
<div
|
|
||||||
*ngIf="hasPositions === false"
|
|
||||||
class="d-flex justify-content-center"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
@Pipe({ name: 'gfSymbol' })
|
@Pipe({ name: 'gfSymbol' })
|
||||||
export class SymbolPipe implements PipeTransform {
|
export class SymbolPipe implements PipeTransform {
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public transform(aSymbol: string): string {
|
public transform(aSymbol: string) {
|
||||||
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
|
return prettifySymbol(aSymbol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||||
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
|
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||||
import {
|
import {
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
User
|
User
|
||||||
@ -68,6 +69,10 @@ export class DataService {
|
|||||||
return this.http.get<AdminData>('/api/admin');
|
return this.http.get<AdminData>('/api/admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public deleteAccess(aId: string) {
|
||||||
|
return this.http.delete<any>(`/api/access/${aId}`);
|
||||||
|
}
|
||||||
|
|
||||||
public deleteAccount(aId: string) {
|
public deleteAccount(aId: string) {
|
||||||
return this.http.delete<any>(`/api/account/${aId}`);
|
return this.http.delete<any>(`/api/account/${aId}`);
|
||||||
}
|
}
|
||||||
@ -164,6 +169,12 @@ export class DataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchPortfolioPublic(aId: string) {
|
||||||
|
return this.http.get<PortfolioPublicDetails>(
|
||||||
|
`/api/portfolio/public/${aId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public fetchPortfolioReport() {
|
public fetchPortfolioReport() {
|
||||||
return this.http.get<PortfolioReport>('/api/portfolio/report');
|
return this.http.get<PortfolioReport>('/api/portfolio/report');
|
||||||
}
|
}
|
||||||
@ -190,6 +201,10 @@ export class DataService {
|
|||||||
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
|
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public postAccess(aAccess: CreateAccessDto) {
|
||||||
|
return this.http.post<OrderModel>(`/api/access`, aAccess);
|
||||||
|
}
|
||||||
|
|
||||||
public postAccount(aAccount: CreateAccountDto) {
|
public postAccount(aAccount: CreateAccountDto) {
|
||||||
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 289 KiB |
BIN
apps/client/src/assets/intro.jpg
Normal file
BIN
apps/client/src/assets/intro.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 140 KiB |
@ -29,5 +29,5 @@ import { environment } from './environments/environment';
|
|||||||
.bootstrapModule(AppModule, {
|
.bootstrapModule(AppModule, {
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }]
|
providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }]
|
||||||
})
|
})
|
||||||
.catch((err) => console.error(err));
|
.catch((error) => console.error(error));
|
||||||
})();
|
})();
|
||||||
|
24
docker/docker-compose-build-local.yml
Normal file
24
docker/docker-compose-build-local.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
version: '3.7'
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:12
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
volumes:
|
||||||
|
- postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: 'redis:alpine'
|
||||||
|
|
||||||
|
ghostfolio:
|
||||||
|
build: ../
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
environment:
|
||||||
|
REDIS_HOST: 'redis'
|
||||||
|
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
||||||
|
ports:
|
||||||
|
- 3333:3333
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres:
|
@ -3,8 +3,6 @@ import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
|
|||||||
|
|
||||||
import { ghostfolioScraperApiSymbolPrefix } from './config';
|
import { ghostfolioScraperApiSymbolPrefix } from './config';
|
||||||
|
|
||||||
const cryptocurrencies = require('cryptocurrencies');
|
|
||||||
|
|
||||||
export const DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
export const DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||||
|
|
||||||
export function capitalize(aString: string) {
|
export function capitalize(aString: string) {
|
||||||
@ -50,9 +48,9 @@ export function getUtc(aDateString: string) {
|
|||||||
|
|
||||||
return new Date(
|
return new Date(
|
||||||
Date.UTC(
|
Date.UTC(
|
||||||
parseInt(yearString),
|
parseInt(yearString, 10),
|
||||||
parseInt(monthString) - 1,
|
parseInt(monthString, 10) - 1,
|
||||||
parseInt(dayString)
|
parseInt(dayString, 10)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -79,12 +77,6 @@ export function groupBy<T, K extends keyof T>(
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCrypto(aSymbol = '') {
|
|
||||||
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
|
||||||
|
|
||||||
return cryptocurrencies.symbols().includes(cryptocurrencySymbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCurrency(aSymbol = '') {
|
export function isCurrency(aSymbol = '') {
|
||||||
return currencies[aSymbol];
|
return currencies[aSymbol];
|
||||||
}
|
}
|
||||||
@ -124,3 +116,7 @@ export const DATE_FORMAT = 'yyyy-MM-dd';
|
|||||||
export function parseDate(date: string) {
|
export function parseDate(date: string) {
|
||||||
return parse(date, DATE_FORMAT, new Date());
|
return parse(date, DATE_FORMAT, new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prettifySymbol(aSymbol: string): string {
|
||||||
|
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
|
||||||
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
export interface Access {
|
export interface Access {
|
||||||
granteeAlias: string;
|
granteeAlias: string;
|
||||||
|
id: string;
|
||||||
|
type: 'PUBLIC' | 'RESTRICTED_VIEW';
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user