diff --git a/CHANGELOG.md b/CHANGELOG.md index c32f0535b..0fef4ef7d 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 - Added the country flag to the currency selector - Added a _Storybook_ story for the currency selector component - Extended the entity logo component by a `hasPlaceholder` attribute to reserve the space of a missing logo +- Warmed up the portfolio snapshot calculation in the background during the biometric authentication ### Changed diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 1d6990307..ddc41abad 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,14 +1,17 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { Logger, Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -24,14 +27,17 @@ import { OidcStrategy } from './oidc.strategy'; @Module({ controllers: [AuthController], imports: [ + ApiModule, ConfigurationModule, FetchModule, JwtModule.register({ secret: process.env.JWT_SECRET_KEY, signOptions: { expiresIn: '180 days' } }), + PortfolioSnapshotQueueModule, PrismaModule, PropertyModule, + RedisCacheModule, SubscriptionModule, UserModule ], diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index 5764eeece..cb9dd8cb7 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -1,6 +1,15 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS +} from '@ghostfolio/common/config'; import { AuthDeviceDto } from '@ghostfolio/common/dtos'; import { AssertionCredentialJSON, @@ -29,6 +38,7 @@ import { VerifyRegistrationResponseOpts } from '@simplewebauthn/server'; import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; +import { isPast } from 'date-fns'; import ms from 'ms'; @Injectable() @@ -36,9 +46,12 @@ export class WebAuthService { private readonly logger = new Logger(WebAuthService.name); public constructor( + private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly deviceService: AuthDeviceService, private readonly jwtService: JwtService, + private readonly portfolioSnapshotService: PortfolioSnapshotService, + private readonly redisCacheService: RedisCacheService, private readonly userService: UserService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -155,6 +168,9 @@ export class WebAuthService { throw new Error('Device not found'); } + // Compute in the background during the biometric authentication + void this.warmUpPortfolioSnapshot({ userId: device.userId }); + const opts: GenerateAuthenticationOptionsOpts = { allowCredentials: [], rpID: this.rpID, @@ -233,4 +249,57 @@ export class WebAuthService { throw new Error(); } + + private async isPortfolioSnapshotExpired(portfolioSnapshotKey: string) { + try { + const { expiration }: PortfolioSnapshotValue = JSON.parse( + await this.redisCacheService.get(portfolioSnapshotKey) + ); + + return isPast(new Date(expiration)); + } catch { + return true; + } + } + + private async warmUpPortfolioSnapshot({ userId }: { userId: string }) { + try { + const user = await this.userService.user({ id: userId }); + + if (!user) { + return; + } + + const userSettings = user.settings.settings; + + const filters = this.apiService.buildFiltersFromUserSettings({ + userSettings + }); + + const portfolioSnapshotKey = + this.redisCacheService.getPortfolioSnapshotKey({ filters, userId }); + + if (await this.isPortfolioSnapshotExpired(portfolioSnapshotKey)) { + await this.portfolioSnapshotService.addJobToQueue({ + data: { + filters, + userId, + calculationType: userSettings.performanceCalculationType, + userCurrency: userSettings.baseCurrency + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId: portfolioSnapshotKey, + priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW + } + }); + } + } catch (error) { + this.logger.error( + `Portfolio snapshot of user '${userId}' could not be warmed up`, + error + ); + } + } }