Browse Source

Task/add access filter and settings interfaces, update access DTOs and components for filter functionality

pull/5848/head
Germán Martín 1 week ago
parent
commit
7abd226c50
  1. 40
      apps/api/src/app/access/access-filter.dto.ts
  2. 1
      apps/api/src/app/access/access-settings.interface.ts
  3. 27
      apps/api/src/app/access/access.controller.ts
  4. 5
      apps/api/src/app/access/create-access.dto.ts
  5. 5
      apps/api/src/app/access/update-access.dto.ts
  6. 87
      apps/api/src/app/endpoints/public/public.controller.ts
  7. 109
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  8. 1
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  9. 12
      libs/common/src/lib/interfaces/access.interface.ts
  10. 4
      libs/common/src/lib/interfaces/index.ts

40
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[];
}

1
apps/api/src/app/access/access-settings.interface.ts

@ -0,0 +1 @@
export type { AccessSettings } from '@ghostfolio/common/interfaces';

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

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

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

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

109
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: [

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

12
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;
}

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

Loading…
Cancel
Save