diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b205efa8..5c46a0f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Respected the `withExcludedAccounts` flag in the account balance time series +- Added a historical cash balances table to the account detail dialog +- Introduced a `HasPermission` annotation for endpoints ### Changed - Relaxed the check for duplicates in the preview step of the activities import (allow same day) +- Respected the `withExcludedAccounts` flag in the account balance time series + +### Fixed + +- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks ## 2.27.1 - 2023-11-28 diff --git a/apps/api/src/decorators/has-permission.decorator.ts b/apps/api/src/decorators/has-permission.decorator.ts new file mode 100644 index 000000000..dc65cf82e --- /dev/null +++ b/apps/api/src/decorators/has-permission.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +export const HAS_PERMISSION_KEY = 'has_permission'; + +export function HasPermission(permission: string) { + return SetMetadata(HAS_PERMISSION_KEY, permission); +} diff --git a/apps/api/src/guards/has-permission.guard.spec.ts b/apps/api/src/guards/has-permission.guard.spec.ts new file mode 100644 index 000000000..7f5f90de9 --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.spec.ts @@ -0,0 +1,55 @@ +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { HasPermissionGuard } from './has-permission.guard'; + +describe('HasPermissionGuard', () => { + let guard: HasPermissionGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HasPermissionGuard, Reflector] + }).compile(); + + guard = module.get(HasPermissionGuard); + reflector = module.get(Reflector); + }); + + function setupReflectorSpy(returnValue: string) { + jest.spyOn(reflector, 'get').mockReturnValue(returnValue); + } + + function createMockExecutionContext(permissions: string[]) { + return new ExecutionContextHost([ + { + user: { + permissions // Set user permissions based on the argument + } + } + ]); + } + + it('should deny access if the user does not have any permission', () => { + setupReflectorSpy('required-permission'); + const noPermissions = createMockExecutionContext([]); + + expect(() => guard.canActivate(noPermissions)).toThrow(HttpException); + }); + + it('should deny access if the user has the wrong permission', () => { + setupReflectorSpy('required-permission'); + const wrongPermission = createMockExecutionContext(['wrong-permission']); + + expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException); + }); + + it('should allow access if the user has the required permission', () => { + setupReflectorSpy('required-permission'); + const rightPermission = createMockExecutionContext(['required-permission']); + + expect(guard.canActivate(rightPermission)).toBe(true); + }); +}); diff --git a/apps/api/src/guards/has-permission.guard.ts b/apps/api/src/guards/has-permission.guard.ts new file mode 100644 index 000000000..298992d06 --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.ts @@ -0,0 +1,37 @@ +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { hasPermission } from '@ghostfolio/common/permissions'; +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class HasPermissionGuard implements CanActivate { + public constructor(private reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + const requiredPermission = this.reflector.get( + HAS_PERMISSION_KEY, + context.getHandler() + ); + + if (!requiredPermission) { + return true; // No specific permissions required + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user || !hasPermission(user.permissions, requiredPermission)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return true; + } +} diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index ef5143475..cd5874ca7 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -346,7 +346,7 @@ export class DataProviderService { ); try { - this.marketDataService.updateMany({ + await this.marketDataService.updateMany({ data: Object.keys(response) .filter((symbol) => { return ( diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 284bfcca2..b3a916da9 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -29,14 +29,15 @@ import { AccountDetailDialogParams } from './interfaces/interfaces'; styleUrls: ['./account-detail-dialog.component.scss'] }) export class AccountDetailDialog implements OnDestroy, OnInit { + public activities: OrderWithAccount[]; public balance: number; public currency: string; public equity: number; public hasImpersonationId: boolean; public historicalDataItems: HistoricalDataItem[]; + public isLoadingActivities: boolean; public isLoadingChart: boolean; public name: string; - public orders: OrderWithAccount[]; public platformName: string; public transactionCount: number; public user: User; @@ -64,6 +65,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { } public ngOnInit() { + this.isLoadingActivities = true; this.isLoadingChart = true; this.dataService @@ -103,7 +105,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ activities }) => { - this.orders = activities; + this.activities = activities; + + this.isLoadingActivities = false; this.changeDetectorRef.markForCheck(); }); @@ -153,8 +157,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit { public onExport() { this.dataService .fetchExport( - this.orders.map((order) => { - return order.id; + this.activities.map(({ id }) => { + return id; }) ) .pipe(takeUntil(this.unsubscribeSubject)) diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 02d1c917e..7e92eca85 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -31,7 +31,7 @@ > -
+
-
-
-
Activities
+ + + Activities -
-
+ + + Cash Balances + + +
diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts index c3d45b6ce..83ac5b6ea 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts @@ -2,9 +2,11 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule } from '@angular/material/dialog'; +import { MatTabsModule } from '@angular/material/tabs'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; +import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfValueModule } from '@ghostfolio/ui/value'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -15,6 +17,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component'; declarations: [AccountDetailDialog], imports: [ CommonModule, + GfAccountBalancesModule, GfActivitiesTableModule, GfDialogFooterModule, GfDialogHeaderModule, @@ -22,6 +25,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component'; GfValueModule, MatButtonModule, MatDialogModule, + MatTabsModule, NgxSkeletonLoaderModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index a5c9201a0..8c2cf9bd5 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -1,5 +1,5 @@
-
+

Activities

(`/api/v1/account/${aAccountId}`); } + public fetchAccountBalances(aAccountId: string) { + return this.http.get( + `/api/v1/account/${aAccountId}/balances` + ); + } + public fetchAccounts() { return this.http.get('/api/v1/account'); } diff --git a/apps/client/src/assets/oss-friends.json b/apps/client/src/assets/oss-friends.json index a95f8e709..d22f4c030 100644 --- a/apps/client/src/assets/oss-friends.json +++ b/apps/client/src/assets/oss-friends.json @@ -1,5 +1,5 @@ { - "createdAt": "2023-11-17T00:00:00.000Z", + "createdAt": "2023-11-30T00:00:00.000Z", "data": [ { "name": "BoxyHQ", @@ -16,6 +16,11 @@ "description": "Centralize community, product, and customer data to understand which companies are engaging with your open source project.", "href": "https://www.crowd.dev" }, + { + "name": "DevHunt", + "description": "Find the best Dev Tools upvoted by the community every week.", + "href": "https://devhunt.org" + }, { "name": "Documenso", "description": "The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.", @@ -59,7 +64,7 @@ { "name": "Hook0", "description": "Open-Source Webhooks-as-a-service (WaaS) that makes it easy for developers to send webhooks.", - "href": "https://www.hook0.com/" + "href": "https://www.hook0.com" }, { "name": "HTMX", @@ -89,7 +94,7 @@ { "name": "Papermark", "description": "Open-Source Docsend Alternative to securely share documents with real-time analytics.", - "href": "https://www.papermark.io/" + "href": "https://www.papermark.io" }, { "name": "Requestly", @@ -109,7 +114,7 @@ { "name": "Shelf.nu", "description": "Open Source Asset and Equipment tracking software that lets you create QR asset labels, manage and overview your assets across locations.", - "href": "https://www.shelf.nu/" + "href": "https://www.shelf.nu" }, { "name": "Sniffnet", diff --git a/libs/ui/src/lib/account-balances/account-balances.component.html b/libs/ui/src/lib/account-balances/account-balances.component.html new file mode 100644 index 000000000..81f8a8192 --- /dev/null +++ b/libs/ui/src/lib/account-balances/account-balances.component.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + +
+ Date + + + + Value + +
+ +
+
diff --git a/libs/ui/src/lib/account-balances/account-balances.component.scss b/libs/ui/src/lib/account-balances/account-balances.component.scss new file mode 100644 index 000000000..b5b58f67e --- /dev/null +++ b/libs/ui/src/lib/account-balances/account-balances.component.scss @@ -0,0 +1,5 @@ +@import 'apps/client/src/styles/ghostfolio-style'; + +:host { + display: block; +} diff --git a/libs/ui/src/lib/account-balances/account-balances.component.ts b/libs/ui/src/lib/account-balances/account-balances.component.ts new file mode 100644 index 000000000..c552519d6 --- /dev/null +++ b/libs/ui/src/lib/account-balances/account-balances.component.ts @@ -0,0 +1,63 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { AccountBalancesResponse } from '@ghostfolio/common/interfaces'; +import { get } from 'lodash'; +import { Subject, takeUntil } from 'rxjs'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-account-balances', + styleUrls: ['./account-balances.component.scss'], + templateUrl: './account-balances.component.html' +}) +export class AccountBalancesComponent implements OnDestroy, OnInit { + @Input() accountId: string; + @Input() locale: string; + + @ViewChild(MatSort) sort: MatSort; + + public dataSource: MatTableDataSource< + AccountBalancesResponse['balances'][0] + > = new MatTableDataSource(); + public displayedColumns: string[] = ['date', 'value']; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService + ) {} + + public ngOnInit() { + this.fetchBalances(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private fetchBalances() { + this.dataService + .fetchAccountBalances(this.accountId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ balances }) => { + this.dataSource = new MatTableDataSource(balances); + + this.dataSource.sort = this.sort; + this.dataSource.sortingDataAccessor = get; + + this.changeDetectorRef.markForCheck(); + }); + } +} diff --git a/libs/ui/src/lib/account-balances/account-balances.module.ts b/libs/ui/src/lib/account-balances/account-balances.module.ts new file mode 100644 index 000000000..cc8fb9677 --- /dev/null +++ b/libs/ui/src/lib/account-balances/account-balances.module.ts @@ -0,0 +1,15 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { GfValueModule } from '@ghostfolio/ui/value'; + +import { AccountBalancesComponent } from './account-balances.component'; + +@NgModule({ + declarations: [AccountBalancesComponent], + exports: [AccountBalancesComponent], + imports: [CommonModule, GfValueModule, MatSortModule, MatTableModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfAccountBalancesModule {}