mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
779 lines
23 KiB
779 lines
23 KiB
import { AssetProfilesService } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.service';
|
|
import { environment } from '@ghostfolio/api/environments/environment';
|
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
|
import {
|
|
ghostfolioPrefix,
|
|
PROPERTY_CURRENCIES,
|
|
PROPERTY_IS_READ_ONLY_MODE,
|
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
|
} from '@ghostfolio/common/config';
|
|
import {
|
|
applyAssetProfileOverrides,
|
|
canMergeAssetProfile,
|
|
getAssetProfileIdentifier,
|
|
getCurrencyFromSymbol,
|
|
hasGhostfolioPrefix
|
|
} from '@ghostfolio/common/helper';
|
|
import {
|
|
AdminData,
|
|
AdminUserResponse,
|
|
AdminUsersResponse,
|
|
AssetProfileIdentifier,
|
|
EnhancedAssetProfile
|
|
} from '@ghostfolio/common/interfaces';
|
|
import { PropertyKey } from '@ghostfolio/common/types';
|
|
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
HttpException,
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
Logger,
|
|
NotFoundException
|
|
} from '@nestjs/common';
|
|
import {
|
|
AssetClass,
|
|
AssetSubClass,
|
|
DataSource,
|
|
Prisma,
|
|
Property,
|
|
SymbolProfile
|
|
} from '@prisma/client';
|
|
import { differenceInDays } from 'date-fns';
|
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
@Injectable()
|
|
export class AdminService {
|
|
private readonly logger = new Logger(AdminService.name);
|
|
|
|
public constructor(
|
|
private readonly assetProfilesService: AssetProfilesService,
|
|
private readonly benchmarkService: BenchmarkService,
|
|
private readonly configurationService: ConfigurationService,
|
|
private readonly dataProviderService: DataProviderService,
|
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
private readonly marketDataService: MarketDataService,
|
|
private readonly prismaService: PrismaService,
|
|
private readonly propertyService: PropertyService,
|
|
private readonly symbolProfileService: SymbolProfileService
|
|
) {}
|
|
|
|
public async addAssetProfile({
|
|
currency,
|
|
dataSource,
|
|
symbol
|
|
}: AssetProfileIdentifier & { currency?: string }): Promise<
|
|
SymbolProfile | never
|
|
> {
|
|
try {
|
|
if (dataSource === 'MANUAL') {
|
|
if (!hasGhostfolioPrefix(symbol)) {
|
|
throw new BadRequestException(
|
|
`symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")`
|
|
);
|
|
}
|
|
|
|
return this.symbolProfileService.add({
|
|
currency,
|
|
dataSource,
|
|
symbol
|
|
});
|
|
}
|
|
|
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
|
{ dataSource, symbol }
|
|
]);
|
|
|
|
const assetProfile =
|
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
|
|
|
|
if (!assetProfile?.currency) {
|
|
throw new BadRequestException(
|
|
`Asset profile not found for ${symbol} (${dataSource})`
|
|
);
|
|
}
|
|
|
|
return this.symbolProfileService.add(
|
|
assetProfile as Prisma.SymbolProfileCreateInput
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
error.code === 'P2002'
|
|
) {
|
|
throw new BadRequestException(
|
|
`Asset profile of ${symbol} (${dataSource}) already exists`
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
public async deleteProfileData({
|
|
dataSource,
|
|
symbol
|
|
}: AssetProfileIdentifier) {
|
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
|
|
|
const currency = getCurrencyFromSymbol(symbol);
|
|
|
|
const customCurrencies = await this.propertyService.getByKey<string[]>(
|
|
PROPERTY_CURRENCIES,
|
|
{ skipCache: true }
|
|
);
|
|
|
|
if (customCurrencies.includes(currency)) {
|
|
const updatedCustomCurrencies = customCurrencies.filter(
|
|
(customCurrency) => {
|
|
return customCurrency !== currency;
|
|
}
|
|
);
|
|
|
|
await this.putSetting(
|
|
PROPERTY_CURRENCIES,
|
|
JSON.stringify(updatedCustomCurrencies)
|
|
);
|
|
} else {
|
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
|
}
|
|
}
|
|
|
|
public async get(): Promise<AdminData> {
|
|
const dataSources = Object.values(DataSource);
|
|
|
|
const [activitiesCount, enabledDataSources, settings, userCount] =
|
|
await Promise.all([
|
|
this.prismaService.order.count(),
|
|
this.dataProviderService.getDataSources(),
|
|
this.propertyService.get(),
|
|
this.countUsersWithAnalytics()
|
|
]);
|
|
|
|
const dataProviders = (
|
|
await Promise.all(
|
|
dataSources.map(async (dataSource) => {
|
|
const assetProfileCount =
|
|
await this.prismaService.symbolProfile.count({
|
|
where: {
|
|
dataSource
|
|
}
|
|
});
|
|
|
|
const isEnabled = enabledDataSources.includes(dataSource);
|
|
|
|
if (
|
|
assetProfileCount > 0 ||
|
|
dataSource === 'GHOSTFOLIO' ||
|
|
isEnabled
|
|
) {
|
|
const dataProviderInfo = this.dataProviderService
|
|
.getDataProvider(dataSource)
|
|
.getDataProviderInfo();
|
|
|
|
return {
|
|
...dataProviderInfo,
|
|
assetProfileCount,
|
|
useForExchangeRates:
|
|
dataSource ===
|
|
this.dataProviderService.getDataSourceForExchangeRates()
|
|
};
|
|
}
|
|
|
|
return null;
|
|
})
|
|
)
|
|
).filter(Boolean);
|
|
|
|
return {
|
|
activitiesCount,
|
|
dataProviders,
|
|
settings,
|
|
userCount,
|
|
version: environment.version
|
|
};
|
|
}
|
|
|
|
public async getUser(id: string): Promise<AdminUserResponse> {
|
|
const [user] = await this.getUsersWithAnalytics({
|
|
where: { id }
|
|
});
|
|
|
|
if (!user) {
|
|
throw new NotFoundException(`User with ID ${id} not found`);
|
|
}
|
|
|
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
user.subscriptions = await this.prismaService.subscription.findMany({
|
|
orderBy: {
|
|
expiresAt: 'desc'
|
|
},
|
|
where: {
|
|
userId: id
|
|
}
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
public async getUsers({
|
|
skip,
|
|
take = Number.MAX_SAFE_INTEGER
|
|
}: {
|
|
skip?: number;
|
|
take?: number;
|
|
}): Promise<AdminUsersResponse> {
|
|
const [count, users] = await Promise.all([
|
|
this.countUsersWithAnalytics(),
|
|
this.getUsersWithAnalytics({
|
|
skip,
|
|
take
|
|
})
|
|
]);
|
|
|
|
return { count, users };
|
|
}
|
|
|
|
/**
|
|
* Merges the source asset profile into the target asset profile. The
|
|
* activities, the watchlist entries and the market data which is missing
|
|
* there are moved to the target asset profile. The metadata of the source
|
|
* asset profile is discarded, because the target asset profile is the
|
|
* authoritative one. Then the source asset profile is deleted and the market
|
|
* data of the target asset profile is gathered again.
|
|
*/
|
|
public async mergeAssetProfile(
|
|
sourceAssetProfileIdentifier: AssetProfileIdentifier,
|
|
targetAssetProfileIdentifier: AssetProfileIdentifier
|
|
): Promise<EnhancedAssetProfile> {
|
|
if (
|
|
getAssetProfileIdentifier(sourceAssetProfileIdentifier) ===
|
|
getAssetProfileIdentifier(targetAssetProfileIdentifier)
|
|
) {
|
|
throw new BadRequestException(
|
|
'The source and the target asset profile must be different'
|
|
);
|
|
}
|
|
|
|
// The rules of the symbol apply to both asset profiles and are examined
|
|
// first. The remaining rules apply to the source asset profile only,
|
|
// because the merge deletes it
|
|
for (const assetProfileIdentifier of [
|
|
sourceAssetProfileIdentifier,
|
|
targetAssetProfileIdentifier
|
|
]) {
|
|
if (!canMergeAssetProfile(assetProfileIdentifier)) {
|
|
throw new BadRequestException(
|
|
`The asset profile ${getAssetProfileIdentifier(assetProfileIdentifier)} cannot be merged`
|
|
);
|
|
}
|
|
}
|
|
|
|
const [sourceAssetProfile, targetAssetProfile] = await Promise.all([
|
|
this.prismaService.symbolProfile.findUnique({
|
|
include: { watchedBy: { select: { id: true } } },
|
|
where: {
|
|
dataSource_symbol: {
|
|
dataSource: sourceAssetProfileIdentifier.dataSource,
|
|
symbol: sourceAssetProfileIdentifier.symbol
|
|
}
|
|
}
|
|
}),
|
|
this.prismaService.symbolProfile.findUnique({
|
|
where: {
|
|
dataSource_symbol: {
|
|
dataSource: targetAssetProfileIdentifier.dataSource,
|
|
symbol: targetAssetProfileIdentifier.symbol
|
|
}
|
|
}
|
|
})
|
|
]);
|
|
|
|
if (!sourceAssetProfile || !targetAssetProfile) {
|
|
throw new NotFoundException(
|
|
'The source or the target asset profile does not exist'
|
|
);
|
|
}
|
|
|
|
// An activity without a currency inherits the currency of its asset
|
|
// profile, hence a merge into an asset profile with another currency
|
|
// would change the value of the moved activities
|
|
if (sourceAssetProfile.currency !== targetAssetProfile.currency) {
|
|
throw new BadRequestException(
|
|
`The currency of the source asset profile (${sourceAssetProfile.currency}) does not match the currency of the target asset profile (${targetAssetProfile.currency})`
|
|
);
|
|
}
|
|
|
|
// A user asset profile must not become accessible to another user and a
|
|
// user must not become unable to be deleted, because the activities of the
|
|
// source asset profile would keep a reference to it
|
|
if (sourceAssetProfile.userId !== targetAssetProfile.userId) {
|
|
throw new BadRequestException(
|
|
'The source and the target asset profile must belong to the same user'
|
|
);
|
|
}
|
|
|
|
const [isBenchmark, splitsCount] = await Promise.all([
|
|
this.benchmarkService.isBenchmark(sourceAssetProfile.id),
|
|
this.prismaService.assetProfileSplit.count({
|
|
where: {
|
|
symbolProfileId: {
|
|
in: [sourceAssetProfile.id, targetAssetProfile.id]
|
|
}
|
|
}
|
|
})
|
|
]);
|
|
|
|
if (
|
|
!canMergeAssetProfile({
|
|
isBenchmark,
|
|
splitsCount,
|
|
symbol: sourceAssetProfileIdentifier.symbol
|
|
})
|
|
) {
|
|
throw new BadRequestException(
|
|
`The asset profile ${getAssetProfileIdentifier(sourceAssetProfileIdentifier)} cannot be merged. Remove the benchmark and the splits before the merge.`
|
|
);
|
|
}
|
|
|
|
const marketDataItems = await this.prismaService.marketData.findMany({
|
|
select: {
|
|
date: true,
|
|
isCarriedForward: true,
|
|
marketPrice: true,
|
|
state: true
|
|
},
|
|
where: {
|
|
dataSource: sourceAssetProfileIdentifier.dataSource,
|
|
symbol: sourceAssetProfileIdentifier.symbol
|
|
}
|
|
});
|
|
|
|
const operations: Prisma.PrismaPromise<unknown>[] = [
|
|
this.prismaService.order.updateMany({
|
|
data: { symbolProfileId: targetAssetProfile.id },
|
|
where: { symbolProfileId: sourceAssetProfile.id }
|
|
}),
|
|
this.prismaService.symbolProfile.update({
|
|
data: {
|
|
watchedBy: {
|
|
connect: sourceAssetProfile.watchedBy.map(({ id }) => {
|
|
return { id };
|
|
})
|
|
}
|
|
},
|
|
where: { id: targetAssetProfile.id }
|
|
}),
|
|
this.prismaService.marketData.createMany({
|
|
data: marketDataItems.map(
|
|
({ date, isCarriedForward, marketPrice, state }) => {
|
|
return {
|
|
date,
|
|
isCarriedForward,
|
|
marketPrice,
|
|
state,
|
|
dataSource: targetAssetProfileIdentifier.dataSource,
|
|
symbol: targetAssetProfileIdentifier.symbol
|
|
};
|
|
}
|
|
),
|
|
skipDuplicates: true
|
|
}),
|
|
// The market data has no relation to the asset profile and is therefore
|
|
// not deleted in cascade
|
|
this.prismaService.marketData.deleteMany({
|
|
where: {
|
|
dataSource: sourceAssetProfileIdentifier.dataSource,
|
|
symbol: sourceAssetProfileIdentifier.symbol
|
|
}
|
|
}),
|
|
this.prismaService.symbolProfile.delete({
|
|
where: { id: sourceAssetProfile.id }
|
|
})
|
|
];
|
|
|
|
const mergeDescription = `${getAssetProfileIdentifier(
|
|
sourceAssetProfileIdentifier
|
|
)} into ${getAssetProfileIdentifier(targetAssetProfileIdentifier)}`;
|
|
|
|
try {
|
|
await this.prismaService.$transaction(operations);
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`Could not merge the asset profile ${mergeDescription}`,
|
|
error.stack
|
|
);
|
|
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
throw new ConflictException(
|
|
`The asset profile ${mergeDescription} could not be merged, because it has been changed in the meantime`
|
|
);
|
|
}
|
|
|
|
throw new InternalServerErrorException(
|
|
`The asset profile ${mergeDescription} could not be merged`
|
|
);
|
|
}
|
|
|
|
// The merge is committed at this point. The gathering of the market data
|
|
// only updates derived data, thus an error must not fail the request.
|
|
// Otherwise the admin would retry a merge which has already been done.
|
|
try {
|
|
// The activities now refer to the market data of the target asset
|
|
// profile, hence the portfolio snapshots are already wrong and are
|
|
// invalidated immediately. The market data is not gathered with force,
|
|
// because a forced gathering deletes the market data of the days which
|
|
// the data provider does not return, including the days which have been
|
|
// copied from the source asset profile.
|
|
await this.assetProfilesService.gatherSymbolAndEmitPortfolioChangedEvents(
|
|
{
|
|
...targetAssetProfileIdentifier,
|
|
force: false,
|
|
symbolProfileId: targetAssetProfile.id,
|
|
withImmediateInvalidation: true
|
|
}
|
|
);
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`The asset profile ${mergeDescription} has been merged, but the market data could not be gathered`,
|
|
error.stack
|
|
);
|
|
}
|
|
|
|
const [mergedAssetProfile] =
|
|
await this.symbolProfileService.getSymbolProfiles([
|
|
targetAssetProfileIdentifier
|
|
]);
|
|
|
|
if (!mergedAssetProfile) {
|
|
this.logger.error(
|
|
`The asset profile ${mergeDescription} has been merged, but the merged asset profile could not be read`
|
|
);
|
|
|
|
throw new InternalServerErrorException(
|
|
`The asset profile ${mergeDescription} has been merged, but the merged asset profile could not be read. Reload the page.`
|
|
);
|
|
}
|
|
|
|
return mergedAssetProfile;
|
|
}
|
|
|
|
public async patchAssetProfileData(
|
|
{ dataSource, symbol }: AssetProfileIdentifier,
|
|
{
|
|
assetClass,
|
|
assetSubClass,
|
|
comment,
|
|
countries,
|
|
currency,
|
|
dataGatheringFrequency,
|
|
dataSource: newDataSource,
|
|
holdings,
|
|
isActive,
|
|
name,
|
|
scraperConfiguration,
|
|
sectors,
|
|
symbol: newSymbol,
|
|
symbolMapping,
|
|
url
|
|
}: Prisma.SymbolProfileUpdateInput
|
|
) {
|
|
const isConversionToManualDataSource =
|
|
newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL;
|
|
|
|
if (isConversionToManualDataSource && !newSymbol) {
|
|
newSymbol = randomUUID();
|
|
}
|
|
|
|
if (
|
|
newDataSource &&
|
|
newSymbol &&
|
|
(newDataSource !== dataSource || newSymbol !== symbol)
|
|
) {
|
|
const newAssetProfileIdentifier: AssetProfileIdentifier = {
|
|
dataSource: newDataSource as DataSource,
|
|
symbol: newSymbol as string
|
|
};
|
|
|
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
|
newAssetProfileIdentifier
|
|
]);
|
|
|
|
if (assetProfile) {
|
|
throw new HttpException(
|
|
getReasonPhrase(StatusCodes.CONFLICT),
|
|
StatusCodes.CONFLICT
|
|
);
|
|
}
|
|
|
|
const operations: Prisma.PrismaPromise<unknown>[] = [
|
|
this.symbolProfileService.updateAssetProfileIdentifier(
|
|
{
|
|
dataSource,
|
|
symbol
|
|
},
|
|
newAssetProfileIdentifier
|
|
),
|
|
this.marketDataService.updateAssetProfileIdentifier(
|
|
{
|
|
dataSource,
|
|
symbol
|
|
},
|
|
newAssetProfileIdentifier
|
|
)
|
|
];
|
|
|
|
if (isConversionToManualDataSource) {
|
|
const currentAssetProfile =
|
|
await this.prismaService.symbolProfile.findUnique({
|
|
include: { assetProfileOverrides: true },
|
|
where: { dataSource_symbol: { dataSource, symbol } }
|
|
});
|
|
|
|
if (!currentAssetProfile) {
|
|
throw new HttpException(
|
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
StatusCodes.NOT_FOUND
|
|
);
|
|
}
|
|
|
|
const currentAssetProfileWithOverrides = applyAssetProfileOverrides(
|
|
currentAssetProfile,
|
|
currentAssetProfile.assetProfileOverrides
|
|
);
|
|
|
|
operations.push(
|
|
// The overrides are applied on every read, so delete them and
|
|
// persist the merged values in the asset profile instead
|
|
this.symbolProfileService.deleteAssetProfileOverrides(
|
|
newAssetProfileIdentifier
|
|
),
|
|
this.symbolProfileService.updateSymbolProfile(
|
|
newAssetProfileIdentifier,
|
|
{
|
|
assetClass: currentAssetProfileWithOverrides.assetClass,
|
|
assetSubClass: currentAssetProfileWithOverrides.assetSubClass,
|
|
countries:
|
|
currentAssetProfileWithOverrides.countries ?? undefined,
|
|
holdings: currentAssetProfileWithOverrides.holdings ?? undefined,
|
|
name: currentAssetProfileWithOverrides.name,
|
|
sectors: currentAssetProfileWithOverrides.sectors ?? undefined,
|
|
url: currentAssetProfileWithOverrides.url
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
try {
|
|
await this.prismaService.$transaction(operations);
|
|
} catch {
|
|
throw new HttpException(
|
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
|
StatusCodes.BAD_REQUEST
|
|
);
|
|
}
|
|
|
|
const [updatedAssetProfile] =
|
|
await this.symbolProfileService.getSymbolProfiles([
|
|
newAssetProfileIdentifier
|
|
]);
|
|
|
|
return updatedAssetProfile;
|
|
} else {
|
|
const assetProfileOverrides = {
|
|
assetClass: assetClass as AssetClass,
|
|
assetSubClass: assetSubClass as AssetSubClass,
|
|
countries: countries as Prisma.JsonArray,
|
|
holdings: holdings as Prisma.JsonArray,
|
|
name: name as string,
|
|
sectors: sectors as Prisma.JsonArray,
|
|
url: url as string
|
|
};
|
|
|
|
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
|
|
comment,
|
|
currency,
|
|
dataGatheringFrequency,
|
|
dataSource,
|
|
isActive,
|
|
scraperConfiguration,
|
|
symbol,
|
|
symbolMapping,
|
|
...this.symbolProfileService.getAssetProfileUpdateInput(
|
|
{ dataSource, symbol },
|
|
assetProfileOverrides
|
|
)
|
|
};
|
|
|
|
await this.symbolProfileService.updateSymbolProfile(
|
|
{
|
|
dataSource,
|
|
symbol
|
|
},
|
|
updatedSymbolProfile
|
|
);
|
|
|
|
const [updatedAssetProfile] =
|
|
await this.symbolProfileService.getSymbolProfiles([
|
|
{
|
|
dataSource: dataSource as DataSource,
|
|
symbol: symbol as string
|
|
}
|
|
]);
|
|
|
|
return updatedAssetProfile;
|
|
}
|
|
}
|
|
|
|
public async putSetting(key: PropertyKey, value: string) {
|
|
let response: Property;
|
|
|
|
if (value) {
|
|
response = await this.propertyService.put({
|
|
key,
|
|
value
|
|
});
|
|
} else {
|
|
response = await this.propertyService.delete({
|
|
key
|
|
});
|
|
}
|
|
|
|
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
|
|
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
|
|
} else if (key === PROPERTY_CURRENCIES) {
|
|
await this.exchangeRateDataService.initialize();
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private async countUsersWithAnalytics() {
|
|
let where: Prisma.UserWhereInput;
|
|
|
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
where = {
|
|
NOT: {
|
|
analytics: null
|
|
}
|
|
};
|
|
}
|
|
|
|
return this.prismaService.user.count({
|
|
where
|
|
});
|
|
}
|
|
|
|
private async getUsersWithAnalytics({
|
|
skip,
|
|
take,
|
|
where
|
|
}: {
|
|
skip?: number;
|
|
take?: number;
|
|
where?: Prisma.UserWhereInput;
|
|
}): Promise<AdminUsersResponse['users']> {
|
|
let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [
|
|
{ createdAt: 'desc' }
|
|
];
|
|
|
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
orderBy = [
|
|
{
|
|
analytics: {
|
|
lastRequestAt: 'desc'
|
|
}
|
|
}
|
|
];
|
|
|
|
const noAnalyticsCondition: Prisma.UserWhereInput['NOT'] = {
|
|
analytics: null
|
|
};
|
|
|
|
if (where) {
|
|
if (where.NOT) {
|
|
where.NOT = { ...where.NOT, ...noAnalyticsCondition };
|
|
} else {
|
|
where.NOT = noAnalyticsCondition;
|
|
}
|
|
} else {
|
|
where = { NOT: noAnalyticsCondition };
|
|
}
|
|
}
|
|
|
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
|
skip,
|
|
take,
|
|
where,
|
|
orderBy: [...orderBy, { id: 'desc' }],
|
|
select: {
|
|
_count: {
|
|
select: { accounts: true, activities: true }
|
|
},
|
|
analytics: {
|
|
select: {
|
|
activityCount: true,
|
|
country: true,
|
|
dataProviderGhostfolioDailyRequests: true,
|
|
lastRequestAt: true
|
|
}
|
|
},
|
|
createdAt: true,
|
|
id: true,
|
|
provider: true,
|
|
role: true,
|
|
subscriptions: {
|
|
orderBy: {
|
|
expiresAt: 'desc'
|
|
},
|
|
take: 1,
|
|
where: {
|
|
expiresAt: {
|
|
gt: new Date()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return usersWithAnalytics.map(
|
|
({ _count, analytics, createdAt, id, provider, role, subscriptions }) => {
|
|
const daysSinceRegistration =
|
|
differenceInDays(new Date(), createdAt) + 1;
|
|
const engagement = analytics
|
|
? analytics.activityCount / daysSinceRegistration
|
|
: undefined;
|
|
|
|
const subscription =
|
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
subscriptions?.length > 0
|
|
? subscriptions[0]
|
|
: undefined;
|
|
|
|
return {
|
|
createdAt,
|
|
engagement,
|
|
id,
|
|
provider,
|
|
role,
|
|
subscription,
|
|
accountCount: _count.accounts || 0,
|
|
activityCount: _count.activities || 0,
|
|
country: analytics?.country,
|
|
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
|
|
lastActivity: analytics?.lastRequestAt
|
|
};
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|