Browse Source

Feature/sync demo account activities based on tags (#4797)

* Sync demo account activities based on tags

* Update changelog
pull/4799/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
cb7434a8b2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 9
      apps/api/src/app/admin/admin.controller.ts
  3. 2
      apps/api/src/app/admin/admin.module.ts
  4. 4
      apps/api/src/app/info/info.service.ts
  5. 6
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 6
      apps/api/src/app/user/user.service.ts
  7. 13
      apps/api/src/services/demo/demo.module.ts
  8. 59
      apps/api/src/services/demo/demo.service.ts
  9. 25
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  10. 21
      apps/client/src/app/components/admin-overview/admin-overview.html
  11. 2
      apps/client/src/app/components/admin-overview/admin-overview.module.ts
  12. 4
      apps/client/src/app/services/admin.service.ts
  13. 7
      libs/common/src/lib/config.ts
  14. 1
      libs/common/src/lib/permissions.ts

1
CHANGELOG.md

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the _Live Demo_ setup by syncing activities based on tags
- Renamed `orders` to `activities` in the `Tag` database schema - Renamed `orders` to `activities` in the `Tag` database schema
- Modularized the cron service - Modularized the cron service
- Refreshed the cryptocurrencies list - Refreshed the cryptocurrencies list

9
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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.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 { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { import {
@ -55,6 +56,7 @@ export class AdminController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -66,6 +68,13 @@ export class AdminController {
return this.adminService.get({ user: this.request.user }); return this.adminService.get({ user: this.request.user });
} }
@Get('demo-user/sync')
@HasPermission(permissions.syncDemoUserAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
return this.demoService.syncDemoUserAccount();
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

2
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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -24,6 +25,7 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

4
apps/api/src/app/info/info.service.ts

@ -11,7 +11,7 @@ import {
HEADER_KEY_TOKEN, HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID_LEGACY,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSource
@ -237,7 +237,7 @@ export class InfoService {
private async getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey( const demoUserId = (await this.propertyService.getByKey(
PROPERTY_DEMO_USER_ID PROPERTY_DEMO_USER_ID_LEGACY
)) as string; )) as string;
if (demoUserId) { if (demoUserId) {

6
apps/api/src/app/portfolio/portfolio.service.ts

@ -31,7 +31,7 @@ import {
} from '@ghostfolio/common/calculation-helper'; } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, TAG_ID_EMERGENCY_FUND,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
@ -564,7 +564,7 @@ export class PortfolioService {
if ( if (
filters?.length === 1 && filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID && filters[0].id === TAG_ID_EMERGENCY_FUND &&
filters[0].type === 'TAG' filters[0].type === 'TAG'
) { ) {
const emergencyFundCashPositions = await this.getCashPositions({ const emergencyFundCashPositions = await this.getCashPositions({
@ -1655,7 +1655,7 @@ export class PortfolioService {
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return ( return (
tags?.some(({ id }) => { tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID; return id === TAG_ID_EMERGENCY_FUND;
}) ?? false }) ?? false
); );
}); });

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

@ -411,6 +411,10 @@ export class UserService {
user.subscription.offer.durationExtension = undefined; user.subscription.offer.durationExtension = undefined;
user.subscription.offer.label = undefined; user.subscription.offer.label = undefined;
} }
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount);
}
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { 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); currentPermissions.push(permissions.impersonateAllUsers);
} }

13
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 {}

59
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
});
}
}

25
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 { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public coupons: Coupon[]; public coupons: Coupon[];
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;
public hasPermissionToSyncDemoUserAccount: boolean;
public hasPermissionToToggleReadOnlyMode: boolean; public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem; public info: InfoItem;
public isDataGatheringEnabled: boolean; public isDataGatheringEnabled: boolean;
@ -60,6 +62,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
@ -80,6 +83,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
permissions.enableSystemMessage permissions.enableSystemMessage
); );
this.hasPermissionToSyncDemoUserAccount = hasPermission(
this.user.permissions,
permissions.syncDemoUserAccount
);
this.hasPermissionToToggleReadOnlyMode = hasPermission( this.hasPermissionToToggleReadOnlyMode = hasPermission(
this.user.permissions, this.user.permissions,
permissions.toggleReadOnlyMode 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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

21
apps/client/src/app/components/admin-overview/admin-overview.html

@ -169,10 +169,23 @@
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Housekeeping</div> <div class="w-50" i18n>Housekeeping</div>
<div class="w-50"> <div class="w-50">
<button color="warn" mat-flat-button (click)="onFlushCache()"> <div class="align-items-start d-flex flex-column">
<ion-icon class="mr-1" name="close-circle-outline" /> @if (hasPermissionToSyncDemoUserAccount) {
<span i18n>Flush Cache</span> <button
</button> class="mb-2"
color="accent"
mat-flat-button
(click)="onSyncDemoUserAccount()"
>
<ion-icon class="mr-1" name="sync-outline" />
<span i18n>Sync Demo User Account</span>
</button>
}
<button color="warn" mat-flat-button (click)="onFlushCache()">
<ion-icon class="mr-1" name="close-circle-outline" />
<span i18n>Flush Cache</span>
</button>
</div>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>

2
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 { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AdminOverviewComponent } from './admin-overview.component'; import { AdminOverviewComponent } from './admin-overview.component';
@ -24,6 +25,7 @@ import { AdminOverviewComponent } from './admin-overview.component';
MatCardModule, MatCardModule,
MatMenuModule, MatMenuModule,
MatSelectModule, MatSelectModule,
MatSnackBarModule,
MatSlideToggleModule, MatSlideToggleModule,
ReactiveFormsModule, ReactiveFormsModule,
RouterModule RouterModule

4
apps/client/src/app/services/admin.service.ts

@ -246,6 +246,10 @@ export class AdminService {
); );
} }
public syncDemoUserAccount() {
return this.http.get<void>(`/api/v1/admin/demo-user/sync`);
}
public testMarketData({ public testMarketData({
dataSource, dataSource,
scraperConfiguration, scraperConfiguration,

7
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_NAME = 'GATHER_ASSET_PROFILE';
export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = { export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = {
attempts: 12, attempts: 12,
@ -122,7 +120,9 @@ export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_DATA_SOURCE_MAPPING = 'DATA_SOURCE_MAPPING'; export const PROPERTY_DATA_SOURCE_MAPPING = 'DATA_SOURCE_MAPPING';
export const PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS = export const PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS =
'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 = '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_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED';
@ -171,4 +171,7 @@ export const SUPPORTED_LANGUAGE_CODES = [
'zh' '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'; export const UNKNOWN_KEY = 'UNKNOWN';

1
libs/common/src/lib/permissions.ts

@ -45,6 +45,7 @@ export const permissions = {
readTags: 'readTags', readTags: 'readTags',
readWatchlist: 'readWatchlist', readWatchlist: 'readWatchlist',
reportDataGlitch: 'reportDataGlitch', reportDataGlitch: 'reportDataGlitch',
syncDemoUserAccount: 'syncDemoUserAccount',
toggleReadOnlyMode: 'toggleReadOnlyMode', toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount', updateAccount: 'updateAccount',
updateAuthDevice: 'updateAuthDevice', updateAuthDevice: 'updateAuthDevice',

Loading…
Cancel
Save