merge
This commit is contained in:
commit
f6262e2021
@ -1,7 +1,7 @@
|
||||
COMPOSE_PROJECT_NAME=ghostfolio
|
||||
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
# VARIOUS
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
110
CHANGELOG.md
110
CHANGELOG.md
@ -7,6 +7,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the _Copy link to clipboard_ action by adding a confirmation on success in the access table to share the portfolio
|
||||
- Improved the endpoint to fetch the logo of an asset or a platform by sending the original MIME type
|
||||
- Eliminated `got` in favor of using `fetch`
|
||||
- Changed the `REDIS_HOST` from `localhost` to `redis` in `.env.example`
|
||||
- Changed the _Postgres_ host from `localhost` to `postgres` in `.env.example`
|
||||
- Changed the _Postgres_ image from `postgres:15` to `postgres:15-alpine` in the `docker-compose` files
|
||||
- Introduced `extends` in the `docker-compose` files
|
||||
- Improved the language localization for German (`de`)
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Upgraded `envalid` from version `7.3.1` to `8.0.0`
|
||||
- Upgraded `replace-in-file` from version `7.0.1` to `8.3.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the handling of a missing url in the endpoint to fetch the logo of an asset or a platform
|
||||
|
||||
## 2.132.0 - 2024-12-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added the user interface for received access from others
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved support for automatic deletion of unused asset profiles when deleting activities
|
||||
- Migrated the coupon redemption to the notification service for prompt dialogs
|
||||
- Refactored `got` calls to use `AbortSignal.timeout()` without `AbortController()`
|
||||
- Improved the language localization for German (`de`)
|
||||
- Eliminated `body-parser` in favor of using `@nestjs/platform-express`
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `angular` from version `18.2.8` to `19.0.5`
|
||||
- Upgraded `husky` from version `9.1.6` to `9.1.7`
|
||||
- Upgraded `marked` from version `12.0.2` to `15.0.4`
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.12.0` to `2.13.1`
|
||||
- Upgraded `ngx-device-detector` from version `8.0.0` to `9.0.0`
|
||||
- Upgraded `ngx-markdown` from version `18.0.0` to `19.0.0`
|
||||
- Upgraded `Nx` from version `20.1.2` to `20.3.0`
|
||||
- Upgraded `prisma` from version `6.0.1` to `6.1.0`
|
||||
- Upgraded `zone.js` from version `0.14.10` to `0.15.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the algebraic sign in the twitter bot service
|
||||
|
||||
## 2.131.0 - 2024-12-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the search for asset profiles with `MANUAL` data source in the create or update activity dialog
|
||||
- Improved the usability of the link to manage access with a new icon
|
||||
- Improved support to import activities by `isin` in the _Yahoo Finance_ service
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
|
||||
## 2.130.0 - 2024-12-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added a new static portfolio analysis rule: _Asset Class Cluster Risk_ (Equity)
|
||||
- Added a new static portfolio analysis rule: _Asset Class Cluster Risk_ (Fixed Income)
|
||||
- Set up a notification service for prompt dialogs
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability to edit the emergency fund
|
||||
- Extracted the market data management from the admin control panel endpoint to a dedicated endpoint
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Upgraded `big.js` from version `6.2.1` to `6.2.2`
|
||||
|
||||
## 2.129.0 - 2024-12-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added `userId` to the `SymbolProfile` database schema
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the _X-ray_ page by hiding empty rule categories
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
## 2.128.0 - 2024-12-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the holding selector in the assistant
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `@internationalized/number` from version `3.5.2` to `3.6.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an exception in the caching of the portfolio snapshot in the portfolio calculator
|
||||
- Fixed the import of `jsonpath` to support REST APIs (`JSON`) via the scraper configuration
|
||||
|
||||
## 2.127.0 - 2024-12-08
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the _X-ray_ page by a summary
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an exception in the caching of the portfolio snapshot in the portfolio calculator
|
||||
|
||||
## 2.126.1 - 2024-12-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added pagination to the users table of the admin control panel
|
||||
@ -14,7 +121,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the labels of the assistant
|
||||
- Improved the caching of the portfolio snapshot in the portfolio calculator by expiring cache entries immediately in case of errors
|
||||
- Extracted the historical market data editor to a reusable component
|
||||
- Upgraded `prettier` from version `3.3.3` to `3.4.2`
|
||||
- Upgraded `prisma` from version `6.0.0` to `6.0.1`
|
||||
|
||||
## 2.125.0 - 2024-11-30
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
||||
### Setup
|
||||
|
||||
1. Run `npm install`
|
||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `docker compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `npm run database:setup` to initialize the database schema
|
||||
1. Start the [server](#start-server) and the [client](#start-client)
|
||||
1. Open https://localhost:4200/en in your browser
|
||||
|
12
Dockerfile
12
Dockerfile
@ -25,13 +25,13 @@ RUN npm install
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
COPY ./jest.config.ts jest.config.ts
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
COPY ./libs libs
|
||||
COPY ./jest.config.ts jest.config.ts
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.mjs replace.build.mjs
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
|
||||
RUN npm run build:production
|
||||
|
||||
|
24
README.md
24
README.md
@ -118,7 +118,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||
|
||||
```bash
|
||||
docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
#### b. Build and run environment
|
||||
@ -126,8 +126,8 @@ docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
Run the following commands to build and start the Docker images:
|
||||
|
||||
```bash
|
||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
docker compose -f docker/docker-compose.build.yml build
|
||||
docker compose -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Setup
|
||||
@ -137,9 +137,19 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
|
||||
#### Upgrade Version
|
||||
|
||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||
At each start, the container will automatically apply the database schema migrations if needed.
|
||||
1. Update the _Ghostfolio_ Docker image
|
||||
|
||||
- Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||
- Run the following command if `ghostfolio:latest` is set:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
```
|
||||
|
||||
1. Run the following command to start the new Docker image:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
The container will automatically apply any required database schema migrations during startup.
|
||||
|
||||
### Home Server Systems (Community)
|
||||
|
||||
@ -296,6 +306,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
|
||||
|
||||
## License
|
||||
|
||||
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||
© 2021 - 2025 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
@ -214,6 +214,9 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -250,6 +253,9 @@ export class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
|
@ -31,7 +31,9 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
|
||||
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
|
||||
import { MarketDataModule } from './endpoints/market-data/market-data.module';
|
||||
import { PublicModule } from './endpoints/public/public.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
@ -55,6 +57,7 @@ import { UserModule } from './user/user.module';
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
ApiKeysModule,
|
||||
AssetModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
@ -82,6 +85,7 @@ import { UserModule } from './user/user.module';
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
|
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyStrategy extends PassportStrategy(
|
||||
HeaderAPIKeyStrategy,
|
||||
'api-key'
|
||||
) {
|
||||
public constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super(
|
||||
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
|
||||
true,
|
||||
async (apiKey: string, done: (error: any, user?: any) => void) => {
|
||||
try {
|
||||
const user = await this.validateApiKey(apiKey);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (hasRole(user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: {
|
||||
activityCount: { increment: 1 },
|
||||
lastRequestAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async validateApiKey(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||
StatusCodes.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
|
||||
|
||||
return this.userService.user({ id });
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||
StatusCodes.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
@ -9,6 +10,7 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { ApiKeyStrategy } from './api-key.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
ApiKeyService,
|
||||
ApiKeyStrategy,
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
GoogleStrategy,
|
||||
|
25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
Normal file
25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
||||
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
public constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.createApiKey)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createApiKey(): Promise<ApiKeyResponse> {
|
||||
return this.apiKeyService.create({ userId: this.request.user.id });
|
||||
}
|
||||
}
|
11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts
Normal file
11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ApiKeysController],
|
||||
imports: [ApiKeyModule]
|
||||
})
|
||||
export class ApiKeysModule {}
|
@ -18,7 +18,8 @@ import {
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -36,9 +37,52 @@ export class GhostfolioController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('dividends/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getDividendsV1(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetDividendsDto
|
||||
): Promise<DividendsResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dividends = await this.ghostfolioService.getDividends({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return dividends;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('dividends/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getDividends(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetDividendsDto
|
||||
@ -75,9 +119,52 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('historical/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getHistoricalV1(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetHistoricalDto
|
||||
): Promise<HistoricalResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const historicalData = await this.ghostfolioService.getHistorical({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return historicalData;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('historical/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getHistorical(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetHistoricalDto
|
||||
@ -114,9 +201,51 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('lookup')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async lookupSymbolV1(
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
): Promise<LookupResponse> {
|
||||
const includeIndices = includeIndicesParam === 'true';
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ghostfolioService.lookup({
|
||||
includeIndices,
|
||||
query: query.toLowerCase()
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('lookup')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async lookupSymbol(
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
@ -152,9 +281,48 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('quotes')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getQuotesV1(
|
||||
@Query() query: GetQuotesDto
|
||||
): Promise<QuotesResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const quotes = await this.ghostfolioService.getQuotes({
|
||||
symbols: query.symbols
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return quotes;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('quotes')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getQuotes(
|
||||
@Query() query: GetQuotesDto
|
||||
): Promise<QuotesResponse> {
|
||||
@ -187,9 +355,20 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('status')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||
}
|
||||
|
@ -220,8 +220,7 @@ export class GhostfolioService {
|
||||
public async incrementDailyRequests({ userId }: { userId: string }) {
|
||||
await this.prismaService.analytics.update({
|
||||
data: {
|
||||
dataProviderGhostfolioDailyRequests: { increment: 1 },
|
||||
lastRequestAt: new Date()
|
||||
dataProviderGhostfolioDailyRequests: { increment: 1 }
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
136
apps/api/src/app/endpoints/market-data/market-data.controller.ts
Normal file
136
apps/api/src/app/endpoints/market-data/market-data.controller.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, Prisma } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||
|
||||
@Controller('market-data')
|
||||
export class MarketDataController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<MarketDataDetailsResponse> {
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const canReadAllAssetProfiles = hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.readMarketData
|
||||
);
|
||||
|
||||
const canReadOwnAssetProfile =
|
||||
assetProfile.userId === this.request.user.id &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.readMarketDataOfOwnAssetProfile
|
||||
);
|
||||
|
||||
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
|
||||
throw new HttpException(
|
||||
assetProfile.userId
|
||||
? getReasonPhrase(StatusCodes.NOT_FOUND)
|
||||
: getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Post(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateMarketData(
|
||||
@Body() data: UpdateBulkMarketDataDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const canUpsertAllAssetProfiles =
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createMarketData
|
||||
) &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.updateMarketData
|
||||
);
|
||||
|
||||
const canUpsertOwnAssetProfile =
|
||||
assetProfile.userId === this.request.user.id &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createMarketDataOfOwnAssetProfile
|
||||
) &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.updateMarketDataOfOwnAssetProfile
|
||||
);
|
||||
|
||||
if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||
({ date, marketPrice }) => ({
|
||||
dataSource,
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: parseISO(date),
|
||||
state: 'CLOSE'
|
||||
})
|
||||
);
|
||||
|
||||
return this.marketDataService.updateMany({
|
||||
data: dataBulkUpdate
|
||||
});
|
||||
}
|
||||
}
|
13
apps/api/src/app/endpoints/market-data/market-data.module.ts
Normal file
13
apps/api/src/app/endpoints/market-data/market-data.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
||||
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MarketDataController } from './market-data.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [MarketDataController],
|
||||
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule]
|
||||
})
|
||||
export class MarketDataModule {}
|
@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateBulkMarketDataDto {
|
||||
@ArrayNotEmpty()
|
||||
@IsArray()
|
||||
@Type(() => UpdateMarketDataDto)
|
||||
marketData: UpdateMarketDataDto[];
|
||||
}
|
||||
|
||||
class UpdateMarketDataDto {
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
date?: string;
|
||||
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
}
|
@ -224,7 +224,7 @@ export class ImportService {
|
||||
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) {
|
||||
activity.dataSource = DataSource.MANUAL;
|
||||
} else {
|
||||
activity.dataSource =
|
||||
@ -356,6 +356,7 @@ export class ImportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
Account: validatedAccount,
|
||||
accountId: validatedAccount?.id,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
@ -380,10 +381,10 @@ export class ImportService {
|
||||
symbolMapping,
|
||||
updatedAt,
|
||||
url,
|
||||
comment: assetProfile.comment,
|
||||
currency: assetProfile.currency,
|
||||
comment: assetProfile.comment
|
||||
userId: dataSource === 'MANUAL' ? user.id : undefined
|
||||
},
|
||||
Account: validatedAccount,
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date(),
|
||||
userId: user.id
|
||||
@ -406,7 +407,8 @@ export class ImportService {
|
||||
create: {
|
||||
dataSource,
|
||||
symbol,
|
||||
currency: assetProfile.currency
|
||||
currency: assetProfile.currency,
|
||||
userId: dataSource === 'MANUAL' ? user.id : undefined
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
|
@ -33,7 +33,6 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import got from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -155,20 +154,15 @@ export class InfoService {
|
||||
|
||||
private async countDockerHubPulls(): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { pull_count } = await got(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
const { pull_count } = (await fetch(
|
||||
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json())) as { pull_count: number };
|
||||
|
||||
return pull_count;
|
||||
} catch (error) {
|
||||
@ -180,22 +174,17 @@ export class InfoService {
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
});
|
||||
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}).then((res) => res.text());
|
||||
|
||||
const $ = cheerio.load(body);
|
||||
|
||||
return extractNumberFromString({
|
||||
value: $(
|
||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
|
||||
).text()
|
||||
});
|
||||
} catch (error) {
|
||||
@ -207,20 +196,15 @@ export class InfoService {
|
||||
|
||||
private async countGitHubStargazers(): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { stargazers_count } = await got(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
const { stargazers_count } = (await fetch(
|
||||
'https://api.github.com/repos/ghostfolio/ghostfolio',
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json())) as { stargazers_count: number };
|
||||
|
||||
return stargazers_count;
|
||||
} catch (error) {
|
||||
@ -335,13 +319,7 @@ export class InfoService {
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||
)) as string;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { data } = await got(
|
||||
const { data } = await fetch(
|
||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||
subDays(new Date(), 90),
|
||||
DATE_FORMAT
|
||||
@ -352,10 +330,11 @@ export class InfoService {
|
||||
'API_KEY_BETTER_UPTIME'
|
||||
)}`
|
||||
},
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
return data.attributes.availability / 100;
|
||||
} catch (error) {
|
||||
|
@ -26,12 +26,13 @@ export class LogoController {
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
const { buffer, type } =
|
||||
await this.logoService.getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
response.contentType('image/png');
|
||||
response.contentType(type);
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
@ -44,9 +45,9 @@ export class LogoController {
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByUrl(url);
|
||||
const { buffer, type } = await this.logoService.getLogoByUrl(url);
|
||||
|
||||
response.contentType('image/png');
|
||||
response.contentType(type);
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
|
@ -4,7 +4,6 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import got from 'got';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Injectable()
|
||||
@ -29,7 +28,7 @@ export class LogoService {
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
if (!assetProfile?.url) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
@ -39,24 +38,26 @@ export class LogoService {
|
||||
return this.getBuffer(assetProfile.url);
|
||||
}
|
||||
|
||||
public async getLogoByUrl(aUrl: string) {
|
||||
public getLogoByUrl(aUrl: string) {
|
||||
return this.getBuffer(aUrl);
|
||||
}
|
||||
|
||||
private getBuffer(aUrl: string) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
private async getBuffer(aUrl: string) {
|
||||
const blob = await fetch(
|
||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).buffer();
|
||||
).then((res) => res.blob());
|
||||
|
||||
return {
|
||||
buffer: await blob.arrayBuffer().then((arrayBuffer) => {
|
||||
return Buffer.from(arrayBuffer);
|
||||
}),
|
||||
type: blob.type
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -93,7 +93,7 @@ export class OrderService {
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
let Account;
|
||||
let Account: Prisma.AccountCreateNestedOneWithoutOrderInput;
|
||||
|
||||
if (data.accountId) {
|
||||
Account = {
|
||||
@ -124,6 +124,7 @@ export class OrderService {
|
||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||
data.SymbolProfile.connectOrCreate.create.userId = userId;
|
||||
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
|
||||
dataSource,
|
||||
symbol: id
|
||||
@ -223,10 +224,7 @@ export class OrderService {
|
||||
order.symbolProfileId
|
||||
]);
|
||||
|
||||
if (
|
||||
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
|
||||
symbolProfile.activitiesCount === 0
|
||||
) {
|
||||
if (symbolProfile.activitiesCount === 0) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
|
@ -176,6 +176,7 @@ export abstract class PortfolioCalculator {
|
||||
if (!transactionPoints.length) {
|
||||
return {
|
||||
currentValueInBaseCurrency: new Big(0),
|
||||
errors: [],
|
||||
hasErrors: false,
|
||||
historicalData: [],
|
||||
positions: [],
|
||||
|
@ -101,6 +101,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
totalInterestWithCurrencyEffect,
|
||||
totalInvestment,
|
||||
totalInvestmentWithCurrencyEffect,
|
||||
errors: [],
|
||||
historicalData: [],
|
||||
totalLiabilitiesWithCurrencyEffect: new Big(0),
|
||||
totalValuablesWithCurrencyEffect: new Big(0)
|
||||
|
@ -23,7 +23,7 @@ import {
|
||||
PortfolioHoldingsResponse,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioReport
|
||||
PortfolioReportResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasReadRestrictedAccessPermission,
|
||||
@ -611,7 +611,7 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getReport(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
): Promise<PortfolioReportResponse> {
|
||||
const report = await this.portfolioService.getReport(impersonationId);
|
||||
|
||||
if (
|
||||
@ -619,10 +619,13 @@ export class PortfolioController {
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
for (const rule in report.rules) {
|
||||
if (report.rules[rule]) {
|
||||
report.rules[rule] = [];
|
||||
}
|
||||
report.rules[rule] = null;
|
||||
}
|
||||
|
||||
report.statistics = {
|
||||
rulesActiveCount: 0,
|
||||
rulesFulfilledCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
return report;
|
||||
|
@ -7,6 +7,8 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||
import { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity';
|
||||
import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income';
|
||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
|
||||
@ -37,7 +39,7 @@ import {
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
PortfolioReportResponse,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
UserSettings
|
||||
@ -1162,7 +1164,9 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
public async getReport(
|
||||
impersonationId: string
|
||||
): Promise<PortfolioReportResponse> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
const userSettings = this.request.user.Settings.settings as UserSettings;
|
||||
|
||||
@ -1179,79 +1183,95 @@ export class PortfolioService {
|
||||
})
|
||||
).toNumber();
|
||||
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
: undefined,
|
||||
economicMarketClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new EconomicMarketClusterRiskDevelopedMarkets(
|
||||
this.exchangeRateDataService,
|
||||
marketsTotalInBaseCurrency,
|
||||
markets.developedMarkets.valueInBaseCurrency
|
||||
),
|
||||
new EconomicMarketClusterRiskEmergingMarkets(
|
||||
this.exchangeRateDataService,
|
||||
marketsTotalInBaseCurrency,
|
||||
markets.emergingMarkets.valueInBaseCurrency
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
: undefined,
|
||||
currencyClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
: undefined,
|
||||
emergencyFund: await this.rulesService.evaluate(
|
||||
[
|
||||
new EmergencyFundSetup(
|
||||
this.exchangeRateDataService,
|
||||
userSettings.emergencyFund
|
||||
const rules: PortfolioReportResponse['rules'] = {
|
||||
accountClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
[
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
summary.committedFunds,
|
||||
summary.fees
|
||||
: undefined,
|
||||
assetClassClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new AssetClassClusterRiskEquity(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
),
|
||||
new AssetClassClusterRiskFixedIncome(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
currencyClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
: undefined,
|
||||
economicMarketClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new EconomicMarketClusterRiskDevelopedMarkets(
|
||||
this.exchangeRateDataService,
|
||||
marketsTotalInBaseCurrency,
|
||||
markets.developedMarkets.valueInBaseCurrency
|
||||
),
|
||||
new EconomicMarketClusterRiskEmergingMarkets(
|
||||
this.exchangeRateDataService,
|
||||
marketsTotalInBaseCurrency,
|
||||
markets.emergingMarkets.valueInBaseCurrency
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
: undefined,
|
||||
emergencyFund: await this.rulesService.evaluate(
|
||||
[
|
||||
new EmergencyFundSetup(
|
||||
this.exchangeRateDataService,
|
||||
userSettings.emergencyFund
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
),
|
||||
fees: await this.rulesService.evaluate(
|
||||
[
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
summary.committedFunds,
|
||||
summary.fees
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
)
|
||||
};
|
||||
|
||||
return { rules, statistics: this.getReportStatistics(rules) };
|
||||
}
|
||||
|
||||
public async updateTags({
|
||||
@ -1670,6 +1690,24 @@ export class PortfolioService {
|
||||
return { markets, marketsAdvanced };
|
||||
}
|
||||
|
||||
private getReportStatistics(
|
||||
evaluatedRules: PortfolioReportResponse['rules']
|
||||
): PortfolioReportResponse['statistics'] {
|
||||
const rulesActiveCount = Object.values(evaluatedRules)
|
||||
.flat()
|
||||
.filter((rule) => {
|
||||
return rule?.isActive === true;
|
||||
}).length;
|
||||
|
||||
const rulesFulfilledCount = Object.values(evaluatedRules)
|
||||
.flat()
|
||||
.filter((rule) => {
|
||||
return rule?.value === true;
|
||||
}).length;
|
||||
|
||||
return { rulesActiveCount, rulesFulfilledCount };
|
||||
}
|
||||
|
||||
private getStreaks({
|
||||
investments,
|
||||
savingsRate
|
||||
|
@ -2,8 +2,11 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { getRandomString } from '@ghostfolio/api/helper/string.helper';
|
||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||
import { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity';
|
||||
import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income';
|
||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
|
||||
@ -37,11 +40,10 @@ import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { createHmac } from 'crypto';
|
||||
import { differenceInDays, subDays } from 'date-fns';
|
||||
import { sortBy, without } from 'lodash';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private i18nService = new I18nService();
|
||||
@ -61,7 +63,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
public createAccessToken(password: string, salt: string): string {
|
||||
const hash = crypto.createHmac('sha512', salt);
|
||||
const hash = createHmac('sha512', salt);
|
||||
hash.update(password);
|
||||
|
||||
return hash.digest('hex');
|
||||
@ -87,6 +89,7 @@ export class UserService {
|
||||
}),
|
||||
this.tagService.getTagsForUser(id)
|
||||
]);
|
||||
|
||||
const access = userData[0];
|
||||
const firstActivity = userData[1];
|
||||
let tags = userData[2];
|
||||
@ -117,7 +120,8 @@ export class UserService {
|
||||
access: access.map((accessItem) => {
|
||||
return {
|
||||
alias: accessItem.alias,
|
||||
id: accessItem.id
|
||||
id: accessItem.id,
|
||||
permissions: accessItem.permissions
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
@ -226,6 +230,24 @@ export class UserService {
|
||||
undefined,
|
||||
{}
|
||||
).getSettings(user.Settings.settings),
|
||||
AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity(
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome(
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskCurrentInvestment:
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
EconomicMarketClusterRiskDevelopedMarkets:
|
||||
new EconomicMarketClusterRiskDevelopedMarkets(
|
||||
undefined,
|
||||
@ -238,16 +260,6 @@ export class UserService {
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskCurrentInvestment:
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
EmergencyFundSetup: new EmergencyFundSetup(
|
||||
undefined,
|
||||
undefined
|
||||
@ -309,6 +321,7 @@ export class UserService {
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.createApiKey);
|
||||
currentPermissions.push(permissions.enableDataProviderGhostfolio);
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
@ -408,10 +421,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
if (data.provider === 'ANONYMOUS') {
|
||||
const accessToken = this.createAccessToken(
|
||||
user.id,
|
||||
this.getRandomString(10)
|
||||
);
|
||||
const accessToken = this.createAccessToken(user.id, getRandomString(10));
|
||||
|
||||
const hashedAccessToken = this.createAccessToken(
|
||||
accessToken,
|
||||
@ -528,17 +538,4 @@ export class UserService {
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private getRandomString(length: number) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomByte = bytes[i];
|
||||
result.push(characters[randomByte % characters.length]);
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
14
apps/api/src/helper/string.helper.ts
Normal file
14
apps/api/src/helper/string.helper.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export function getRandomString(length: number) {
|
||||
const bytes = randomBytes(length);
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomByte = bytes[i];
|
||||
result.push(characters[randomByte % characters.length]);
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
@ -7,7 +7,6 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { json } from 'body-parser';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
@ -48,7 +47,7 @@ async function bootstrap() {
|
||||
);
|
||||
|
||||
// Support 10mb csv/json files for importing activities
|
||||
app.use(json({ limit: '10mb' }));
|
||||
app.useBodyParser('json', { limit: '10mb' });
|
||||
|
||||
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||
app.use(
|
||||
|
95
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
Normal file
95
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class AssetClassClusterRiskEquity extends Rule<Settings> {
|
||||
private holdings: PortfolioPosition[];
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
holdings: PortfolioPosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AssetClassClusterRiskEquity.name,
|
||||
name: 'Equity'
|
||||
});
|
||||
|
||||
this.holdings = holdings;
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute(
|
||||
this.holdings,
|
||||
'assetClass',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
let totalValue = 0;
|
||||
|
||||
const equityValueInBaseCurrency =
|
||||
holdingsGroupedByAssetClass.find(({ groupKey }) => {
|
||||
return groupKey === 'EQUITY';
|
||||
})?.value ?? 0;
|
||||
|
||||
for (const { value } of holdingsGroupedByAssetClass) {
|
||||
totalValue += value;
|
||||
}
|
||||
|
||||
const equityValueRatio = totalValue
|
||||
? equityValueInBaseCurrency / totalValue
|
||||
: 0;
|
||||
|
||||
if (equityValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) exceeds ${(
|
||||
ruleSettings.thresholdMax * 100
|
||||
).toPrecision(3)}%`,
|
||||
value: false
|
||||
};
|
||||
} else if (equityValueRatio < ruleSettings.thresholdMin) {
|
||||
return {
|
||||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is below ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(3)}%`,
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is within the range of ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(
|
||||
3
|
||||
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getConfiguration() {
|
||||
return {
|
||||
threshold: {
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
unit: '%'
|
||||
},
|
||||
thresholdMax: true,
|
||||
thresholdMin: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
|
||||
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.82,
|
||||
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.78
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
thresholdMin: number;
|
||||
thresholdMax: number;
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
|
||||
private holdings: PortfolioPosition[];
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
holdings: PortfolioPosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AssetClassClusterRiskFixedIncome.name,
|
||||
name: 'Fixed Income'
|
||||
});
|
||||
|
||||
this.holdings = holdings;
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute(
|
||||
this.holdings,
|
||||
'assetClass',
|
||||
ruleSettings.baseCurrency
|
||||
);
|
||||
let totalValue = 0;
|
||||
|
||||
const fixedIncomeValueInBaseCurrency =
|
||||
holdingsGroupedByAssetClass.find(({ groupKey }) => {
|
||||
return groupKey === 'FIXED_INCOME';
|
||||
})?.value ?? 0;
|
||||
|
||||
for (const { value } of holdingsGroupedByAssetClass) {
|
||||
totalValue += value;
|
||||
}
|
||||
|
||||
const fixedIncomeValueRatio = totalValue
|
||||
? fixedIncomeValueInBaseCurrency / totalValue
|
||||
: 0;
|
||||
|
||||
if (fixedIncomeValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) exceeds ${(
|
||||
ruleSettings.thresholdMax * 100
|
||||
).toPrecision(3)}%`,
|
||||
value: false
|
||||
};
|
||||
} else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) {
|
||||
return {
|
||||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is below ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(3)}%`,
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is within the range of ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(
|
||||
3
|
||||
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
public getConfiguration() {
|
||||
return {
|
||||
threshold: {
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
unit: '%'
|
||||
},
|
||||
thresholdMax: true,
|
||||
thresholdMin: true
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
|
||||
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.22,
|
||||
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.18
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
thresholdMin: number;
|
||||
thresholdMax: number;
|
||||
}
|
@ -28,7 +28,12 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
let maxItem = holdingsGroupedByCurrency[0];
|
||||
let totalValue = 0;
|
||||
|
||||
holdingsGroupedByCurrency.forEach((groupItem) => {
|
||||
const baseCurrencyValue =
|
||||
holdingsGroupedByCurrency.find(({ groupKey }) => {
|
||||
return groupKey === ruleSettings.baseCurrency;
|
||||
})?.value ?? 0;
|
||||
|
||||
for (const groupItem of holdingsGroupedByCurrency) {
|
||||
// Calculate total value
|
||||
totalValue += groupItem.value;
|
||||
|
||||
@ -36,13 +41,11 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
if (groupItem.investment > maxItem.investment) {
|
||||
maxItem = groupItem;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const baseCurrencyItem = holdingsGroupedByCurrency.find((item) => {
|
||||
return item.groupKey === ruleSettings.baseCurrency;
|
||||
});
|
||||
|
||||
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
|
||||
const baseCurrencyValueRatio = totalValue
|
||||
? baseCurrencyValue / totalValue
|
||||
: 0;
|
||||
|
||||
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
|
||||
return {
|
||||
|
12
apps/api/src/services/api-key/api-key.module.ts
Normal file
12
apps/api/src/services/api-key/api-key.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
@Module({
|
||||
exports: [ApiKeyService],
|
||||
imports: [PrismaModule],
|
||||
providers: [ApiKeyService]
|
||||
})
|
||||
export class ApiKeyModule {}
|
63
apps/api/src/services/api-key/api-key.service.ts
Normal file
63
apps/api/src/services/api-key/api-key.service.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { getRandomString } from '@ghostfolio/api/helper/string.helper';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { pbkdf2Sync } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
private readonly algorithm = 'sha256';
|
||||
private readonly iterations = 100000;
|
||||
private readonly keyLength = 64;
|
||||
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async create({ userId }: { userId: string }): Promise<ApiKeyResponse> {
|
||||
const apiKey = this.generateApiKey();
|
||||
const hashedKey = this.hashApiKey(apiKey);
|
||||
|
||||
await this.prismaService.apiKey.deleteMany({ where: { userId } });
|
||||
|
||||
await this.prismaService.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
public async getUserByApiKey(apiKey: string) {
|
||||
const hashedKey = this.hashApiKey(apiKey);
|
||||
|
||||
const { user } = await this.prismaService.apiKey.findUnique({
|
||||
include: { user: true },
|
||||
where: { hashedKey }
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public hashApiKey(apiKey: string): string {
|
||||
return pbkdf2Sync(
|
||||
apiKey,
|
||||
'',
|
||||
this.iterations,
|
||||
this.keyLength,
|
||||
this.algorithm
|
||||
).toString('hex');
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return getRandomString(32)
|
||||
.split('')
|
||||
.reduce((acc, char, index) => {
|
||||
const chunkIndex = Math.floor(index / 4);
|
||||
acc[chunkIndex] = (acc[chunkIndex] || '') + char;
|
||||
return acc;
|
||||
}, [])
|
||||
.join('-');
|
||||
}
|
||||
}
|
@ -26,12 +26,11 @@ import {
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
||||
import got, { Headers } from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class CoinGeckoService implements DataProviderInterface {
|
||||
private readonly apiUrl: string;
|
||||
private readonly headers: Headers = {};
|
||||
private readonly headers: HeadersInit = {};
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
@ -69,23 +68,18 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
};
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { name } = await got(`${this.apiUrl}/coins/${symbol}`, {
|
||||
const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, {
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}).json<any>();
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}).then((res) => res.json());
|
||||
|
||||
response.name = name;
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
@ -118,13 +112,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const { prices } = await got(
|
||||
const { prices } = await fetch(
|
||||
`${
|
||||
this.apiUrl
|
||||
}/coins/${symbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
|
||||
@ -132,10 +120,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
)}&to=${getUnixTime(to)}`,
|
||||
{
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
@ -179,22 +166,15 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const quotes = await got(
|
||||
const quotes = await fetch(
|
||||
`${this.apiUrl}/simple/price?ids=${symbols.join(
|
||||
','
|
||||
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
|
||||
{
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
for (const symbol in quotes) {
|
||||
response[symbol] = {
|
||||
@ -208,7 +188,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
@ -228,17 +208,12 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { coins } = await got(`${this.apiUrl}/search?query=${query}`, {
|
||||
const { coins } = await fetch(`${this.apiUrl}/search?query=${query}`, {
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}).json<any>();
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}).then((res) => res.json());
|
||||
|
||||
items = coins.map(({ id: symbol, name }) => {
|
||||
return {
|
||||
@ -254,7 +229,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
|
@ -4,7 +4,6 @@ import { parseSymbol } from '@ghostfolio/common/helper';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import got, { Headers } from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
@ -32,7 +31,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
return response;
|
||||
}
|
||||
|
||||
const headers: Headers = {};
|
||||
const headers: HeadersInit = {};
|
||||
const { exchange, ticker } = parseSymbol({
|
||||
symbol,
|
||||
dataSource: response.dataSource
|
||||
@ -43,20 +42,20 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
this.configurationService.get('API_KEY_OPEN_FIGI');
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const mappings = await got
|
||||
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
|
||||
headers,
|
||||
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }],
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
})
|
||||
.json<any[]>();
|
||||
const mappings = (await fetch(
|
||||
`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`,
|
||||
{
|
||||
body: JSON.stringify([
|
||||
{ exchCode: exchange, idType: 'TICKER', idValue: ticker }
|
||||
]),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).then((res) => res.json())) as any[];
|
||||
|
||||
if (mappings?.length === 1 && mappings[0].data?.length === 1) {
|
||||
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];
|
||||
|
@ -7,7 +7,6 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { countries } from 'countries-list';
|
||||
import got from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
@ -45,37 +44,25 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
return response;
|
||||
}
|
||||
|
||||
let abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const profile = await got(
|
||||
const profile = await fetch(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
)
|
||||
.json<any>()
|
||||
.then((res) => res.json())
|
||||
.catch(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
return fetch(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/funds/${
|
||||
symbol.split('.')?.[0]
|
||||
}.json`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
)
|
||||
.json<any>()
|
||||
.then((res) => res.json())
|
||||
.catch(() => {
|
||||
return {};
|
||||
});
|
||||
@ -87,37 +74,27 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
response.isin = isin;
|
||||
}
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const holdings = await got(
|
||||
const holdings = await fetch(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
)
|
||||
.json<any>()
|
||||
.then((res) => res.json())
|
||||
.catch(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
return fetch(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
|
||||
symbol.split('.')?.[0]
|
||||
}.json`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
)
|
||||
.json<any>()
|
||||
.then((res) => res.json())
|
||||
.catch(() => {
|
||||
return {};
|
||||
});
|
||||
|
@ -166,7 +166,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
try {
|
||||
const { quotes } = await yahooFinance.search(symbol);
|
||||
|
||||
if (quotes.length === 1) {
|
||||
if (quotes?.[0]?.symbol) {
|
||||
symbol = quotes[0].symbol;
|
||||
}
|
||||
} catch {}
|
||||
|
@ -618,7 +618,8 @@ export class DataProviderService {
|
||||
promises.push(
|
||||
dataProviderService.search({
|
||||
includeIndices,
|
||||
query
|
||||
query,
|
||||
userId: user.id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -31,7 +31,6 @@ import {
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { addDays, format, isSameDay, isToday } from 'date-fns';
|
||||
import got from 'got';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
@ -91,17 +90,11 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const response: {
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
} = {};
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const historicalResult = await got(
|
||||
const historicalResult = await fetch(
|
||||
`${this.URL}/div/${symbol}?api_token=${
|
||||
this.apiKey
|
||||
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
|
||||
@ -109,10 +102,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
for (const { date, value } of historicalResult) {
|
||||
response[date] = {
|
||||
@ -146,13 +138,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
symbol = this.convertToEodSymbol(symbol);
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const response = await got(
|
||||
const response = await fetch(
|
||||
`${this.URL}/eod/${symbol}?api_token=${
|
||||
this.apiKey
|
||||
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
|
||||
@ -160,10 +146,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
DATE_FORMAT
|
||||
)}&period=${granularity}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
return response.reduce(
|
||||
(result, { adjusted_close, date }) => {
|
||||
@ -217,21 +202,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
});
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const realTimeResponse = await got(
|
||||
const realTimeResponse = await fetch(
|
||||
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
|
||||
this.apiKey
|
||||
}&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
const quotes: {
|
||||
close: number;
|
||||
@ -304,7 +282,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
@ -418,19 +396,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
})[] = [];
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const response = await got(
|
||||
const response = await fetch(
|
||||
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
searchResult = response.map(
|
||||
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {
|
||||
@ -453,7 +426,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
|
@ -21,7 +21,6 @@ import {
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { format, isAfter, isBefore, isSameDay } from 'date-fns';
|
||||
import got from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
@ -72,19 +71,12 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const { historical } = await got(
|
||||
const { historical } = await fetch(
|
||||
`${this.URL}/historical-price-full/${symbol}?apikey=${this.apiKey}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
@ -130,19 +122,12 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const quotes = await got(
|
||||
const quotes = await fetch(
|
||||
`${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
for (const { price, symbol } of quotes) {
|
||||
response[symbol] = {
|
||||
@ -156,7 +141,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
@ -176,19 +161,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const result = await got(
|
||||
const result = await fetch(
|
||||
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
|
||||
{
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
items = result.map(({ currency, name, symbol }) => {
|
||||
return {
|
||||
@ -203,7 +183,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
|
@ -28,7 +28,6 @@ import {
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import got from 'got';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
|
||||
@Injectable()
|
||||
@ -86,23 +85,16 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
} = {};
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const { dividends } = await got(
|
||||
`${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
|
||||
const { dividends } = (await fetch(
|
||||
`${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
|
||||
to,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
{
|
||||
headers: await this.getRequestHeaders(),
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<DividendsResponse>();
|
||||
).then((res) => res.json())) as DividendsResponse;
|
||||
|
||||
response = dividends;
|
||||
} catch (error) {
|
||||
@ -111,8 +103,13 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
} else {
|
||||
message =
|
||||
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(message, 'GhostfolioService');
|
||||
@ -131,23 +128,16 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const { historicalData } = await got(
|
||||
`${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
|
||||
const { historicalData } = (await fetch(
|
||||
`${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
|
||||
to,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
{
|
||||
headers: await this.getRequestHeaders(),
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<HistoricalResponse>();
|
||||
).then((res) => res.json())) as HistoricalResponse;
|
||||
|
||||
return {
|
||||
[symbol]: historicalData
|
||||
@ -158,8 +148,13 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
} else {
|
||||
message =
|
||||
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(message, 'GhostfolioService');
|
||||
@ -194,34 +189,32 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, requestTimeout);
|
||||
|
||||
const { quotes } = await got(
|
||||
`${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
|
||||
const { quotes } = (await fetch(
|
||||
`${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
|
||||
{
|
||||
headers: await this.getRequestHeaders(),
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(requestTimeout)
|
||||
}
|
||||
).json<QuotesResponse>();
|
||||
).then((res) => res.json())) as QuotesResponse;
|
||||
|
||||
response = quotes;
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error.name === 'AbortError') {
|
||||
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
} else {
|
||||
message =
|
||||
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(message, 'GhostfolioService');
|
||||
@ -238,32 +231,32 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
let searchResult: LookupResponse = { items: [] };
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
searchResult = await got(
|
||||
`${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`,
|
||||
searchResult = (await fetch(
|
||||
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
|
||||
{
|
||||
headers: await this.getRequestHeaders(),
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<LookupResponse>();
|
||||
).then((res) => res.json())) as LookupResponse;
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error.name === 'AbortError') {
|
||||
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
message =
|
||||
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
|
||||
} else {
|
||||
message =
|
||||
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(message, 'GhostfolioService');
|
||||
@ -278,7 +271,7 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
)) as string;
|
||||
|
||||
return {
|
||||
[HEADER_KEY_TOKEN]: `Bearer ${apiKey}`
|
||||
[HEADER_KEY_TOKEN]: `Api-Key ${apiKey}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -79,4 +79,5 @@ export interface GetQuotesParams {
|
||||
export interface GetSearchParams {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
@ -26,10 +26,8 @@ import {
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { addDays, format, isBefore } from 'date-fns';
|
||||
import got, { Headers } from 'got';
|
||||
import jsonpath from 'jsonpath';
|
||||
import * as jsonpath from 'jsonpath';
|
||||
|
||||
@Injectable()
|
||||
export class ManualService implements DataProviderInterface {
|
||||
@ -219,41 +217,48 @@ export class ManualService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||
let items = await this.prismaService.symbolProfile.findMany({
|
||||
public async search({
|
||||
query,
|
||||
userId
|
||||
}: GetSearchParams): Promise<LookupResponse> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
symbol: true,
|
||||
userId: true
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
AND: [
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: query
|
||||
}
|
||||
dataSource: this.getName()
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: query
|
||||
}
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: query
|
||||
}
|
||||
},
|
||||
{
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: query
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
OR: [{ userId }, { userId: null }]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
items = items.filter(({ symbol }) => {
|
||||
// Remove UUID symbols (activities of type ITEM)
|
||||
return !isUUID(symbol);
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item) => {
|
||||
return { ...item, dataProviderInfo: this.getDataProviderInfo() };
|
||||
@ -269,28 +274,23 @@ export class ManualService implements DataProviderInterface {
|
||||
scraperConfiguration: ScraperConfiguration
|
||||
): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
let locale = scraperConfiguration.locale;
|
||||
const { body, headers } = await got(scraperConfiguration.url, {
|
||||
headers: scraperConfiguration.headers as Headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
const response = await fetch(scraperConfiguration.url, {
|
||||
headers: scraperConfiguration.headers as HeadersInit,
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
});
|
||||
|
||||
if (headers['content-type'].includes('application/json')) {
|
||||
const data = JSON.parse(body);
|
||||
if (response.headers['content-type'].includes('application/json')) {
|
||||
const data = await response.json();
|
||||
const value = String(
|
||||
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||
);
|
||||
|
||||
return extractNumberFromString({ locale, value });
|
||||
} else {
|
||||
const $ = cheerio.load(body);
|
||||
const $ = cheerio.load(await response.text());
|
||||
|
||||
if (!locale) {
|
||||
try {
|
||||
|
@ -20,7 +20,6 @@ import {
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import got from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class RapidApiService implements DataProviderInterface {
|
||||
@ -135,13 +134,7 @@ export class RapidApiService implements DataProviderInterface {
|
||||
oneYearAgo: { value: number; valueText: string };
|
||||
}> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { fgi } = await got(
|
||||
const { fgi } = await fetch(
|
||||
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
|
||||
{
|
||||
headers: {
|
||||
@ -149,16 +142,17 @@ export class RapidApiService implements DataProviderInterface {
|
||||
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
||||
'x-rapidapi-key': this.configurationService.get('API_KEY_RAPID_API')
|
||||
},
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
return fgi;
|
||||
} catch (error) {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
if (error?.name === 'AbortError') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
|
@ -86,7 +86,9 @@ export class PortfolioSnapshotProcessor {
|
||||
|
||||
const expiration = addMilliseconds(
|
||||
new Date(),
|
||||
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||
(snapshot?.errors?.length ?? 0) === 0
|
||||
? this.configurationService.get('CACHE_QUOTES_TTL')
|
||||
: 0
|
||||
);
|
||||
|
||||
this.redisCacheService.set(
|
||||
|
@ -87,9 +87,15 @@ export class TwitterBotService {
|
||||
|
||||
return benchmarks
|
||||
.map(({ marketCondition, name, performances }) => {
|
||||
return `${name} ${(
|
||||
let changeFormAllTimeHigh = (
|
||||
performances.allTimeHigh.performancePercent * 100
|
||||
).toFixed(1)}%${
|
||||
).toFixed(1);
|
||||
|
||||
if (Math.abs(parseFloat(changeFormAllTimeHigh)) === 0) {
|
||||
changeFormAllTimeHigh = '0.0';
|
||||
}
|
||||
|
||||
return `${name} ${changeFormAllTimeHigh}%${
|
||||
marketCondition !== 'NEUTRAL_MARKET'
|
||||
? ' ' + resolveMarketCondition(marketCondition).emoji
|
||||
: ''
|
||||
|
@ -16,6 +16,12 @@
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"rules": {
|
||||
"@angular-eslint/prefer-standalone": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": ["@angular-eslint/eslint-plugin", "@typescript-eslint"],
|
||||
|
@ -212,7 +212,7 @@
|
||||
"extract-i18n": {
|
||||
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"buildTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
|
@ -38,7 +38,8 @@ import { UserService } from './services/user/user.service';
|
||||
selector: 'gf-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
styleUrls: ['./app.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class AppComponent implements OnDestroy, OnInit {
|
||||
@HostBinding('class.has-info-message') get getHasMessage() {
|
||||
|
@ -66,7 +66,7 @@
|
||||
</button>
|
||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||
@if (element.type === 'PUBLIC') {
|
||||
<button mat-menu-item (click)="onCopyToClipboard(element.id)">
|
||||
<button mat-menu-item (click)="onCopyUrlToClipboard(element.id)">
|
||||
<ng-container i18n>Copy link to clipboard</ng-container>
|
||||
</button>
|
||||
<hr class="my-0" />
|
||||
|
@ -12,13 +12,15 @@ import {
|
||||
OnChanges,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-access-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './access-table.component.html',
|
||||
styleUrls: ['./access-table.component.scss']
|
||||
styleUrls: ['./access-table.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class AccessTableComponent implements OnChanges {
|
||||
@Input() accesses: Access[];
|
||||
@ -33,7 +35,8 @@ export class AccessTableComponent implements OnChanges {
|
||||
|
||||
public constructor(
|
||||
private clipboard: Clipboard,
|
||||
private notificationService: NotificationService
|
||||
private notificationService: NotificationService,
|
||||
private snackBar: MatSnackBar
|
||||
) {}
|
||||
|
||||
public ngOnChanges() {
|
||||
@ -54,8 +57,16 @@ export class AccessTableComponent implements OnChanges {
|
||||
return `${this.baseUrl}/${languageCode}/p/${aId}`;
|
||||
}
|
||||
|
||||
public onCopyToClipboard(aId: string): void {
|
||||
public onCopyUrlToClipboard(aId: string): void {
|
||||
this.clipboard.copy(this.getPublicUrl(aId));
|
||||
|
||||
this.snackBar.open(
|
||||
'✅ ' + $localize`Link has been copied to the clipboard`,
|
||||
undefined,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public onDeleteAccess(aId: string) {
|
||||
|
@ -37,7 +37,8 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||
selector: 'gf-account-detail-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: 'account-detail-dialog.html',
|
||||
styleUrls: ['./account-detail-dialog.component.scss']
|
||||
styleUrls: ['./account-detail-dialog.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public accountBalances: AccountBalancesResponse['balances'];
|
||||
|
@ -23,7 +23,8 @@ import { Subject, Subscription } from 'rxjs';
|
||||
selector: 'gf-accounts-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './accounts-table.component.html',
|
||||
styleUrls: ['./accounts-table.component.scss']
|
||||
styleUrls: ['./accounts-table.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class AccountsTableComponent implements OnChanges, OnDestroy {
|
||||
@Input() accounts: AccountModel[];
|
||||
|
@ -27,7 +27,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-admin-jobs',
|
||||
styleUrls: ['./admin-jobs.scss'],
|
||||
templateUrl: './admin-jobs.html'
|
||||
templateUrl: './admin-jobs.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AdminJobsComponent implements OnDestroy, OnInit {
|
||||
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW;
|
||||
|
@ -48,7 +48,8 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
|
||||
host: { class: 'has-fab' },
|
||||
selector: 'gf-admin-market-data',
|
||||
styleUrls: ['./admin-market-data.scss'],
|
||||
templateUrl: './admin-market-data.html'
|
||||
templateUrl: './admin-market-data.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AdminMarketDataComponent
|
||||
implements AfterViewInit, OnDestroy, OnInit
|
||||
|
@ -42,7 +42,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||
selector: 'gf-asset-profile-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: 'asset-profile-dialog.html',
|
||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||
styleUrls: ['./asset-profile-dialog.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetProfileClass: string;
|
||||
@ -121,8 +122,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
});
|
||||
|
||||
this.adminService
|
||||
.fetchAdminMarketDataBySymbol({
|
||||
this.dataService
|
||||
.fetchMarketDataBySymbol({
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
|
@ -28,7 +28,8 @@ import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-asset-profile-dialog',
|
||||
styleUrls: ['./create-asset-profile-dialog.component.scss'],
|
||||
templateUrl: 'create-asset-profile-dialog.html'
|
||||
templateUrl: 'create-asset-profile-dialog.html',
|
||||
standalone: false
|
||||
})
|
||||
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||
public createAssetProfileForm: FormGroup;
|
||||
|
@ -36,7 +36,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
selector: 'gf-admin-overview',
|
||||
styleUrls: ['./admin-overview.scss'],
|
||||
templateUrl: './admin-overview.html'
|
||||
templateUrl: './admin-overview.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public couponDuration: StringValue = '14 days';
|
||||
|
@ -29,7 +29,8 @@ import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-admin-platform',
|
||||
styleUrls: ['./admin-platform.component.scss'],
|
||||
templateUrl: './admin-platform.component.html'
|
||||
templateUrl: './admin-platform.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AdminPlatformComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
@ -19,7 +19,8 @@ import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-or-update-platform-dialog',
|
||||
styleUrls: ['./create-or-update-platform-dialog.scss'],
|
||||
templateUrl: 'create-or-update-platform-dialog.html'
|
||||
templateUrl: 'create-or-update-platform-dialog.html',
|
||||
standalone: false
|
||||
})
|
||||
export class CreateOrUpdatePlatformDialog implements OnDestroy {
|
||||
public platformForm: FormGroup;
|
||||
|
@ -30,7 +30,8 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-admin-settings',
|
||||
styleUrls: ['./admin-settings.component.scss'],
|
||||
templateUrl: './admin-settings.component.html'
|
||||
templateUrl: './admin-settings.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AdminSettingsComponent implements OnDestroy, OnInit {
|
||||
public defaultDateFormat: string;
|
||||
@ -100,7 +101,8 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
deviceType: this.deviceType,
|
||||
pricingUrl: this.pricingUrl
|
||||
pricingUrl: this.pricingUrl,
|
||||
user: this.user
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '98vh' : undefined,
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
|
||||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
|
||||
@ -25,7 +26,6 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
|
||||
MatDialogModule
|
||||
],
|
||||
selector: 'gf-ghostfolio-premium-api-dialog',
|
||||
standalone: true,
|
||||
styleUrls: ['./ghostfolio-premium-api-dialog.scss'],
|
||||
templateUrl: './ghostfolio-premium-api-dialog.html'
|
||||
})
|
||||
@ -33,7 +33,8 @@ export class GfGhostfolioPremiumApiDialogComponent {
|
||||
public constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>
|
||||
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>,
|
||||
private notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
public onCancel() {
|
||||
@ -41,19 +42,21 @@ export class GfGhostfolioPremiumApiDialogComponent {
|
||||
}
|
||||
|
||||
public onSetGhostfolioApiKey() {
|
||||
let ghostfolioApiKey = prompt(
|
||||
$localize`Please enter your Ghostfolio API key:`
|
||||
);
|
||||
ghostfolioApiKey = ghostfolioApiKey?.trim();
|
||||
this.notificationService.prompt({
|
||||
confirmFn: (value) => {
|
||||
const ghostfolioApiKey = value?.trim();
|
||||
|
||||
if (ghostfolioApiKey) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, {
|
||||
value: ghostfolioApiKey
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.dialogRef.close();
|
||||
});
|
||||
}
|
||||
if (ghostfolioApiKey) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, {
|
||||
value: ghostfolioApiKey
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.dialogRef.close();
|
||||
});
|
||||
}
|
||||
},
|
||||
title: $localize`Please enter your Ghostfolio API key.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -31,17 +31,19 @@
|
||||
mat-flat-button
|
||||
>Notify me</a
|
||||
>
|
||||
<div>
|
||||
<small class="text-muted" i18n>or</small>
|
||||
</div>
|
||||
<button
|
||||
color="accent"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
(click)="onSetGhostfolioApiKey()"
|
||||
>
|
||||
I have an API key
|
||||
</button>
|
||||
@if (data.user?.settings?.isExperimentalFeatures) {
|
||||
<div>
|
||||
<small class="text-muted" i18n>or</small>
|
||||
</div>
|
||||
<button
|
||||
color="accent"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
(click)="onSetGhostfolioApiKey()"
|
||||
>
|
||||
I have an API key
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface GhostfolioPremiumApiDialogParams {
|
||||
deviceType: string;
|
||||
pricingUrl: string;
|
||||
user: User;
|
||||
}
|
||||
|
@ -29,7 +29,8 @@ import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-admin-tag',
|
||||
styleUrls: ['./admin-tag.component.scss'],
|
||||
templateUrl: './admin-tag.component.html'
|
||||
templateUrl: './admin-tag.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AdminTagComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
@ -19,7 +19,8 @@ import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
|
||||
host: { class: 'h-100' },
|
||||
selector: 'gf-create-or-update-tag-dialog',
|
||||
styleUrls: ['./create-or-update-tag-dialog.scss'],
|
||||
templateUrl: 'create-or-update-tag-dialog.html'
|
||||
templateUrl: 'create-or-update-tag-dialog.html',
|
||||
standalone: false
|
||||
})
|
||||
export class CreateOrUpdateTagDialog implements OnDestroy {
|
||||
public tagForm: FormGroup;
|
||||
|
@ -29,7 +29,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
selector: 'gf-admin-users',
|
||||
styleUrls: ['./admin-users.scss'],
|
||||
templateUrl: './admin-users.html'
|
||||
templateUrl: './admin-users.html',
|
||||
standalone: false
|
||||
})
|
||||
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
|
@ -13,7 +13,6 @@ import { DataSource } from '@prisma/client';
|
||||
imports: [CommonModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
selector: 'gf-asset-profile-icon',
|
||||
standalone: true,
|
||||
styleUrls: ['./asset-profile-icon.component.scss'],
|
||||
templateUrl: './asset-profile-icon.component.html'
|
||||
})
|
||||
|
@ -44,7 +44,8 @@ import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
selector: 'gf-benchmark-comparator',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './benchmark-comparator.component.html',
|
||||
styleUrls: ['./benchmark-comparator.component.scss']
|
||||
styleUrls: ['./benchmark-comparator.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
@Input() benchmark: Partial<SymbolProfile>;
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
selector: 'gf-dialog-footer',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './dialog-footer.component.html',
|
||||
styleUrls: ['./dialog-footer.component.scss']
|
||||
styleUrls: ['./dialog-footer.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class DialogFooterComponent {
|
||||
@Input() deviceType: string;
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
selector: 'gf-dialog-header',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './dialog-header.component.html',
|
||||
styleUrls: ['./dialog-header.component.scss']
|
||||
styleUrls: ['./dialog-header.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class DialogHeaderComponent {
|
||||
@Input() deviceType: string;
|
||||
|
@ -12,7 +12,8 @@ import {
|
||||
selector: 'gf-fear-and-greed-index',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './fear-and-greed-index.component.html',
|
||||
styleUrls: ['./fear-and-greed-index.component.scss']
|
||||
styleUrls: ['./fear-and-greed-index.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class FearAndGreedIndexComponent implements OnChanges {
|
||||
@Input() fearAndGreedIndex: number;
|
||||
|
@ -35,7 +35,8 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
||||
selector: 'gf-header',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './header.component.html',
|
||||
styleUrls: ['./header.component.scss']
|
||||
styleUrls: ['./header.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class HeaderComponent implements OnChanges {
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
|
@ -81,7 +81,6 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
selector: 'gf-holding-detail-dialog',
|
||||
standalone: true,
|
||||
styleUrls: ['./holding-detail-dialog.component.scss'],
|
||||
templateUrl: 'holding-detail-dialog.html'
|
||||
})
|
||||
|
@ -20,7 +20,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
selector: 'gf-home-holdings',
|
||||
styleUrls: ['./home-holdings.scss'],
|
||||
templateUrl: './home-holdings.html'
|
||||
templateUrl: './home-holdings.html',
|
||||
standalone: false
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
|
||||
|
@ -18,7 +18,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
selector: 'gf-home-market',
|
||||
styleUrls: ['./home-market.scss'],
|
||||
templateUrl: './home-market.html'
|
||||
templateUrl: './home-market.html',
|
||||
standalone: false
|
||||
})
|
||||
export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public benchmarks: Benchmark[];
|
||||
|
@ -20,7 +20,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
selector: 'gf-home-overview',
|
||||
styleUrls: ['./home-overview.scss'],
|
||||
templateUrl: './home-overview.html'
|
||||
templateUrl: './home-overview.html',
|
||||
standalone: false
|
||||
})
|
||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||
|
@ -16,7 +16,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
selector: 'gf-home-summary',
|
||||
styleUrls: ['./home-summary.scss'],
|
||||
templateUrl: './home-summary.html'
|
||||
templateUrl: './home-summary.html',
|
||||
standalone: false
|
||||
})
|
||||
export class HomeSummaryComponent implements OnDestroy, OnInit {
|
||||
public hasImpersonationId: boolean;
|
||||
|
@ -45,7 +45,8 @@ import { isAfter } from 'date-fns';
|
||||
selector: 'gf-investment-chart',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './investment-chart.component.html',
|
||||
styleUrls: ['./investment-chart.component.scss']
|
||||
styleUrls: ['./investment-chart.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
@Input() benchmarkDataItems: InvestmentItem[] = [];
|
||||
|
@ -14,7 +14,8 @@ import { Router } from '@angular/router';
|
||||
selector: 'gf-login-with-access-token-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./login-with-access-token-dialog.scss'],
|
||||
templateUrl: 'login-with-access-token-dialog.html'
|
||||
templateUrl: 'login-with-access-token-dialog.html',
|
||||
standalone: false
|
||||
})
|
||||
export class LoginWithAccessTokenDialog {
|
||||
public isAccessTokenHidden = true;
|
||||
|
@ -24,7 +24,8 @@ import { isNumber } from 'lodash';
|
||||
selector: 'gf-portfolio-performance',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './portfolio-performance.component.html',
|
||||
styleUrls: ['./portfolio-performance.component.scss']
|
||||
styleUrls: ['./portfolio-performance.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class PortfolioPerformanceComponent implements OnChanges {
|
||||
@Input() deviceType: string;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
||||
import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
|
||||
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
@ -16,7 +17,8 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
selector: 'gf-portfolio-summary',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './portfolio-summary.component.html',
|
||||
styleUrls: ['./portfolio-summary.component.scss']
|
||||
styleUrls: ['./portfolio-summary.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class PortfolioSummaryComponent implements OnChanges {
|
||||
@Input() baseCurrency: string;
|
||||
@ -34,6 +36,8 @@ export class PortfolioSummaryComponent implements OnChanges {
|
||||
);
|
||||
public timeInMarket: string;
|
||||
|
||||
public constructor(private notificationService: NotificationService) {}
|
||||
|
||||
public ngOnChanges() {
|
||||
if (this.summary) {
|
||||
if (this.summary.firstOrderDate) {
|
||||
@ -49,14 +53,15 @@ export class PortfolioSummaryComponent implements OnChanges {
|
||||
}
|
||||
|
||||
public onEditEmergencyFund() {
|
||||
const emergencyFundInput = prompt(
|
||||
$localize`Please enter the amount of your emergency fund:`,
|
||||
this.summary.emergencyFund?.total?.toString() ?? '0'
|
||||
);
|
||||
const emergencyFund = parseFloat(emergencyFundInput?.trim());
|
||||
this.notificationService.prompt({
|
||||
confirmFn: (value) => {
|
||||
const emergencyFund = parseFloat(value.trim()) || 0;
|
||||
|
||||
if (emergencyFund >= 0) {
|
||||
this.emergencyFundChanged.emit(emergencyFund);
|
||||
}
|
||||
this.emergencyFundChanged.emit(emergencyFund);
|
||||
},
|
||||
confirmLabel: $localize`Save`,
|
||||
defaultValue: this.summary.emergencyFund?.total?.toString() ?? '0',
|
||||
title: $localize`Please set the amount of your emergency fund.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,6 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
|
||||
MatSliderModule
|
||||
],
|
||||
selector: 'gf-rule-settings-dialog',
|
||||
standalone: true,
|
||||
styleUrls: ['./rule-settings-dialog.scss'],
|
||||
templateUrl: './rule-settings-dialog.html'
|
||||
})
|
||||
|
@ -24,7 +24,8 @@ import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-setti
|
||||
selector: 'gf-rule',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './rule.component.html',
|
||||
styleUrls: ['./rule.component.scss']
|
||||
styleUrls: ['./rule.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class RuleComponent implements OnInit {
|
||||
@Input() hasPermissionToUpdateUserSettings: boolean;
|
||||
|
@ -16,7 +16,8 @@ import {
|
||||
selector: 'gf-rules',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './rules.component.html',
|
||||
styleUrls: ['./rules.component.scss']
|
||||
styleUrls: ['./rules.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class RulesComponent {
|
||||
@Input() hasPermissionToUpdateUserSettings: boolean;
|
||||
|
@ -8,7 +8,8 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
|
||||
host: { class: 'd-flex flex-column flex-grow-1 h-100' },
|
||||
selector: 'gf-subscription-interstitial-dialog',
|
||||
styleUrls: ['./subscription-interstitial-dialog.scss'],
|
||||
templateUrl: 'subscription-interstitial-dialog.html'
|
||||
templateUrl: 'subscription-interstitial-dialog.html',
|
||||
standalone: false
|
||||
})
|
||||
export class SubscriptionInterstitialDialog {
|
||||
private readonly VARIANTS_COUNT = 2;
|
||||
|
@ -14,7 +14,8 @@ import { FormControl } from '@angular/forms';
|
||||
selector: 'gf-toggle',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './toggle.component.html',
|
||||
styleUrls: ['./toggle.component.scss']
|
||||
styleUrls: ['./toggle.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class ToggleComponent implements OnChanges {
|
||||
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
|
||||
|
@ -22,7 +22,8 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
|
||||
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'
|
||||
templateUrl: 'create-or-update-access-dialog.html',
|
||||
standalone: false
|
||||
})
|
||||
export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||
public accessForm: FormGroup;
|
||||
@ -38,7 +39,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
|
||||
private notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
public ngOnInit() {
|
||||
this.accessForm = this.formBuilder.group({
|
||||
alias: [this.data.access.alias],
|
||||
permissions: [this.data.access.permissions[0], Validators.required],
|
||||
|
@ -24,10 +24,12 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/cre
|
||||
host: { class: 'has-fab' },
|
||||
selector: 'gf-user-account-access',
|
||||
styleUrls: ['./user-account-access.scss'],
|
||||
templateUrl: './user-account-access.html'
|
||||
templateUrl: './user-account-access.html',
|
||||
standalone: false
|
||||
})
|
||||
export class UserAccountAccessComponent implements OnDestroy, OnInit {
|
||||
public accesses: Access[];
|
||||
public accessesGet: Access[];
|
||||
public accessesGive: Access[];
|
||||
public deviceType: string;
|
||||
public hasPermissionToCreateAccess: boolean;
|
||||
public hasPermissionToDeleteAccess: boolean;
|
||||
@ -125,11 +127,21 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.accessesGet = this.user.access.map(({ alias, id, permissions }) => {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: $localize`Me`,
|
||||
type: 'PRIVATE'
|
||||
};
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchAccesses()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((accesses) => {
|
||||
this.accesses = accesses;
|
||||
this.accessesGive = accesses;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
@ -1,14 +1,16 @@
|
||||
<div class="container">
|
||||
<h1
|
||||
class="align-items-center d-none d-sm-flex h3 justify-content-center mb-3 text-center"
|
||||
>
|
||||
@if (accessesGet.length > 0) {
|
||||
<h1 class="h3 mb-3 text-center" i18n>Received Access</h1>
|
||||
<gf-access-table class="mb-5" [accesses]="accessesGet" [user]="user" />
|
||||
}
|
||||
<h1 class="align-items-center d-flex h3 justify-content-center mb-3">
|
||||
<span i18n>Granted Access</span>
|
||||
@if (user?.subscription?.type === 'Basic') {
|
||||
<gf-premium-indicator class="ml-1" />
|
||||
}
|
||||
</h1>
|
||||
<gf-access-table
|
||||
[accesses]="accesses"
|
||||
[accesses]="accessesGive"
|
||||
[showActions]="hasPermissionToDeleteAccess"
|
||||
[user]="user"
|
||||
(accessDeleted)="onDeleteAccess($event)"
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
|
||||
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -16,7 +17,7 @@ import {
|
||||
MatSnackBarRef,
|
||||
TextOnlySnackBar
|
||||
} from '@angular/material/snack-bar';
|
||||
import { StringValue } from 'ms';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
@ -25,7 +26,8 @@ import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-user-account-membership',
|
||||
styleUrls: ['./user-account-membership.scss'],
|
||||
templateUrl: './user-account-membership.html'
|
||||
templateUrl: './user-account-membership.html',
|
||||
standalone: false
|
||||
})
|
||||
export class UserAccountMembershipComponent implements OnDestroy {
|
||||
public baseCurrency: string;
|
||||
@ -34,6 +36,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
public defaultDateFormat: string;
|
||||
public durationExtension: StringValue;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToCreateApiKey: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public price: number;
|
||||
public priceId: string;
|
||||
@ -73,6 +76,11 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
this.user.settings.locale
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateApiKey = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createApiKey
|
||||
);
|
||||
|
||||
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
@ -100,15 +108,15 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
this.dataService
|
||||
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
|
||||
.pipe(
|
||||
switchMap(({ sessionId }: { sessionId: string }) => {
|
||||
return this.stripeService.redirectToCheckout({ sessionId });
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.notificationService.alert({
|
||||
title: error.message
|
||||
});
|
||||
|
||||
throw error;
|
||||
}),
|
||||
switchMap(({ sessionId }: { sessionId: string }) => {
|
||||
return this.stripeService.redirectToCheckout({ sessionId });
|
||||
})
|
||||
)
|
||||
.subscribe((result) => {
|
||||
@ -120,51 +128,90 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
public onGenerateApiKey() {
|
||||
this.notificationService.confirm({
|
||||
confirmFn: () => {
|
||||
this.dataService
|
||||
.postApiKey()
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.snackBar.open(
|
||||
'😞 ' + $localize`Could not generate an API key`,
|
||||
undefined,
|
||||
{
|
||||
duration: ms('3 seconds')
|
||||
}
|
||||
);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ apiKey }) => {
|
||||
this.notificationService.alert({
|
||||
discardLabel: $localize`Okay`,
|
||||
message:
|
||||
$localize`Set this API key in your self-hosted environment:` +
|
||||
'<br />' +
|
||||
apiKey,
|
||||
title: $localize`Ghostfolio Premium Data Provider API Key`
|
||||
});
|
||||
});
|
||||
},
|
||||
confirmType: ConfirmationDialogType.Primary,
|
||||
title: $localize`Do you really want to generate a new API key?`
|
||||
});
|
||||
}
|
||||
|
||||
public onRedeemCoupon() {
|
||||
let couponCode = prompt($localize`Please enter your coupon code:`);
|
||||
couponCode = couponCode?.trim();
|
||||
this.notificationService.prompt({
|
||||
confirmFn: (value) => {
|
||||
const couponCode = value?.trim();
|
||||
|
||||
if (couponCode) {
|
||||
this.dataService
|
||||
.redeemCoupon(couponCode)
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeSubject),
|
||||
catchError(() => {
|
||||
this.snackBar.open(
|
||||
'😞 ' + $localize`Could not redeem coupon code`,
|
||||
undefined,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
if (couponCode) {
|
||||
this.dataService
|
||||
.redeemCoupon(couponCode)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.snackBar.open(
|
||||
'😞 ' + $localize`Could not redeem coupon code`,
|
||||
undefined,
|
||||
{
|
||||
duration: ms('3 seconds')
|
||||
}
|
||||
);
|
||||
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.snackBarRef = this.snackBar.open(
|
||||
'✅ ' + $localize`Coupon code has been redeemed`,
|
||||
$localize`Reload`,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
|
||||
this.snackBarRef
|
||||
.afterDismissed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
return EMPTY;
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
this.snackBarRef = this.snackBar.open(
|
||||
'✅ ' + $localize`Coupon code has been redeemed`,
|
||||
$localize`Reload`,
|
||||
{
|
||||
duration: 3000
|
||||
}
|
||||
);
|
||||
|
||||
this.snackBarRef
|
||||
.onAction()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
window.location.reload();
|
||||
this.snackBarRef
|
||||
.afterDismissed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
this.snackBarRef
|
||||
.onAction()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
title: $localize`Please enter your coupon code.`
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -4,7 +4,9 @@
|
||||
<div class="align-items-center d-flex flex-column">
|
||||
<gf-membership-card
|
||||
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
|
||||
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
|
||||
[name]="user?.subscription?.type"
|
||||
(generateApiKeyClicked)="onGenerateApiKey()"
|
||||
/>
|
||||
@if (user?.subscription?.type === 'Basic') {
|
||||
<div class="d-flex flex-column mt-5">
|
||||
|
@ -32,7 +32,8 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-user-account-settings',
|
||||
styleUrls: ['./user-account-settings.scss'],
|
||||
templateUrl: './user-account-settings.html'
|
||||
templateUrl: './user-account-settings.html',
|
||||
standalone: false
|
||||
})
|
||||
export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||
public appearancePlaceholder = $localize`Auto`;
|
||||
|
@ -14,7 +14,8 @@ import svgMap from 'svgmap';
|
||||
selector: 'gf-world-map-chart',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './world-map-chart.component.html',
|
||||
styleUrls: ['./world-map-chart.component.scss']
|
||||
styleUrls: ['./world-map-chart.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class WorldMapChartComponent implements OnChanges, OnDestroy {
|
||||
@Input() countries: { [code: string]: { name?: string; value: number } };
|
||||
|
@ -29,7 +29,7 @@ export class AuthGuard {
|
||||
`/${paths.resources}`
|
||||
];
|
||||
|
||||
constructor(
|
||||
public constructor(
|
||||
private dataService: DataService,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
|
@ -8,7 +8,6 @@ import { IAlertDialogParams } from './interfaces/interfaces';
|
||||
@Component({
|
||||
imports: [CommonModule, MatButtonModule, MatDialogModule],
|
||||
selector: 'gf-alert-dialog',
|
||||
standalone: true,
|
||||
styleUrls: ['./alert-dialog.scss'],
|
||||
templateUrl: './alert-dialog.html'
|
||||
})
|
||||
|
@ -9,7 +9,6 @@ import { IConfirmDialogParams } from './interfaces/interfaces';
|
||||
@Component({
|
||||
imports: [CommonModule, MatButtonModule, MatDialogModule],
|
||||
selector: 'gf-confirmation-dialog',
|
||||
standalone: true,
|
||||
styleUrls: ['./confirmation-dialog.scss'],
|
||||
templateUrl: './confirmation-dialog.html'
|
||||
})
|
||||
|
@ -17,3 +17,12 @@ export interface IConfirmParams {
|
||||
message?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface IPromptParams {
|
||||
confirmFn: (value: string) => void;
|
||||
confirmLabel?: string;
|
||||
defaultValue?: string;
|
||||
discardLabel?: string;
|
||||
title: string;
|
||||
valueLabel?: string;
|
||||
}
|
||||
|
@ -7,7 +7,12 @@ import { isFunction } from 'lodash';
|
||||
import { GfAlertDialogComponent } from './alert-dialog/alert-dialog.component';
|
||||
import { GfConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component';
|
||||
import { ConfirmationDialogType } from './confirmation-dialog/confirmation-dialog.type';
|
||||
import { IAlertParams, IConfirmParams } from './interfaces/interfaces';
|
||||
import {
|
||||
IAlertParams,
|
||||
IConfirmParams,
|
||||
IPromptParams
|
||||
} from './interfaces/interfaces';
|
||||
import { GfPromptDialogComponent } from './prompt-dialog/prompt-dialog.component';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@ -73,6 +78,36 @@ export class NotificationService {
|
||||
});
|
||||
}
|
||||
|
||||
public prompt(aParams: IPromptParams) {
|
||||
if (!aParams.confirmLabel) {
|
||||
aParams.confirmLabel = translate('OK');
|
||||
}
|
||||
|
||||
if (!aParams.discardLabel) {
|
||||
aParams.discardLabel = translate('CANCEL');
|
||||
}
|
||||
|
||||
const dialog = this.matDialog.open(GfPromptDialogComponent, {
|
||||
autoFocus: true,
|
||||
maxWidth: this.dialogMaxWidth,
|
||||
width: this.dialogWidth
|
||||
});
|
||||
|
||||
dialog.componentInstance.initialize({
|
||||
confirmLabel: aParams.confirmLabel,
|
||||
defaultValue: aParams.defaultValue,
|
||||
discardLabel: aParams.discardLabel,
|
||||
title: aParams.title,
|
||||
valueLabel: aParams.valueLabel
|
||||
});
|
||||
|
||||
return dialog.afterClosed().subscribe((result: string) => {
|
||||
if (result !== 'discard' && isFunction(aParams.confirmFn)) {
|
||||
aParams.confirmFn(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setDialogMaxWidth(aDialogMaxWidth: string) {
|
||||
this.dialogMaxWidth = aDialogMaxWidth;
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user