diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 2803a0580..4fbdafb08 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -31,6 +31,7 @@ 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 { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { PublicModule } from './endpoints/public/public.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; @@ -76,6 +77,7 @@ import { UserModule } from './user/user.module'; ExchangeRateModule, ExchangeRateDataModule, ExportModule, + GhostfolioModule, HealthModule, ImportModule, InfoModule, diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts new file mode 100644 index 000000000..9b9020451 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -0,0 +1,43 @@ +import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { + Controller, + Get, + HttpException, + Query, + UseGuards +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +import { GhostfolioService } from './ghostfolio.service'; + +@Controller('data-providers/ghostfolio') +export class GhostfolioController { + public constructor(private readonly ghostfolioService: GhostfolioService) {} + + @Get('lookup') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async lookupSymbol( + @Query('includeIndices') includeIndicesParam = 'false', + @Query('query') query = '' + ): Promise<{ items: LookupItem[] }> { + const includeIndices = includeIndicesParam === 'true'; + + try { + return this.ghostfolioService.lookup({ + includeIndices, + query: query.toLowerCase() + }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts new file mode 100644 index 000000000..01691bcf4 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts @@ -0,0 +1,83 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; +import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; +import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; +import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; +import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; +import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; +import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; +import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; +import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { GhostfolioController } from './ghostfolio.controller'; +import { GhostfolioService } from './ghostfolio.service'; + +@Module({ + controllers: [GhostfolioController], + imports: [ + CryptocurrencyModule, + DataProviderModule, + MarketDataModule, + PrismaModule, + PropertyModule, + RedisCacheModule, + SymbolProfileModule + ], + providers: [ + AlphaVantageService, + CoinGeckoService, + ConfigurationService, + DataProviderService, + EodHistoricalDataService, + FinancialModelingPrepService, + GhostfolioService, + GoogleSheetsService, + ManualService, + RapidApiService, + YahooFinanceService, + YahooFinanceDataEnhancerService, + { + inject: [ + AlphaVantageService, + CoinGeckoService, + EodHistoricalDataService, + FinancialModelingPrepService, + GoogleSheetsService, + ManualService, + RapidApiService, + YahooFinanceService + ], + provide: 'DataProviderInterfaces', + useFactory: ( + alphaVantageService, + coinGeckoService, + eodHistoricalDataService, + financialModelingPrepService, + googleSheetsService, + manualService, + rapidApiService, + yahooFinanceService + ) => [ + alphaVantageService, + coinGeckoService, + eodHistoricalDataService, + financialModelingPrepService, + googleSheetsService, + manualService, + rapidApiService, + yahooFinanceService + ] + } + ] +}) +export class GhostfolioModule {} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts new file mode 100644 index 000000000..08fb13dbf --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -0,0 +1,93 @@ +import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { DataProviderInfo } from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; + +@Injectable() +export class GhostfolioService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService + ) {} + + public async lookup({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }): Promise<{ items: LookupItem[] }> { + const results: { items: LookupItem[] } = { items: [] }; + + if (!query) { + return results; + } + + try { + let lookupItems: LookupItem[] = []; + const promises: Promise<{ items: LookupItem[] }>[] = []; + + if (query?.length < 2) { + return { items: lookupItems }; + } + + for (const dataProviderService of this.getDataProviderServices()) { + promises.push( + dataProviderService.search({ + includeIndices, + query + }) + ); + } + + const searchResults = await Promise.all(promises); + + searchResults.forEach(({ items }) => { + if (items?.length > 0) { + lookupItems = lookupItems.concat(items); + } + }); + + const filteredItems = lookupItems + .filter(({ currency }) => { + // Only allow symbols with supported currency + return currency ? true : false; + }) + .sort(({ name: name1 }, { name: name2 }) => { + return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); + }) + .map((lookupItem) => { + lookupItem.dataProviderInfo = this.getDataProviderInfo(); + lookupItem.dataSource = 'GHOSTFOLIO'; + + return lookupItem; + }); + + results.items = filteredItems; + return results; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + + private getDataProviderInfo(): DataProviderInfo { + return { + isPremium: true, + name: 'Ghostfolio Premium', + url: 'https://ghostfol.io' + }; + } + + private getDataProviderServices() { + return this.configurationService + .get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + .map((dataSource) => { + return this.dataProviderService.getDataProvider(DataSource[dataSource]); + }); + } +} diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 443a2a052..71a56e99c 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -307,6 +307,7 @@ export class UserService { // Reset holdings view mode user.Settings.settings.holdingsViewMode = undefined; } else if (user.subscription?.type === 'Premium') { + currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.reportDataGlitch); currentPermissions = without( diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 10810deb5..acde7d823 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -35,6 +35,9 @@ export class ConfigurationService { DATA_SOURCES: json({ default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] }), + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({ + default: [] + }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 8d6dd34de..2f94739fb 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -15,6 +15,7 @@ export interface Environment extends CleanedEnvAccessors { DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_IMPORT: string; DATA_SOURCES: string[]; + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean; diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 8a517c5fe..f4b61ea33 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -32,6 +32,15 @@ const routes: Routes = [ loadChildren: () => import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule) }, + { + canActivate: [AuthGuard], + loadComponent: () => + import('./pages/api/api-page.component').then( + (c) => c.GfApiPageComponent + ), + path: 'api', + title: 'Ghostfolio API' + }, { path: 'auth', loadChildren: () => diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts new file mode 100644 index 000000000..6ad2e8e4d --- /dev/null +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -0,0 +1,56 @@ +import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; + +import { CommonModule } from '@angular/common'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Component, OnInit } from '@angular/core'; +import { map, Observable, Subject, takeUntil } from 'rxjs'; + +@Component({ + host: { class: 'page' }, + imports: [CommonModule], + selector: 'gf-api-page', + standalone: true, + styleUrls: ['./api-page.scss'], + templateUrl: './api-page.html' +}) +export class GfApiPageComponent implements OnInit { + public symbols$: Observable; + + private unsubscribeSubject = new Subject(); + + public constructor(private http: HttpClient) {} + + public ngOnInit() { + this.symbols$ = this.fetchSymbols({ query: 'apple' }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private fetchSymbols({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }) { + let params = new HttpParams().set('query', query); + + if (includeIndices) { + params = params.append('includeIndices', includeIndices); + } + + return this.http + .get<{ + items: LookupItem[]; + }>('/api/v1/data-providers/ghostfolio/lookup', { params }) + .pipe( + map(({ items }) => { + return items; + }), + takeUntil(this.unsubscribeSubject) + ); + } +} diff --git a/apps/client/src/app/pages/api/api-page.html b/apps/client/src/app/pages/api/api-page.html new file mode 100644 index 000000000..7097333ed --- /dev/null +++ b/apps/client/src/app/pages/api/api-page.html @@ -0,0 +1,10 @@ +
+
+

Lookup

+
    + @for (item of symbols; track item.symbol) { +
  • {{ item.name }} ({{ item.symbol }})
  • + } +
+
+
diff --git a/apps/client/src/app/pages/api/api-page.scss b/apps/client/src/app/pages/api/api-page.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/client/src/app/pages/api/api-page.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index ab443ea5e..1a81938b5 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -22,6 +22,7 @@ export const permissions = { deletePlatform: 'deletePlatform', deleteTag: 'deleteTag', deleteUser: 'deleteUser', + enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableFearAndGreedIndex: 'enableFearAndGreedIndex', enableImport: 'enableImport', enableBlog: 'enableBlog', diff --git a/prisma/migrations/20241103110114_added_ghostfolio_to_data_source/migration.sql b/prisma/migrations/20241103110114_added_ghostfolio_to_data_source/migration.sql new file mode 100644 index 000000000..9687a87b0 --- /dev/null +++ b/prisma/migrations/20241103110114_added_ghostfolio_to_data_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "DataSource" ADD VALUE 'GHOSTFOLIO'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a34e8e11..5fe4ce7c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -281,6 +281,7 @@ enum DataSource { COINGECKO EOD_HISTORICAL_DATA FINANCIAL_MODELING_PREP + GHOSTFOLIO GOOGLE_SHEETS MANUAL RAPID_API