Browse Source

Merge branch 'main' into task/improve-stripe-checkout-session-verification

pull/6872/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
b43315cfa1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .config/prisma.ts
  2. 9
      CHANGELOG.md
  3. 41
      README.md
  4. 42
      apps/api/src/app/app.module.ts
  5. 2
      apps/api/src/app/user/user.service.ts
  6. 1
      apps/api/src/services/configuration/configuration.service.ts
  7. 1
      apps/api/src/services/interfaces/environment.interface.ts
  8. 20
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  9. 20
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  10. 2
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  11. 21
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  12. 152
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  13. 52
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  14. 3
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  15. 6
      apps/client/src/app/pages/pricing/pricing-page.scss
  16. 3
      libs/common/src/lib/config.ts
  17. 10
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  18. 4
      libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts
  19. 40
      package-lock.json
  20. 6
      package.json
  21. 2
      prisma/seed.mts

2
.config/prisma.ts

@ -7,7 +7,7 @@ expand(config({ quiet: true }));
export default defineConfig({ export default defineConfig({
datasource: { datasource: {
url: process.env.DATABASE_URL ?? '' url: process.env.DIRECT_URL ?? process.env.DATABASE_URL
}, },
migrations: { migrations: {
path: join(__dirname, '..', 'prisma', 'migrations'), path: join(__dirname, '..', 'prisma', 'migrations'),

9
CHANGELOG.md

@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added support for the `DIRECT_URL` environment variable to enable direct database connections
### Changed ### Changed
- Extracted the page tabs to a reusable component - Improved the pagination in the activities table of the holding detail dialog
- Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental)
- Improved the verification of the _Stripe_ checkout session when creating a subscription - Improved the verification of the _Stripe_ checkout session when creating a subscription
- Extracted the page tabs to a reusable component
- Upgraded `bull-board` from version `7.0.0` to `7.1.5`
### Fixed ### Fixed

41
README.md

@ -85,26 +85,27 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | | `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | | `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### OpenID Connect OIDC (Experimental) #### OpenID Connect OIDC (Experimental)

42
apps/api/src/app/app.module.ts

@ -74,29 +74,25 @@ import { UserModule } from './user/user.module';
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarksModule, BenchmarksModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' BullBoardModule.forRoot({
? [ adapter: ExpressAdapter,
BullBoardModule.forRoot({ boardOptions: {
adapter: ExpressAdapter, uiConfig: {
boardOptions: { boardLogo: {
uiConfig: { height: 0,
boardLogo: { path: '',
height: 0, width: 0
path: '', },
width: 0 boardTitle: 'Job Queues',
}, favIcon: {
boardTitle: 'Job Queues', alternative: '/assets/favicon-32x32.png',
favIcon: { default: '/assets/favicon-32x32.png'
alternative: '/assets/favicon-32x32.png', }
default: '/assets/favicon-32x32.png' }
} },
} middleware: BullBoardAuthMiddleware,
}, route: BULL_BOARD_ROUTE
middleware: BullBoardAuthMiddleware, }),
route: BULL_BOARD_ROUTE
})
]
: []),
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),

2
apps/api/src/app/user/user.service.ts

@ -532,7 +532,7 @@ export class UserService {
} }
if (hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
if (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) { if ((user.settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions.push(permissions.accessAdminControlBullBoard); currentPermissions.push(permissions.accessAdminControlBullBoard);
} }

1
apps/api/src/services/configuration/configuration.service.ts

@ -44,7 +44,6 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_BULL_BOARD: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -20,7 +20,6 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_BULL_BOARD: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;

20
apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -19,18 +19,14 @@ import { DataGatheringProcessor } from './data-gathering.processor';
@Module({ @Module({
imports: [ imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' BullBoardModule.forFeature({
? [ adapter: BullAdapter,
BullBoardModule.forFeature({ name: DATA_GATHERING_QUEUE,
adapter: BullAdapter, options: {
name: DATA_GATHERING_QUEUE, displayName: 'Data Gathering',
options: { readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
displayName: 'Data Gathering', }
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' }),
}
})
]
: []),
BullModule.registerQueue({ BullModule.registerQueue({
limiter: { limiter: {
duration: ms('4 seconds'), duration: ms('4 seconds'),

20
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -25,18 +25,14 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
imports: [ imports: [
AccountBalanceModule, AccountBalanceModule,
ActivitiesModule, ActivitiesModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' BullBoardModule.forFeature({
? [ adapter: BullAdapter,
BullBoardModule.forFeature({ name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
adapter: BullAdapter, options: {
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, displayName: 'Portfolio Snapshot Computation',
options: { readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
displayName: 'Portfolio Snapshot Computation', }
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' }),
}
})
]
: []),
BullModule.registerQueue({ BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: { settings: {

2
apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts

@ -13,7 +13,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
@Module({ @Module({
exports: [BullModule, StatisticsGatheringService], exports: [BullModule, StatisticsGatheringService],
imports: [ imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' ...(process.env.ENABLE_FEATURE_STATISTICS === 'true'
? [ ? [
BullBoardModule.forFeature({ BullBoardModule.forFeature({
adapter: BullAdapter, adapter: BullAdapter,

21
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -93,12 +93,25 @@ export class StatisticsGatheringProcessor {
@Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME)
public async gatherUptimeStatistics() { public async gatherUptimeStatistics() {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
if (!monitorId) {
Logger.log(
`Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`,
'StatisticsGatheringProcessor'
);
return;
}
Logger.log( Logger.log(
'Uptime statistics gathering has been started', 'Uptime statistics gathering has been started',
'StatisticsGatheringProcessor' 'StatisticsGatheringProcessor'
); );
const uptime = await this.getUptime(); const uptime = await this.getUptime(monitorId);
await this.propertyService.put({ await this.propertyService.put({
key: PROPERTY_UPTIME, key: PROPERTY_UPTIME,
@ -179,12 +192,8 @@ export class StatisticsGatheringProcessor {
} }
} }
private async getUptime(): Promise<number> { private async getUptime(monitorId: string): Promise<number> {
try { try {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
const { data } = await fetch( const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),

152
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -1,7 +1,8 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_COLOR_SCHEME,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
ghostfolioScraperApiSymbolPrefix locale
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
@ -26,9 +27,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject,
OnInit, OnInit,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -97,11 +100,8 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'
}) })
export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator; protected readonly adminMarketDataService = inject(AdminMarketDataService);
@ViewChild(MatSort) sort: MatSort; protected readonly allFilters: Filter[] = [
public activeFilters: Filter[] = [];
public allFilters: Filter[] = [
...Object.keys(AssetSubClass) ...Object.keys(AssetSubClass)
.filter((assetSubClass) => { .filter((assetSubClass) => {
return assetSubClass !== 'CASH'; return assetSubClass !== 'CASH';
@ -146,37 +146,39 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
type: 'PRESET_ID' as Filter['type'] type: 'PRESET_ID' as Filter['type']
} }
]; ];
public benchmarks: Partial<SymbolProfile>[]; protected dataSource = new MatTableDataSource<AdminMarketDataItem>();
public currentDataSource: DataSource; protected defaultDateFormat: string;
public currentSymbol: string; protected readonly displayedColumns: string[] = [];
public dataSource = new MatTableDataSource<AdminMarketDataItem>(); protected readonly filters$ = new Subject<Filter[]>();
public defaultDateFormat: string; protected isLoading = true;
public deviceType: string; protected readonly isUUID = isUUID;
public displayedColumns: string[] = []; protected pageSize = DEFAULT_PAGE_SIZE;
public filters$ = new Subject<Filter[]>(); protected placeholder = '';
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; protected readonly selection = new SelectionModel<AdminMarketDataItem>(true);
public hasPermissionForSubscription: boolean; protected totalItems = 0;
public info: InfoItem; protected user: User;
public isLoading = true;
public isUUID = isUUID; private activeFilters: Filter[] = [];
public placeholder = ''; private benchmarks: Partial<SymbolProfile>[];
public pageSize = DEFAULT_PAGE_SIZE; private readonly deviceType = computed(
public selection: SelectionModel<Partial<SymbolProfile>>; () => this.deviceDetectorService.deviceInfo().deviceType
public totalItems = 0; );
public user: User; private readonly hasPermissionForSubscription: boolean;
private readonly info: InfoItem;
public constructor( private readonly paginator = viewChild.required(MatPaginator);
public adminMarketDataService: AdminMarketDataService, private readonly sort = viewChild.required(MatSort);
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private readonly adminService = inject(AdminService);
private dataService: DataService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private destroyRef: DestroyRef, private readonly dataService = inject(DataService);
private deviceDetectorService: DeviceDetectorService, private readonly destroyRef = inject(DestroyRef);
private dialog: MatDialog, private readonly deviceDetectorService = inject(DeviceDetectorService);
private route: ActivatedRoute, private readonly dialog = inject(MatDialog);
private router: Router, private readonly route = inject(ActivatedRoute);
private userService: UserService private readonly router = inject(Router);
) { private readonly userService = inject(UserService);
public constructor() {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
@ -255,14 +257,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
} }
public ngAfterViewInit() { public ngAfterViewInit() {
this.sort.sortChange.subscribe( this.sort().sortChange.subscribe(
({ active: sortColumn, direction }: Sort) => { ({ active: sortColumn, direction }: Sort) => {
this.paginator.pageIndex = 0; this.paginator().pageIndex = 0;
this.loadData({ this.loadData({
sortColumn, sortColumn,
sortDirection: direction, sortDirection: direction,
pageIndex: this.paginator.pageIndex pageIndex: this.paginator().pageIndex
}); });
} }
); );
@ -272,24 +274,24 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
const { benchmarks } = this.dataService.fetchInfo(); const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.selection = new SelectionModel(true);
} }
public onChangePage(page: PageEvent) { protected onChangePage(page: PageEvent) {
this.loadData({ this.loadData({
pageIndex: page.pageIndex, pageIndex: page.pageIndex,
sortColumn: this.sort.active, sortColumn: this.sort().active,
sortDirection: this.sort.direction sortDirection: this.sort().direction
}); });
} }
public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { protected onDeleteAssetProfile({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
} }
public onDeleteAssetProfiles() { protected onDeleteAssetProfiles() {
this.adminMarketDataService.deleteAssetProfiles( this.adminMarketDataService.deleteAssetProfiles(
this.selection.selected.map(({ dataSource, symbol }) => { this.selection.selected.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -297,7 +299,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
); );
} }
public onGather7Days() { protected onGather7Days() {
this.adminService this.adminService
.gather7Days() .gather7Days()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -308,7 +310,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
} }
public onGatherMax() { protected onGatherMax() {
this.adminService this.adminService
.gatherMax() .gatherMax()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -319,31 +321,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
} }
public onGatherProfileData() { protected onGatherProfileData() {
this.adminService this.adminService
.gatherProfileData() .gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe();
} }
public onGatherProfileDataBySymbol({ protected onOpenAssetProfileDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
public onOpenAssetProfileDialog({
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
@ -375,8 +360,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
? Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER
: DEFAULT_PAGE_SIZE; : DEFAULT_PAGE_SIZE;
if (pageIndex === 0 && this.paginator) { if (pageIndex === 0 && this.paginator()) {
this.paginator.pageIndex = 0; this.paginator().pageIndex = 0;
} }
this.placeholder = this.placeholder =
@ -406,7 +391,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}; };
}) })
); );
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort();
this.isLoading = false; this.isLoading = false;
@ -435,12 +420,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
data: { data: {
dataSource, dataSource,
symbol, symbol,
colorScheme: this.user?.settings.colorScheme, colorScheme:
deviceType: this.deviceType, this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
locale: this.user?.settings?.locale deviceType: this.deviceType(),
}, locale: this.user?.settings?.locale ?? locale
height: this.deviceType === 'mobile' ? '98vh' : '80vh', } satisfies AssetProfileDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
@ -471,10 +457,10 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
>(GfCreateAssetProfileDialogComponent, { >(GfCreateAssetProfileDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
deviceType: this.deviceType, deviceType: this.deviceType(),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale ?? locale
}, } satisfies CreateAssetProfileDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef

52
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES, NUMERICAL_PRECISION_THRESHOLD_5_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
@ -48,6 +49,7 @@ import {
MatDialogRef MatDialogRef
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { PageEvent } from '@angular/material/paginator';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
@ -140,6 +142,8 @@ export class GfHoldingDetailDialogComponent implements OnInit {
public netPerformancePercentWithCurrencyEffectPrecision = 2; public netPerformancePercentWithCurrencyEffectPrecision = 2;
public netPerformanceWithCurrencyEffect: number; public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2; public netPerformanceWithCurrencyEffectPrecision = 2;
public pageIndex = 0;
public pageSize = DEFAULT_PAGE_SIZE;
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
@ -178,10 +182,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
const filters: Filter[] = [ const filters = this.getActivityFilters();
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
{ id: this.data.symbol, type: 'SYMBOL' }
];
this.holdingForm = this.formBuilder.group({ this.holdingForm = this.formBuilder.group({
tags: [] as string[] tags: [] as string[]
@ -240,18 +241,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService this.fetchActivities(filters);
.fetchActivities({
filters,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities);
this.changeDetectorRef.markForCheck();
});
this.dataService this.dataService
.fetchHoldingDetail({ .fetchHoldingDetail({
@ -543,6 +533,12 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}); });
} }
public onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex;
this.fetchActivities();
}
public onCloneActivity(aActivity: Activity) { public onCloneActivity(aActivity: Activity) {
this.router.navigate( this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink, internalRoutes.portfolio.subRoutes.activities.routerLink,
@ -626,6 +622,23 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
private fetchActivities(filters: Filter[] = this.getActivityFilters()) {
this.dataService
.fetchActivities({
filters,
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection,
take: this.pageSize
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities);
this.changeDetectorRef.markForCheck();
});
}
private fetchMarketData() { private fetchMarketData() {
this.dataService this.dataService
.fetchMarketDataBySymbol({ .fetchMarketDataBySymbol({
@ -648,4 +661,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
private getActivityFilters(): Filter[] {
return [
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
{ id: this.data.symbol, type: 'SYMBOL' }
];
}
} }

3
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -353,6 +353,8 @@
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="data.locale" [locale]="data.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showActions]=" [showActions]="
!data.hasImpersonationId && !data.hasImpersonationId &&
data.hasPermissionToCreateActivity && data.hasPermissionToCreateActivity &&
@ -367,6 +369,7 @@
(activityToClone)="onCloneActivity($event)" (activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)" (activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()" (export)="onExport()"
(pageChanged)="onChangePage($event)"
/> />
</mat-tab> </mat-tab>
<mat-tab> <mat-tab>

6
apps/client/src/app/pages/pricing/pricing-page.scss

@ -18,8 +18,12 @@
border-color 0.5s ease, border-color 0.5s ease,
box-shadow 0.5s ease; box-shadow 0.5s ease;
&:hover,
&.active { &.active {
border-color: currentColor;
box-shadow: 0 0 0 1px currentColor;
}
&:hover {
border-color: rgba(var(--palette-primary-500), 1); border-color: rgba(var(--palette-primary-500), 1);
box-shadow: 0 0 0 1px rgba(var(--palette-primary-500), 1); box-shadow: 0 0 0 1px rgba(var(--palette-primary-500), 1);
} }

3
libs/common/src/lib/config.ts

@ -2,6 +2,8 @@ import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { JobOptions, JobStatus } from 'bull'; import { JobOptions, JobStatus } from 'bull';
import ms from 'ms'; import ms from 'ms';
import { ColorScheme } from './types';
export const ghostfolioPrefix = 'GF'; export const ghostfolioPrefix = 'GF';
export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`;
export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies = export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies =
@ -77,6 +79,7 @@ export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW =
export const STATISTICS_GATHERING_QUEUE = 'STATISTICS_GATHERING_QUEUE'; export const STATISTICS_GATHERING_QUEUE = 'STATISTICS_GATHERING_QUEUE';
export const DEFAULT_COLOR_SCHEME: ColorScheme = 'LIGHT';
export const DEFAULT_CURRENCY = 'USD'; export const DEFAULT_CURRENCY = 'USD';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_HOST = '0.0.0.0'; export const DEFAULT_HOST = '0.0.0.0';

10
libs/ui/src/lib/page-tabs/page-tabs.component.scss

@ -14,13 +14,13 @@
) )
); );
.fab-container { ::ng-deep {
@media (max-width: 575.98px) { .fab-container {
bottom: 5rem; @media (max-width: 575.98px) {
bottom: 5rem;
}
} }
}
::ng-deep {
.mat-mdc-tab-nav-panel { .mat-mdc-tab-nav-panel {
padding: 2rem 0; padding: 2rem 0;
} }

4
libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts

@ -1,3 +1,5 @@
import { DEFAULT_COLOR_SCHEME } from '@ghostfolio/common/config';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import '@angular/localize/init'; import '@angular/localize/init';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
@ -37,7 +39,7 @@ export const Default: Story = {
args: { args: {
holdings, holdings,
baseCurrency: 'USD', baseCurrency: 'USD',
colorScheme: 'LIGHT', colorScheme: DEFAULT_COLOR_SCHEME,
cursor: undefined, cursor: undefined,
dateRange: 'mtd', dateRange: 'mtd',
locale: 'en-US' locale: 'en-US'

40
package-lock.json

@ -21,9 +21,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "7.0.0", "@bull-board/api": "7.1.5",
"@bull-board/express": "7.0.0", "@bull-board/express": "7.1.5",
"@bull-board/nestjs": "7.0.0", "@bull-board/nestjs": "7.1.5",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.6", "@internationalized/number": "3.6.6",
@ -3522,25 +3522,25 @@
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@bull-board/api": { "node_modules/@bull-board/api": {
"version": "7.0.0", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.0.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz",
"integrity": "sha512-ISNspLHVmUWUSq/eLw+wd1FuBBUnqpLbYP2xUNmehpfKhS+NoZWMbBvqjUYVeE/HLfUkRcR1edzMKpl5n9zlSw==", "integrity": "sha512-EW0sbTtGIysu9vipdVpPQeToPqOpPgVZTt+pn1Ut3gbSS/GLWbEgIfFtMmSQDUoSL9WH00RzjgUY5K+43nWh0A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis-info": "^3.1.0" "redis-info": "^3.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@bull-board/ui": "7.0.0" "@bull-board/ui": "7.1.5"
} }
}, },
"node_modules/@bull-board/express": { "node_modules/@bull-board/express": {
"version": "7.0.0", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.0.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.1.5.tgz",
"integrity": "sha512-3Tc/EyU5PQMTcTzcafFSrmRDiEbJBEU/EaVQ5OVYcuJ7DZCp5Pkvm0/2VtaCe2uywdtwn0ZaynlSIpB27FKX6A==", "integrity": "sha512-kp4SzhVjZlykryiQwcOhJjDhiLbBnZoAMoSgEstzqQ0raLw+jERRC6ryJ0MIQO+SO+Jv9EjjxrXCR8O2YSP/eg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "7.0.0", "@bull-board/api": "7.1.5",
"@bull-board/ui": "7.0.0", "@bull-board/ui": "7.1.5",
"ejs": "^5.0.2", "ejs": "^5.0.2",
"express": "^5.2.1" "express": "^5.2.1"
} }
@ -3558,12 +3558,12 @@
} }
}, },
"node_modules/@bull-board/nestjs": { "node_modules/@bull-board/nestjs": {
"version": "7.0.0", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.0.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.1.5.tgz",
"integrity": "sha512-ypXm0eJHIMQzjN+3fjf84cVxugBg/K4Bpo0eYcV4u/AsteR/dnr6e7F79ICRgg1WWoczqmSMl0JhlmykpyhAMg==", "integrity": "sha512-1y+HkjnDaZoSCXJRsiYfBNBVx+PX3I8x3Uv+SSJuSpt2vHifMRwFbChO3XDxeWXetT1eR+yqPVq6ub5eJwNOYQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@bull-board/api": "^7.0.0", "@bull-board/api": "^7.1.5",
"@nestjs/bull-shared": "^10.0.0 || ^11.0.0", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
@ -3572,12 +3572,12 @@
} }
}, },
"node_modules/@bull-board/ui": { "node_modules/@bull-board/ui": {
"version": "7.0.0", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.0.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.1.5.tgz",
"integrity": "sha512-AnKeklpDn0iMFgu4ukDU6uTNmw4oudl07G4k2Fh95SknKDrXSiWRV0N1TGUawMqyfG1Yi5P/W/8d7raBq/Uw6w==", "integrity": "sha512-2IkatKwNRx/1M9/lAZIptcxS1FPNq6icpp2M46Upwd4olVxs/ujF9Kvs+Ff9ExtIO/OgYfwx7mG2IprGZ+nQCg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "7.0.0" "@bull-board/api": "7.1.5"
} }
}, },
"node_modules/@cacheable/utils": { "node_modules/@cacheable/utils": {

6
package.json

@ -65,9 +65,9 @@
"@angular/platform-browser-dynamic": "21.2.7", "@angular/platform-browser-dynamic": "21.2.7",
"@angular/router": "21.2.7", "@angular/router": "21.2.7",
"@angular/service-worker": "21.2.7", "@angular/service-worker": "21.2.7",
"@bull-board/api": "7.0.0", "@bull-board/api": "7.1.5",
"@bull-board/express": "7.0.0", "@bull-board/express": "7.1.5",
"@bull-board/nestjs": "7.0.0", "@bull-board/nestjs": "7.1.5",
"@codewithdan/observable-store": "2.2.15", "@codewithdan/observable-store": "2.2.15",
"@date-fns/utc": "2.1.1", "@date-fns/utc": "2.1.1",
"@internationalized/number": "3.6.6", "@internationalized/number": "3.6.6",

2
prisma/seed.mts

@ -2,7 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const adapter = new PrismaPg({ const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL connectionString: process.env.DIRECT_URL ?? process.env.DATABASE_URL
}); });
const prisma = new PrismaClient({ adapter }); const prisma = new PrismaClient({ adapter });

Loading…
Cancel
Save