diff --git a/apps/api/src/app/access/access-filter.dto.ts b/apps/api/src/app/access/access-filter.dto.ts new file mode 100644 index 000000000..ac8c81bb3 --- /dev/null +++ b/apps/api/src/app/access/access-filter.dto.ts @@ -0,0 +1,40 @@ +import { DataSource } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested +} from 'class-validator'; + +export class HoldingFilterDto { + @IsEnum(DataSource) + dataSource: DataSource; + + @IsString() + symbol: string; +} + +export class AccessFilterDto { + @IsArray() + @IsOptional() + @IsString({ each: true }) + accountIds?: string[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + assetClasses?: string[]; + + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => HoldingFilterDto) + holdings?: HoldingFilterDto[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + tagIds?: string[]; +} diff --git a/apps/api/src/app/access/access-settings.interface.ts b/apps/api/src/app/access/access-settings.interface.ts new file mode 100644 index 000000000..8e173fdae --- /dev/null +++ b/apps/api/src/app/access/access-settings.interface.ts @@ -0,0 +1 @@ +export type { AccessSettings } from '@ghostfolio/common/interfaces'; diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index cb1e2d4af..06ebc41d5 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -22,6 +22,7 @@ import { AuthGuard } from '@nestjs/passport'; import { Access as AccessModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { AccessSettings } from './access-settings.interface'; import { AccessService } from './access.service'; import { CreateAccessDto } from './create-access.dto'; import { UpdateAccessDto } from './update-access.dto'; @@ -46,12 +47,19 @@ export class AccessController { }); return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions }) => { + ({ + alias, + granteeUser, + id, + permissions: accessPermissions, + settings + }) => { if (granteeUser) { return { alias, id, - permissions, + permissions: accessPermissions, + settings: settings as AccessSettings, grantee: granteeUser?.id, type: 'PRIVATE' }; @@ -60,7 +68,8 @@ export class AccessController { return { alias, id, - permissions, + permissions: accessPermissions, + settings: settings as AccessSettings, grantee: 'Public', type: 'PUBLIC' }; @@ -85,12 +94,17 @@ export class AccessController { } try { + const settings: AccessSettings = data.filter + ? { filter: data.filter } + : {}; + return this.accessService.createAccess({ alias: data.alias || undefined, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + settings: settings as any, user: { connect: { id: this.request.user.id } } }); } catch { @@ -152,13 +166,18 @@ export class AccessController { } try { + const settings: AccessSettings = data.filter + ? { filter: data.filter } + : {}; + return this.accessService.updateAccess({ data: { alias: data.alias, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, - permissions: data.permissions + permissions: data.permissions, + settings: settings as any }, where: { id } }); diff --git a/apps/api/src/app/access/create-access.dto.ts b/apps/api/src/app/access/create-access.dto.ts index 087df7183..29d92bb8b 100644 --- a/apps/api/src/app/access/create-access.dto.ts +++ b/apps/api/src/app/access/create-access.dto.ts @@ -1,3 +1,5 @@ +import { AccessFilter } from '@ghostfolio/common/interfaces'; + import { AccessPermission } from '@prisma/client'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -6,6 +8,9 @@ export class CreateAccessDto { @IsString() alias?: string; + @IsOptional() + filter?: AccessFilter; + @IsOptional() @IsUUID() granteeUserId?: string; diff --git a/apps/api/src/app/access/update-access.dto.ts b/apps/api/src/app/access/update-access.dto.ts index 2850186f9..f7c163b0d 100644 --- a/apps/api/src/app/access/update-access.dto.ts +++ b/apps/api/src/app/access/update-access.dto.ts @@ -1,3 +1,5 @@ +import { AccessFilter } from '@ghostfolio/common/interfaces'; + import { AccessPermission } from '@prisma/client'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -6,6 +8,9 @@ export class UpdateAccessDto { @IsString() alias?: string; + @IsOptional() + filter?: AccessFilter; + @IsOptional() @IsUUID() granteeUserId?: string; diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index b09ced4fb..82bb60ba6 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -8,7 +8,10 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { getSum } from '@ghostfolio/common/helper'; -import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; +import { + AccessSettings, + PublicPortfolioResponse +} from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -61,6 +64,10 @@ export class PublicController { hasDetails = user.subscription.type === 'Premium'; } + // Get filter configuration from access settings + const accessSettings = (access.settings ?? {}) as AccessSettings; + const filter = accessSettings.filter; + const [ { createdAt, holdings, markets }, { performance: performance1d }, @@ -81,6 +88,48 @@ export class PublicController { }) ]); + // Apply filter to holdings if configured + let filteredHoldings = holdings; + if (filter) { + filteredHoldings = Object.fromEntries( + Object.entries(holdings).filter(([, holding]) => { + // Filter by asset class + if ( + filter.assetClasses && + filter.assetClasses.length > 0 && + !filter.assetClasses.includes(holding.assetClass) + ) { + return false; + } + + // Filter by specific holdings (symbol + dataSource) + if (filter.holdings && filter.holdings.length > 0) { + const matchesHolding = filter.holdings.some( + (h) => + h.symbol === holding.symbol && + h.dataSource === holding.dataSource + ); + if (!matchesHolding) { + return false; + } + } + + // Filter by tags - check if holding has at least one of the filtered tags + if (filter.tagIds && filter.tagIds.length > 0) { + const holdingTagIds = holding.tags?.map((tag) => tag.id) ?? []; + const hasMatchingTag = filter.tagIds.some((tagId) => + holdingTagIds.includes(tagId) + ); + if (!hasMatchingTag) { + return false; + } + } + + return true; + }) + ); + } + const { activities } = await this.orderService.getOrders({ includeDrafts: false, sortColumn: 'date', @@ -92,12 +141,20 @@ export class PublicController { withExcludedAccountsAndActivities: false }); + // Filter activities by account if filter is configured + let filteredActivities = activities; + if (filter?.accountIds && filter.accountIds.length > 0) { + filteredActivities = activities.filter((activity) => + filter.accountIds.includes(activity.accountId) + ); + } + // Experimental const latestActivities = this.configurationService.get( 'ENABLE_FEATURE_SUBSCRIPTION' ) ? [] - : activities.map( + : filteredActivities.map( ({ currency, date, @@ -151,19 +208,23 @@ export class PublicController { }; const totalValue = getSum( - Object.values(holdings).map(({ currency, marketPrice, quantity }) => { - return new Big( - this.exchangeRateDataService.toCurrency( - quantity * marketPrice, - currency, - this.request.user?.settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ) - ); - }) + Object.values(filteredHoldings).map( + ({ currency, marketPrice, quantity }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + quantity * marketPrice, + currency, + this.request.user?.settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ) + ); + } + ) ).toNumber(); - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + for (const [symbol, portfolioPosition] of Object.entries( + filteredHoldings + )) { publicPortfolioResponse.holdings[symbol] = { allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 33be69382..093d7f596 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -84,7 +84,10 @@ export class GfCreateOrUpdateAccessDialogComponent this.accessForm = this.formBuilder.group({ alias: [this.data.access.alias], - filter: [null], + filterAccount: [null], + filterAssetClass: [null], + filterHolding: [null], + filterTag: [null], granteeUserId: [ this.data.access.grantee, isPublic @@ -104,14 +107,17 @@ export class GfCreateOrUpdateAccessDialogComponent this.accessForm.get('type').valueChanges.subscribe((accessType) => { const granteeUserIdControl = this.accessForm.get('granteeUserId'); const permissionsControl = this.accessForm.get('permissions'); - const filterControl = this.accessForm.get('filter'); if (accessType === 'PRIVATE') { granteeUserIdControl.setValidators([ (control: AbstractControl) => Validators.required(control) ]); this.showFilterPanel = false; - filterControl.setValue(null); + // Limpiar los filtros + this.accessForm.get('filterAccount')?.setValue(null); + this.accessForm.get('filterAssetClass')?.setValue(null); + this.accessForm.get('filterHolding')?.setValue(null); + this.accessForm.get('filterTag')?.setValue(null); } else { granteeUserIdControl.clearValidators(); granteeUserIdControl.setValue(null); @@ -149,13 +155,82 @@ export class GfCreateOrUpdateAccessDialogComponent this.unsubscribeSubject.complete(); } + private buildFilterObject(): + | { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: { dataSource: string; symbol: string }[]; + tagIds?: string[]; + } + | undefined { + const filterAccount = this.accessForm.get('filterAccount')?.value as + | string + | null; + const filterAssetClass = this.accessForm.get('filterAssetClass')?.value as + | string + | null; + const filterHolding = this.accessForm.get('filterHolding')?.value as + | string + | null; + const filterTag = this.accessForm.get('filterTag')?.value as string | null; + + // Solo retornar filtro si hay al menos un campo con valor + if (!filterAccount && !filterAssetClass && !filterHolding && !filterTag) { + return undefined; + } + + const filter: { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: { dataSource: string; symbol: string }[]; + tagIds?: string[]; + } = {}; + + if (filterAccount) { + filter.accountIds = [filterAccount]; + } + + if (filterAssetClass) { + filter.assetClasses = [filterAssetClass]; + } + + if (filterHolding) { + // Buscar el holding seleccionado para obtener dataSource y symbol + const holding = this.holdings.find((h) => h.symbol === filterHolding); + if (holding) { + filter.holdings = [ + { + dataSource: holding.dataSource, + symbol: holding.symbol + } + ]; + } + } + + if (filterTag) { + filter.tagIds = [filterTag]; + } + + return filter; + } + private loadFilterData() { + const existingFilter = this.data.access.settings?.filter; + // Cargar cuentas this.dataService .fetchAccounts() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((response) => { this.accounts = response.accounts; + + // Si existe un filtro de cuenta, establecerlo + if (existingFilter?.accountIds?.[0]) { + this.accessForm + .get('filterAccount') + ?.setValue(existingFilter.accountIds[0]); + } + this.changeDetectorRef.markForCheck(); }); @@ -179,6 +254,20 @@ export class GfCreateOrUpdateAccessDialogComponent label: ac, type: 'ASSET_CLASS' as const })); + + // Si existe un filtro de asset class, establecerlo + if (existingFilter?.assetClasses?.[0]) { + this.accessForm + .get('filterAssetClass') + ?.setValue(existingFilter.assetClasses[0]); + } + + // Si existe un filtro de holding, establecerlo + if (existingFilter?.holdings?.[0]?.symbol) { + this.accessForm + .get('filterHolding') + ?.setValue(existingFilter.holdings[0].symbol); + } } this.changeDetectorRef.markForCheck(); }); @@ -193,13 +282,23 @@ export class GfCreateOrUpdateAccessDialogComponent label: tag.name, type: 'TAG' as const })); + + // Si existe un filtro de tag, establecerlo + if (existingFilter?.tagIds?.[0]) { + this.accessForm.get('filterTag')?.setValue(existingFilter.tagIds[0]); + } + this.changeDetectorRef.markForCheck(); }); } private async createAccess() { + // Construir el objeto filter si estamos en modo PUBLIC + const filter = this.showFilterPanel ? this.buildFilterObject() : undefined; + const access: CreateAccessDto = { alias: this.accessForm.get('alias')?.value as string, + filter: filter, granteeUserId: this.accessForm.get('granteeUserId')?.value as string, permissions: [ this.accessForm.get('permissions')?.value as AccessPermission @@ -236,8 +335,12 @@ export class GfCreateOrUpdateAccessDialogComponent } private async updateAccess() { + // Construir el objeto filter si estamos en modo PUBLIC + const filter = this.showFilterPanel ? this.buildFilterObject() : undefined; + const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value as string, + filter: filter, granteeUserId: this.accessForm.get('granteeUserId')?.value as string, id: this.data.access.id, permissions: [ diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index bdb9af6ed..bcd719640 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -226,6 +226,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit { id: access.id, grantee: access.grantee === 'Public' ? null : access.grantee, permissions: access.permissions, + settings: access.settings, type: access.type } }, diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts index 7736a71ab..cd65e6813 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -2,10 +2,22 @@ import { AccessType } from '@ghostfolio/common/types'; import { AccessPermission } from '@prisma/client'; +export interface AccessFilter { + accountIds?: string[]; + assetClasses?: string[]; + holdings?: { dataSource: string; symbol: string }[]; + tagIds?: string[]; +} + +export interface AccessSettings { + filter?: AccessFilter; +} + export interface Access { alias?: string; grantee?: string; id: string; permissions: AccessPermission[]; + settings?: AccessSettings; type: AccessType; } diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index e3c2c2038..f387809bd 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -1,4 +1,4 @@ -import type { Access } from './access.interface'; +import type { Access, AccessFilter, AccessSettings } from './access.interface'; import type { AccountBalance } from './account-balance.interface'; import type { AdminData } from './admin-data.interface'; import type { AdminJobs } from './admin-jobs.interface'; @@ -79,6 +79,8 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface'; export { Access, + AccessFilter, + AccessSettings, AccessTokenResponse, AccountBalance, AccountBalancesResponse,