Compare commits

...

37 Commits

Author SHA1 Message Date
7425ba94f1 Release 1.73.0 (#461) 2021-11-10 21:16:06 +01:00
b9522307c4 Feature/various client improvements (#460)
* Various improvements
  * info messages
  * skeleton loader of portfolio holdings

* Update changelog
2021-11-10 21:03:25 +01:00
e01e039a00 Release 1.72.0 (#459) 2021-11-08 20:55:38 +01:00
6d3513c17f Update version 2021-11-07 21:27:37 +01:00
d60b444324 Release/1.71.0 (#458)
* Refactor logger

* Fix exception

* Update changelog
2021-11-07 21:25:18 +01:00
2873130259 Release 1.70.0 (#457) 2021-11-07 18:38:29 +01:00
d999a27159 Feature/move scraper configuration to symbol profile (#456)
* Move scraper configuration

* Update changelog
2021-11-07 18:36:28 +01:00
b6902e10ea Feature/improve import json file validation (#455)
* Improve import validation

* Update changelog
2021-11-07 17:16:19 +01:00
7f3f75386d Release 1.69.0 (#454) 2021-11-07 09:45:19 +01:00
678544748a Add symbol mapping (#452)
* Add symbol mapping

* Update changelog
2021-11-07 09:42:36 +01:00
632f3e3872 Add ok.csv (#453) 2021-11-06 21:09:06 +01:00
87301ddbd5 Feature/improve registration page (#451)
* Improve registration page

* Update changelog
2021-11-02 21:49:57 +01:00
7d03c373ac Release 1.68.0 (#450) 2021-11-01 21:27:23 +01:00
edb66bb166 Feature/extend statistics (#449)
* Extend statistics

* Update changelog
2021-11-01 21:15:09 +01:00
54bbc8446b Feature/prettify scraper symbol in chart (#448)
* Prettify scraper symbol in chart

* Update changelog
2021-11-01 20:29:16 +01:00
9933967e42 Release 1.67.0 (#447) 2021-11-01 19:33:45 +01:00
5618513d07 Update changelog (#446) 2021-10-31 17:02:41 +01:00
1397cd62a8 Feature/extend public page (#445)
* Extend public page

* Update changelog
2021-10-31 17:00:59 +01:00
e7fb31d1a6 add Dockerfile with all in one docker image (#431)
* Add Dockerfile with all in one docker image

* Change to alpine image and reduce node_modules size

* Improve documentation and fix changelog and license

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-30 18:54:14 +02:00
51e7b94ad0 Release 1.66.0 (#443) 2021-10-30 11:22:50 +02:00
9133ea38f3 Feature/order accesses (#441)
* Order accesses

* Update changelog
2021-10-30 10:49:17 +02:00
a864c617b9 Feature/improve landing page (#442)
* Add intro screen

* Update changelog
2021-10-30 10:46:54 +02:00
442df9d6f8 Release 1.65.0 (#440) 2021-10-25 21:03:36 +02:00
2de0e75cb8 Feature/add user interface for granting and revoking public access (#439)
* Add user interface for granting and revoking public access

* Update changelog
2021-10-25 20:46:36 +02:00
1296f95602 Feature/move data enhancer from data provider to data gathering (#437)
* Move data enhancers from data provider to data enhancer module

* Update changelog
2021-10-24 10:49:17 +02:00
e98dff877a Add link to Visual Studio Code (#436) 2021-10-22 20:20:59 +02:00
964b37af30 Add Visual Studio Code link (#435) 2021-10-22 20:17:33 +02:00
2d89d12d31 Release 1.64.0 (#434) 2021-10-21 22:25:58 +02:00
19e2d54791 Refactor custom cryptocurrencies (#433) 2021-10-21 22:11:12 +02:00
e24e5e1c44 Add custom crypto currencies (#432)
* Add custom cryptocurrencies
2021-10-21 21:20:01 +02:00
a0d4ff7920 Clean up ts-node script (#430)
Co-Authored-By: Davin <52686922+Frozies@users.noreply.github.com>
2021-10-20 22:46:58 +02:00
099ad18aaf refactor data-provider.service.ts to have dynamic list of services (#429)
* Refactor data-provider.service.ts to have dynamic list of services

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-20 21:41:40 +02:00
e3cd99f5d2 Release 1.63.0 (#427) 2021-10-19 18:49:11 +02:00
6dea9093ba Feature/add public portfolio (#426)
* Setup public portfolio page

* Update changelog
2021-10-19 18:27:50 +02:00
43104f81d0 Add test data (#424) 2021-10-17 11:56:35 +02:00
6d2e3b6e40 Feature/improve skeleton loader size (#425)
* Improve skeleton loader size

* Update changelog
2021-10-17 11:53:27 +02:00
1d9a31dbb8 Improve styling (#423) 2021-10-17 11:42:27 +02:00
122 changed files with 1821 additions and 689 deletions

View File

@ -1 +1,2 @@
/dist /dist
/test/import

3
.vscode/launch.json vendored
View File

@ -26,7 +26,8 @@
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"
] ],
"console": "integratedTerminal"
} }
] ]
} }

View File

@ -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
View 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" ]

View File

@ -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`

View File

@ -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",

View File

@ -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
}
});
}
} }

View File

@ -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 {}

View File

@ -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
});
}
} }

View File

@ -0,0 +1 @@
export class CreateAccessDto {}

View File

@ -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);
} }
} }
} }

View File

@ -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 });
} }

View File

@ -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,

View File

@ -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(
{ {

View File

@ -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: [

View File

@ -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[]> {

View File

@ -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);

View File

@ -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
); );

View File

@ -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(

View File

@ -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 {}

View File

@ -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(

View File

@ -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')
}); });
} }
} }

View File

@ -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),

View File

@ -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);
} }
} }

View File

@ -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;
} }

View File

@ -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();
}
} }

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CryptocurrencyService } from './cryptocurrency.service';
@Module({
providers: [CryptocurrencyService],
exports: [CryptocurrencyService]
})
export class CryptocurrencyModule {}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
{
"1INCH": "1inch",
"AVAX": "Avalanche",
"MATIC": "Polygon",
"SHIB": "Shiba Inu"
}

View File

@ -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 {}

View File

@ -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
]; ];

View File

@ -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);

View File

@ -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 {}

View File

@ -70,4 +70,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return Promise.resolve(response); return Promise.resolve(response);
} }
public getName() {
return 'TRACKINSIGHT';
}
} }

View File

@ -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]

View File

@ -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.');
} }
} }
}

View File

@ -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;
});
}
} }

View File

@ -1,6 +0,0 @@
export interface ScraperConfig {
currency: string;
selector: string;
symbol: string;
url: string;
}

View File

@ -0,0 +1,4 @@
export interface ScraperConfiguration {
selector: string;
url: string;
}

View File

@ -8,4 +8,6 @@ export interface DataEnhancerInterface {
response: IDataProviderResponse; response: IDataProviderResponse;
symbol: string; symbol: string;
}): Promise<IDataProviderResponse>; }): Promise<IDataProviderResponse>;
getName(): string;
} }

View File

@ -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[] }>;
} }

View File

@ -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;
} }

View File

@ -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

View File

@ -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;

View File

@ -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;
} }

View 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 {}

View File

@ -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;
}) ?? {}
);
}
} }

View File

@ -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: () =>

View File

@ -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"

View File

@ -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>

View File

@ -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);
}
}
} }

View File

@ -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);
}
}
} }

View File

@ -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]
}) })

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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'

View File

@ -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>&nbsp;<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>&nbsp;<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>

View File

@ -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()

View File

@ -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>

View File

@ -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,

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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 {}

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams {
access: Access;
}

View File

@ -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']) {

View File

@ -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>

View File

@ -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();
}); });

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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,

View File

@ -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) {

View File

@ -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
}; };
} }

View File

@ -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"

View File

@ -7,6 +7,10 @@
.mat-expansion-panel-header { .mat-expansion-panel-header {
color: inherit; color: inherit;
&[aria-disabled='true'] {
cursor: default;
}
} }
} }
} }

View File

@ -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,

View File

@ -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 {}

View 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();
}
}

View 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>

View 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 {}

View 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));
}

View File

@ -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

View File

@ -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>

View File

@ -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;
}
}
}
} }

View File

@ -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();
}); });

View File

@ -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>

View File

@ -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);
} }
} }

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -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));
})(); })();

View 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:

View File

@ -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, '');
}

View File

@ -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