Feature/modernize tags endpoint (#4317)

* Modernize tags endpoint

* Update changelog
This commit is contained in:
Thomas Kaul 2025-02-15 10:00:14 +01:00 committed by GitHub
parent 4f76ee6758
commit f1ab3ff8e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 147 additions and 159 deletions

View File

@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Asia-Pacific Markets)
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Japan)
- Added support to create custom tags in the holding detail dialog
- Added support to create custom tags in the holding detail dialog (experimental)
- Extended the tags selector component by a `readonly` attribute
- Extended the tags selector component to support creating custom tags
- Extended the holding detail dialog by the historical market data editor (experimental)

View File

@ -36,6 +36,7 @@ import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
@ -49,7 +50,6 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module';
@Module({
@ -124,7 +124,7 @@ import { UserModule } from './user/user.module';
SitemapModule,
SubscriptionModule,
SymbolModule,
TagModule,
TagsModule,
TwitterBotModule,
UserModule
],

View File

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -21,23 +22,15 @@ import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
@Controller('tags')
export class TagsController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
@ -70,6 +63,31 @@ export class TagController {
return this.tagService.createTag(data);
}
@Delete(':id')
@HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@HasPermission(permissions.updateTag)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -94,22 +112,4 @@ export class TagController {
}
});
}
@Delete(':id')
@HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
}

View File

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
@Module({
controllers: [TagsController],
imports: [PrismaModule, TagModule]
})
export class TagsModule {}

View File

@ -1,14 +0,0 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -1,81 +0,0 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

View File

@ -1,11 +1,52 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsForUser(userId: string) {
const tags = await this.prismaService.tag.findMany({
include: {
@ -41,4 +82,36 @@ export class TagService {
isUsed: _count.orders > 0
}));
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

View File

@ -1,8 +1,7 @@
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -43,7 +42,6 @@ export class AdminTagComponent implements OnInit, OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
@ -100,7 +98,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
}
private deleteTag(aId: string) {
this.adminService
this.dataService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
@ -116,7 +114,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
}
private fetchTags() {
this.adminService
this.dataService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
@ -148,7 +146,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tag: CreateTagDto | null) => {
if (tag) {
this.adminService
this.dataService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
@ -184,7 +182,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tag: UpdateTagDto | null) => {
if (tag) {
this.adminService
this.dataService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({

View File

@ -1,5 +1,5 @@
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {

View File

@ -2,7 +2,6 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
@ -133,7 +132,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@ -162,7 +160,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
});
if (newTag && this.hasPermissionToCreateOwnTag) {
this.adminService
this.dataService
.postTag({ ...newTag, userId: this.user.id })
.pipe(
switchMap((createdTag) => {

View File

@ -388,7 +388,9 @@
</mat-tab-group>
<gf-tags-selector
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[hasPermissionToCreateTag]="
hasPermissionToCreateOwnTag && user?.settings?.isExperimentalFeatures
"
[readonly]="!data.hasPermissionToUpdateOrder"
[tags]="activityForm.get('tags')?.value"
[tagsAvailable]="tagsAvailable"

View File

@ -1,8 +1,6 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
HEADER_KEY_SKIP_INTERCEPTOR,
@ -25,7 +23,7 @@ import {
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
import { DataSource, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull';
import { format } from 'date-fns';
import { switchMap } from 'rxjs';
@ -75,10 +73,6 @@ export class AdminService {
);
}
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tag/${aId}`);
}
public executeJob(aId: string) {
return this.http.get<void>(`/api/v1/admin/queue/job/${aId}/execute`);
}
@ -155,10 +149,6 @@ export class AdminService {
return this.http.get<Platform[]>('/api/v1/platform');
}
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tag');
}
public fetchUsers({
skip,
take = DEFAULT_PAGE_SIZE
@ -261,10 +251,6 @@ export class AdminService {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tag`, aTag);
}
public putPlatform(aPlatform: UpdatePlatformDto) {
return this.http.put<Platform>(
`/api/v1/platform/${aPlatform.id}`,
@ -272,10 +258,6 @@ export class AdminService {
);
}
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tag/${aTag.id}`, aTag);
}
public testMarketData({
dataSource,
scraperConfiguration,

View File

@ -4,6 +4,8 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import {
Activities,
@ -308,6 +310,10 @@ export class DataService {
return this.http.delete<any>(`/api/v1/user`, { body: aData });
}
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tags/${aId}`);
}
public deleteUser(aId: string) {
return this.http.delete<any>(`/api/v1/user/${aId}`);
}
@ -662,6 +668,10 @@ export class DataService {
);
}
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tags');
}
public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken
@ -709,6 +719,10 @@ export class DataService {
return this.http.post<OrderModel>('/api/v1/order', aOrder);
}
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tags`, aTag);
}
public postUser() {
return this.http.post<UserItem>('/api/v1/user', {});
}
@ -736,6 +750,10 @@ export class DataService {
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
}
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tags/${aTag.id}`, aTag);
}
public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>('/api/v1/user/setting', aData);
}