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
- 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

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 { 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<Prisma.BatchPayload> {
return this.demoService.syncDemoUserAccount();
}
@HasPermission(permissions.accessAdminControl)
@Post('gather')
@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 { 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,

4
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) {

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

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

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 { 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();

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

@ -169,10 +169,23 @@
<div class="d-flex my-3">
<div class="w-50" i18n>Housekeeping</div>
<div class="w-50">
<button color="warn" mat-flat-button (click)="onFlushCache()">
<ion-icon class="mr-1" name="close-circle-outline" />
<span i18n>Flush Cache</span>
</button>
<div class="align-items-start d-flex flex-column">
@if (hasPermissionToSyncDemoUserAccount) {
<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>
</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 { 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

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({
dataSource,
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_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';

1
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',

Loading…
Cancel
Save