From f1ab3ff8e85530106a9e904907ea36a5c5f5cf7c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 15 Feb 2025 10:00:14 +0100 Subject: [PATCH] Feature/modernize tags endpoint (#4317) * Modernize tags endpoint * Update changelog --- CHANGELOG.md | 2 +- apps/api/src/app/app.module.ts | 4 +- .../{tag => endpoints/tags}/create-tag.dto.ts | 0 .../tags/tags.controller.ts} | 56 ++++++------- .../api/src/app/endpoints/tags/tags.module.ts | 12 +++ .../{tag => endpoints/tags}/update-tag.dto.ts | 0 apps/api/src/app/tag/tag.module.ts | 14 ---- apps/api/src/app/tag/tag.service.ts | 81 ------------------- apps/api/src/services/tag/tag.service.ts | 73 +++++++++++++++++ .../admin-tag/admin-tag.component.ts | 14 ++-- .../create-or-update-tag-dialog.component.ts | 4 +- .../holding-detail-dialog.component.ts | 4 +- .../holding-detail-dialog.html | 4 +- apps/client/src/app/services/admin.service.ts | 20 +---- apps/client/src/app/services/data.service.ts | 18 +++++ 15 files changed, 147 insertions(+), 159 deletions(-) rename apps/api/src/app/{tag => endpoints/tags}/create-tag.dto.ts (100%) rename apps/api/src/app/{tag/tag.controller.ts => endpoints/tags/tags.controller.ts} (95%) create mode 100644 apps/api/src/app/endpoints/tags/tags.module.ts rename apps/api/src/app/{tag => endpoints/tags}/update-tag.dto.ts (100%) delete mode 100644 apps/api/src/app/tag/tag.module.ts delete mode 100644 apps/api/src/app/tag/tag.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db1563f2..c5e98236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 6d097aef..3e68bf42 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -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 ], diff --git a/apps/api/src/app/tag/create-tag.dto.ts b/apps/api/src/app/endpoints/tags/create-tag.dto.ts similarity index 100% rename from apps/api/src/app/tag/create-tag.dto.ts rename to apps/api/src/app/endpoints/tags/create-tag.dto.ts diff --git a/apps/api/src/app/tag/tag.controller.ts b/apps/api/src/app/endpoints/tags/tags.controller.ts similarity index 95% rename from apps/api/src/app/tag/tag.controller.ts rename to apps/api/src/app/endpoints/tags/tags.controller.ts index 862f0a70..bf216bb2 100644 --- a/apps/api/src/app/tag/tag.controller.ts +++ b/apps/api/src/app/endpoints/tags/tags.controller.ts @@ -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 { @@ -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 }); - } } diff --git a/apps/api/src/app/endpoints/tags/tags.module.ts b/apps/api/src/app/endpoints/tags/tags.module.ts new file mode 100644 index 00000000..a8a2f1c5 --- /dev/null +++ b/apps/api/src/app/endpoints/tags/tags.module.ts @@ -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 {} diff --git a/apps/api/src/app/tag/update-tag.dto.ts b/apps/api/src/app/endpoints/tags/update-tag.dto.ts similarity index 100% rename from apps/api/src/app/tag/update-tag.dto.ts rename to apps/api/src/app/endpoints/tags/update-tag.dto.ts diff --git a/apps/api/src/app/tag/tag.module.ts b/apps/api/src/app/tag/tag.module.ts deleted file mode 100644 index 48587c54..00000000 --- a/apps/api/src/app/tag/tag.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/api/src/app/tag/tag.service.ts b/apps/api/src/app/tag/tag.service.ts deleted file mode 100644 index c4a5447a..00000000 --- a/apps/api/src/app/tag/tag.service.ts +++ /dev/null @@ -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 { - return this.prismaService.tag.delete({ where }); - } - - public async getTag( - tagWhereUniqueInput: Prisma.TagWhereUniqueInput - ): Promise { - 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 { - return this.prismaService.tag.update({ - data, - where - }); - } -} diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index b16f22fb..3d6bd390 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -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 { + return this.prismaService.tag.delete({ where }); + } + + public async getTag( + tagWhereUniqueInput: Prisma.TagWhereUniqueInput + ): Promise { + 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 { + return this.prismaService.tag.update({ + data, + where + }); + } } diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.ts b/apps/client/src/app/components/admin-tag/admin-tag.component.ts index 3c0bdb68..bd7432b3 100644 --- a/apps/client/src/app/components/admin-tag/admin-tag.component.ts +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.ts @@ -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(); 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({ diff --git a/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts index 9b7194cc..1bbda8e1 100644 --- a/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts +++ b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts @@ -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 { diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 54fab34d..69322b21 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -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(); public constructor( - private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, public dialogRef: MatDialogRef, @@ -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) => { diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index df1c0c62..a767a579 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -388,7 +388,9 @@ (`/api/v1/tag/${aId}`); - } - public executeJob(aId: string) { return this.http.get(`/api/v1/admin/queue/job/${aId}/execute`); } @@ -155,10 +149,6 @@ export class AdminService { return this.http.get('/api/v1/platform'); } - public fetchTags() { - return this.http.get('/api/v1/tag'); - } - public fetchUsers({ skip, take = DEFAULT_PAGE_SIZE @@ -261,10 +251,6 @@ export class AdminService { return this.http.post(`/api/v1/platform`, aPlatform); } - public postTag(aTag: CreateTagDto) { - return this.http.post(`/api/v1/tag`, aTag); - } - public putPlatform(aPlatform: UpdatePlatformDto) { return this.http.put( `/api/v1/platform/${aPlatform.id}`, @@ -272,10 +258,6 @@ export class AdminService { ); } - public putTag(aTag: UpdateTagDto) { - return this.http.put(`/api/v1/tag/${aTag.id}`, aTag); - } - public testMarketData({ dataSource, scraperConfiguration, diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 4a57d587..0da1275e 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -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(`/api/v1/user`, { body: aData }); } + public deleteTag(aId: string) { + return this.http.delete(`/api/v1/tags/${aId}`); + } + public deleteUser(aId: string) { return this.http.delete(`/api/v1/user/${aId}`); } @@ -662,6 +668,10 @@ export class DataService { ); } + public fetchTags() { + return this.http.get('/api/v1/tags'); + } + public loginAnonymous(accessToken: string) { return this.http.post('/api/v1/auth/anonymous', { accessToken @@ -709,6 +719,10 @@ export class DataService { return this.http.post('/api/v1/order', aOrder); } + public postTag(aTag: CreateTagDto) { + return this.http.post(`/api/v1/tags`, aTag); + } + public postUser() { return this.http.post('/api/v1/user', {}); } @@ -736,6 +750,10 @@ export class DataService { return this.http.put(`/api/v1/order/${aOrder.id}`, aOrder); } + public putTag(aTag: UpdateTagDto) { + return this.http.put(`/api/v1/tags/${aTag.id}`, aTag); + } + public putUserSetting(aData: UpdateUserSettingDto) { return this.http.put('/api/v1/user/setting', aData); }