This commit is contained in:
sudacode 2025-01-05 16:47:32 -08:00
commit f6262e2021
237 changed files with 18646 additions and 17359 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -176,6 +176,7 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) {
return {
currentValueInBaseCurrency: new Big(0),
errors: [],
hasErrors: false,
historicalData: [],
positions: [],

View File

@ -101,6 +101,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -618,7 +618,8 @@ export class DataProviderService {
promises.push(
dataProviderService.search({
includeIndices,
query
query,
userId: user.id
})
);
}

View File

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

View File

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

View File

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

View File

@ -79,4 +79,5 @@ export interface GetQuotesParams {
export interface GetSearchParams {
includeIndices?: boolean;
query: string;
userId?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,12 @@
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts"],
"rules": {
"@angular-eslint/prefer-standalone": "off"
}
}
],
"plugins": ["@angular-eslint/eslint-plugin", "@typescript-eslint"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,7 @@
import { User } from '@ghostfolio/common/interfaces';
export interface GhostfolioPremiumApiDialogParams {
deviceType: string;
pricingUrl: string;
user: User;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ export class AuthGuard {
`/${paths.resources}`
];
constructor(
public constructor(
private dataService: DataService,
private router: Router,
private settingsStorageService: SettingsStorageService,

View File

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

View File

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

View File

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

View File

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