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({
datasource: {
url: process.env.DATABASE_URL ?? ''
url: process.env.DIRECT_URL ?? process.env.DATABASE_URL
},
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
### Added
- Added support for the `DIRECT_URL` environment variable to enable direct database connections
### 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
- Extracted the page tabs to a reusable component
- Upgraded `bull-board` from version `7.0.0` to `7.1.5`
### Fixed

41
README.md

@ -85,26 +85,27 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `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_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}` |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `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. |
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `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}` |
| `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`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `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)

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

@ -74,29 +74,25 @@ import { UserModule } from './user/user.module';
AuthDeviceModule,
AuthModule,
BenchmarksModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forRoot({
adapter: ExpressAdapter,
boardOptions: {
uiConfig: {
boardLogo: {
height: 0,
path: '',
width: 0
},
boardTitle: 'Job Queues',
favIcon: {
alternative: '/assets/favicon-32x32.png',
default: '/assets/favicon-32x32.png'
}
}
},
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
})
]
: []),
BullBoardModule.forRoot({
adapter: ExpressAdapter,
boardOptions: {
uiConfig: {
boardLogo: {
height: 0,
path: '',
width: 0
},
boardTitle: 'Job Queues',
favIcon: {
alternative: '/assets/favicon-32x32.png',
default: '/assets/favicon-32x32.png'
}
}
},
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
}),
BullModule.forRoot({
redis: {
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 (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) {
if ((user.settings.settings as UserSettings).isExperimentalFeatures) {
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_OIDC: bool({ default: false }),
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_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
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_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_BULL_BOARD: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: 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({
imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: DATA_GATHERING_QUEUE,
options: {
displayName: 'Data Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullBoardModule.forFeature({
adapter: BullAdapter,
name: DATA_GATHERING_QUEUE,
options: {
displayName: 'Data Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
}),
BullModule.registerQueue({
limiter: {
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: [
AccountBalanceModule,
ActivitiesModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: {
displayName: 'Portfolio Snapshot Computation',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullBoardModule.forFeature({
adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: {
displayName: 'Portfolio Snapshot Computation',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
}),
BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: {

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

@ -13,7 +13,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
@Module({
exports: [BullModule, StatisticsGatheringService],
imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
...(process.env.ENABLE_FEATURE_STATISTICS === 'true'
? [
BullBoardModule.forFeature({
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)
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(
'Uptime statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const uptime = await this.getUptime();
const uptime = await this.getUptime(monitorId);
await this.propertyService.put({
key: PROPERTY_UPTIME,
@ -179,12 +192,8 @@ export class StatisticsGatheringProcessor {
}
}
private async getUptime(): Promise<number> {
private async getUptime(monitorId: string): Promise<number> {
try {
const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID
);
const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
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 {
DEFAULT_COLOR_SCHEME,
DEFAULT_PAGE_SIZE,
ghostfolioScraperApiSymbolPrefix
locale
} from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
@ -26,9 +27,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
inject,
OnInit,
ViewChild
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@ -97,11 +100,8 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
templateUrl: './admin-market-data.html'
})
export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = [];
public allFilters: Filter[] = [
protected readonly adminMarketDataService = inject(AdminMarketDataService);
protected readonly allFilters: Filter[] = [
...Object.keys(AssetSubClass)
.filter((assetSubClass) => {
return assetSubClass !== 'CASH';
@ -146,37 +146,39 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
type: 'PRESET_ID' as Filter['type']
}
];
public benchmarks: Partial<SymbolProfile>[];
public currentDataSource: DataSource;
public currentSymbol: string;
public dataSource = new MatTableDataSource<AdminMarketDataItem>();
public defaultDateFormat: string;
public deviceType: string;
public displayedColumns: string[] = [];
public filters$ = new Subject<Filter[]>();
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public isLoading = true;
public isUUID = isUUID;
public placeholder = '';
public pageSize = DEFAULT_PAGE_SIZE;
public selection: SelectionModel<Partial<SymbolProfile>>;
public totalItems = 0;
public user: User;
public constructor(
public adminMarketDataService: AdminMarketDataService,
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceDetectorService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
protected dataSource = new MatTableDataSource<AdminMarketDataItem>();
protected defaultDateFormat: string;
protected readonly displayedColumns: string[] = [];
protected readonly filters$ = new Subject<Filter[]>();
protected isLoading = true;
protected readonly isUUID = isUUID;
protected pageSize = DEFAULT_PAGE_SIZE;
protected placeholder = '';
protected readonly selection = new SelectionModel<AdminMarketDataItem>(true);
protected totalItems = 0;
protected user: User;
private activeFilters: Filter[] = [];
private benchmarks: Partial<SymbolProfile>[];
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly hasPermissionForSubscription: boolean;
private readonly info: InfoItem;
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort);
private readonly adminService = inject(AdminService);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
@ -255,14 +257,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}
public ngAfterViewInit() {
this.sort.sortChange.subscribe(
this.sort().sortChange.subscribe(
({ active: sortColumn, direction }: Sort) => {
this.paginator.pageIndex = 0;
this.paginator().pageIndex = 0;
this.loadData({
sortColumn,
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();
this.benchmarks = benchmarks;
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.selection = new SelectionModel(true);
}
public onChangePage(page: PageEvent) {
protected onChangePage(page: PageEvent) {
this.loadData({
pageIndex: page.pageIndex,
sortColumn: this.sort.active,
sortDirection: this.sort.direction
sortColumn: this.sort().active,
sortDirection: this.sort().direction
});
}
public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
protected onDeleteAssetProfile({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
}
public onDeleteAssetProfiles() {
protected onDeleteAssetProfiles() {
this.adminMarketDataService.deleteAssetProfiles(
this.selection.selected.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
@ -297,7 +299,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
);
}
public onGather7Days() {
protected onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -308,7 +310,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
});
}
public onGatherMax() {
protected onGatherMax() {
this.adminService
.gatherMax()
.pipe(takeUntilDestroyed(this.destroyRef))
@ -319,31 +321,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
});
}
public onGatherProfileData() {
protected onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
public onGatherProfileDataBySymbol({
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({
protected onOpenAssetProfileDialog({
dataSource,
symbol
}: AssetProfileIdentifier) {
@ -375,8 +360,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
? Number.MAX_SAFE_INTEGER
: DEFAULT_PAGE_SIZE;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
if (pageIndex === 0 && this.paginator()) {
this.paginator().pageIndex = 0;
}
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;
@ -435,12 +420,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
data: {
dataSource,
symbol,
colorScheme: this.user?.settings.colorScheme,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
colorScheme:
this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale
} satisfies AssetProfileDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
@ -471,10 +457,10 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
>(GfCreateAssetProfileDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale
} satisfies CreateAssetProfileDialogParams,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
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 {
DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
@ -48,6 +49,7 @@ import {
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { PageEvent } from '@angular/material/paginator';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
@ -140,6 +142,8 @@ export class GfHoldingDetailDialogComponent implements OnInit {
public netPerformancePercentWithCurrencyEffectPrecision = 2;
public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2;
public pageIndex = 0;
public pageSize = DEFAULT_PAGE_SIZE;
public quantity: number;
public quantityPrecision = 2;
public reportDataGlitchMail: string;
@ -178,10 +182,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
}
public ngOnInit() {
const filters: Filter[] = [
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
{ id: this.data.symbol, type: 'SYMBOL' }
];
const filters = this.getActivityFilters();
this.holdingForm = this.formBuilder.group({
tags: [] as string[]
@ -240,18 +241,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchActivities({
filters,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities);
this.changeDetectorRef.markForCheck();
});
this.fetchActivities(filters);
this.dataService
.fetchHoldingDetail({
@ -543,6 +533,12 @@ export class GfHoldingDetailDialogComponent implements OnInit {
});
}
public onChangePage(page: PageEvent) {
this.pageIndex = page.pageIndex;
this.fetchActivities();
}
public onCloneActivity(aActivity: Activity) {
this.router.navigate(
internalRoutes.portfolio.subRoutes.activities.routerLink,
@ -626,6 +622,23 @@ export class GfHoldingDetailDialogComponent implements OnInit {
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() {
this.dataService
.fetchMarketDataBySymbol({
@ -648,4 +661,11 @@ export class GfHoldingDetailDialogComponent implements OnInit {
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"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity &&
@ -367,6 +369,7 @@
(activityToClone)="onCloneActivity($event)"
(activityToUpdate)="onUpdateActivity($event)"
(export)="onExport()"
(pageChanged)="onChangePage($event)"
/>
</mat-tab>
<mat-tab>

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

@ -18,8 +18,12 @@
border-color 0.5s ease,
box-shadow 0.5s ease;
&:hover,
&.active {
border-color: currentColor;
box-shadow: 0 0 0 1px currentColor;
}
&:hover {
border-color: 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 ms from 'ms';
import { ColorScheme } from './types';
export const ghostfolioPrefix = 'GF';
export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`;
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 DEFAULT_COLOR_SCHEME: ColorScheme = 'LIGHT';
export const DEFAULT_CURRENCY = 'USD';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
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 {
@media (max-width: 575.98px) {
bottom: 5rem;
::ng-deep {
.fab-container {
@media (max-width: 575.98px) {
bottom: 5rem;
}
}
}
::ng-deep {
.mat-mdc-tab-nav-panel {
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 '@angular/localize/init';
import { moduleMetadata } from '@storybook/angular';
@ -37,7 +39,7 @@ export const Default: Story = {
args: {
holdings,
baseCurrency: 'USD',
colorScheme: 'LIGHT',
colorScheme: DEFAULT_COLOR_SCHEME,
cursor: undefined,
dateRange: 'mtd',
locale: 'en-US'

40
package-lock.json

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

6
package.json

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

2
prisma/seed.mts

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

Loading…
Cancel
Save