diff --git a/CHANGELOG.md b/CHANGELOG.md index b422474d5..9e9b99fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the _Live Demo_ setup by syncing activities based on tags - Renamed `orders` to `activities` in the `Tag` database schema - Modularized the cron service - Refreshed the cryptocurrencies list diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 736f6da33..82524ef9b 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -3,6 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; +import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { @@ -55,6 +56,7 @@ export class AdminController { private readonly adminService: AdminService, private readonly apiService: ApiService, private readonly dataGatheringService: DataGatheringService, + private readonly demoService: DemoService, private readonly manualService: ManualService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -66,6 +68,13 @@ export class AdminController { return this.adminService.get({ user: this.request.user }); } + @Get('demo-user/sync') + @HasPermission(permissions.syncDemoUserAccount) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async syncDemoUserAccount(): Promise { + return this.demoService.syncDemoUserAccount(); + } + @HasPermission(permissions.accessAdminControl) @Post('gather') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts index 54145fa27..598b68f17 100644 --- a/apps/api/src/app/admin/admin.module.ts +++ b/apps/api/src/app/admin/admin.module.ts @@ -4,6 +4,7 @@ import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { DemoModule } from '@ghostfolio/api/services/demo/demo.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -24,6 +25,7 @@ import { QueueModule } from './queue/queue.module'; ConfigurationModule, DataGatheringModule, DataProviderModule, + DemoModule, ExchangeRateDataModule, MarketDataModule, OrderModule, diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 9581807b9..1f7b39d9b 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -11,7 +11,7 @@ import { HEADER_KEY_TOKEN, PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_COUNTRIES_OF_SUBSCRIBERS, - PROPERTY_DEMO_USER_ID, + PROPERTY_DEMO_USER_ID_LEGACY, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SLACK_COMMUNITY_USERS, ghostfolioFearAndGreedIndexDataSource @@ -237,7 +237,7 @@ export class InfoService { private async getDemoAuthToken() { const demoUserId = (await this.propertyService.getByKey( - PROPERTY_DEMO_USER_ID + PROPERTY_DEMO_USER_ID_LEGACY )) as string; if (demoUserId) { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e8f50e1e2..18f923112 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -31,7 +31,7 @@ import { } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, - EMERGENCY_FUND_TAG_ID, + TAG_ID_EMERGENCY_FUND, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; @@ -564,7 +564,7 @@ export class PortfolioService { if ( filters?.length === 1 && - filters[0].id === EMERGENCY_FUND_TAG_ID && + filters[0].id === TAG_ID_EMERGENCY_FUND && filters[0].type === 'TAG' ) { const emergencyFundCashPositions = await this.getCashPositions({ @@ -1655,7 +1655,7 @@ export class PortfolioService { const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { return ( tags?.some(({ id }) => { - return id === EMERGENCY_FUND_TAG_ID; + return id === TAG_ID_EMERGENCY_FUND; }) ?? false ); }); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 87c82fa0b..2a48b2583 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -411,6 +411,10 @@ export class UserService { user.subscription.offer.durationExtension = undefined; user.subscription.offer.label = undefined; } + + if (hasRole(user, Role.ADMIN)) { + currentPermissions.push(permissions.syncDemoUserAccount); + } } if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { @@ -433,7 +437,7 @@ export class UserService { } } - if (!environment.production && role === 'ADMIN') { + if (!environment.production && hasRole(user, Role.ADMIN)) { currentPermissions.push(permissions.impersonateAllUsers); } diff --git a/apps/api/src/services/demo/demo.module.ts b/apps/api/src/services/demo/demo.module.ts new file mode 100644 index 000000000..8f86de058 --- /dev/null +++ b/apps/api/src/services/demo/demo.module.ts @@ -0,0 +1,13 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +import { DemoService } from './demo.service'; + +@Module({ + exports: [DemoService], + imports: [PrismaModule, PropertyModule], + providers: [DemoService] +}) +export class DemoModule {} diff --git a/apps/api/src/services/demo/demo.service.ts b/apps/api/src/services/demo/demo.service.ts new file mode 100644 index 000000000..477b43e3b --- /dev/null +++ b/apps/api/src/services/demo/demo.service.ts @@ -0,0 +1,59 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_DEMO_ACCOUNT_ID, + PROPERTY_DEMO_USER_ID, + TAG_ID_DEMO +} from '@ghostfolio/common/config'; + +import { Injectable } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class DemoService { + public constructor( + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService + ) {} + + public async syncDemoUserAccount() { + const [demoAccountId, demoUserId] = (await Promise.all([ + this.propertyService.getByKey(PROPERTY_DEMO_ACCOUNT_ID), + this.propertyService.getByKey(PROPERTY_DEMO_USER_ID) + ])) as [string, string]; + + let activities = await this.prismaService.order.findMany({ + orderBy: { + date: 'asc' + }, + where: { + tags: { + some: { + id: TAG_ID_DEMO + } + } + } + }); + + activities = activities.map((activity) => { + return { + ...activity, + accountId: demoAccountId, + accountUserId: demoUserId, + comment: null, + id: uuidv4(), + userId: demoUserId + }; + }); + + await this.prismaService.order.deleteMany({ + where: { + userId: demoUserId + } + }); + + return this.prismaService.order.createMany({ + data: activities + }); + } +} diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 2f814668a..23578cff1 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -22,12 +22,13 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { MatSlideToggleChange } from '@angular/material/slide-toggle'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { differenceInSeconds, formatDistanceToNowStrict, parseISO } from 'date-fns'; -import { StringValue } from 'ms'; +import ms, { StringValue } from 'ms'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { public coupons: Coupon[]; public hasPermissionForSubscription: boolean; public hasPermissionForSystemMessage: boolean; + public hasPermissionToSyncDemoUserAccount: boolean; public hasPermissionToToggleReadOnlyMode: boolean; public info: InfoItem; public isDataGatheringEnabled: boolean; @@ -60,6 +62,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private notificationService: NotificationService, + private snackBar: MatSnackBar, private userService: UserService ) { this.info = this.dataService.fetchInfo(); @@ -80,6 +83,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { permissions.enableSystemMessage ); + this.hasPermissionToSyncDemoUserAccount = hasPermission( + this.user.permissions, + permissions.syncDemoUserAccount + ); + this.hasPermissionToToggleReadOnlyMode = hasPermission( this.user.permissions, permissions.toggleReadOnlyMode @@ -206,6 +214,21 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { } } + public onSyncDemoUserAccount() { + this.adminService + .syncDemoUserAccount() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.snackBar.open( + '✅ ' + $localize`Demo user account has been synced.`, + undefined, + { + duration: ms('3 seconds') + } + ); + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index bcbf666ef..246e5fe04 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -169,10 +169,23 @@
Housekeeping
- +
+ @if (hasPermissionToSyncDemoUserAccount) { + + } + +
diff --git a/apps/client/src/app/components/admin-overview/admin-overview.module.ts b/apps/client/src/app/components/admin-overview/admin-overview.module.ts index da49eb858..1c5fba202 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.module.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.module.ts @@ -9,6 +9,7 @@ import { MatCardModule } from '@angular/material/card'; import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; import { RouterModule } from '@angular/router'; import { AdminOverviewComponent } from './admin-overview.component'; @@ -24,6 +25,7 @@ import { AdminOverviewComponent } from './admin-overview.component'; MatCardModule, MatMenuModule, MatSelectModule, + MatSnackBarModule, MatSlideToggleModule, ReactiveFormsModule, RouterModule diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index cb72fb9fd..670535291 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -246,6 +246,10 @@ export class AdminService { ); } + public syncDemoUserAccount() { + return this.http.get(`/api/v1/admin/demo-user/sync`); + } + public testMarketData({ dataSource, scraperConfiguration, diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 06ff59bb7..a3b129b55 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -76,8 +76,6 @@ export const DERIVED_CURRENCIES = [ } ]; -export const EMERGENCY_FUND_TAG_ID = '4452656d-9fa4-4bd0-ba38-70492e31d180'; - export const GATHER_ASSET_PROFILE_PROCESS_JOB_NAME = 'GATHER_ASSET_PROFILE'; export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = { attempts: 12, @@ -122,7 +120,9 @@ export const PROPERTY_CURRENCIES = 'CURRENCIES'; export const PROPERTY_DATA_SOURCE_MAPPING = 'DATA_SOURCE_MAPPING'; export const PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS = 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS'; +export const PROPERTY_DEMO_ACCOUNT_ID = 'DEMO_ACCOUNT_ID'; export const PROPERTY_DEMO_USER_ID = 'DEMO_USER_ID'; +export const PROPERTY_DEMO_USER_ID_LEGACY = 'DEMO_USER_ID_LEGACY'; export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; @@ -171,4 +171,7 @@ export const SUPPORTED_LANGUAGE_CODES = [ 'zh' ]; +export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180'; +export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; + export const UNKNOWN_KEY = 'UNKNOWN'; diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 592167562..8f8a10427 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -45,6 +45,7 @@ export const permissions = { readTags: 'readTags', readWatchlist: 'readWatchlist', reportDataGlitch: 'reportDataGlitch', + syncDemoUserAccount: 'syncDemoUserAccount', toggleReadOnlyMode: 'toggleReadOnlyMode', updateAccount: 'updateAccount', updateAuthDevice: 'updateAuthDevice',