diff --git a/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.html b/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.html index 374954022..f4df4b696 100644 --- a/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.html +++ b/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.html @@ -7,7 +7,7 @@

{{ isEdit ? 'Edit' : 'Create New' }} Category

Name - +
diff --git a/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.ts b/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.ts index e6ef70d08..8ade8f8cb 100644 --- a/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.ts +++ b/alcs-frontend/src/app/features/admin/tag/tag-category/tag-category-dialog/tag-category-dialog.component.ts @@ -2,6 +2,7 @@ import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { TagCategoryDto } from '../../../../../services/tag/tag-category/tag-category.dto'; import { TagCategoryService } from '../../../../../services/tag/tag-category/tag-category.service'; +import { FormControl } from '@angular/forms'; @Component({ selector: 'app-tag-category-dialog', @@ -15,6 +16,7 @@ export class TagCategoryDialogComponent { isLoading = false; isEdit = false; showNameWarning = false; + nameControl = new FormControl(); constructor( @Inject(MAT_DIALOG_DATA) public data: TagCategoryDto | undefined, @@ -63,6 +65,6 @@ export class TagCategoryDialogComponent { private showWarning() { this.showNameWarning = true; - this.name = ''; + this.nameControl.setErrors({"invalid": true}); } } diff --git a/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.html b/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.html index c5a734706..ca9a9bb41 100644 --- a/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.html +++ b/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.html @@ -8,7 +8,7 @@

{{ isEdit ? 'Edit' : 'Create New' }} Tag

Name - +
diff --git a/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.ts b/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.ts index b03786f32..b99a2f481 100644 --- a/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.ts +++ b/alcs-frontend/src/app/features/admin/tag/tag-dialog/tag-dialog.component.ts @@ -5,6 +5,7 @@ import { TagService } from '../../../../services/tag/tag.service'; import { TagCategoryService } from '../../../../services/tag/tag-category/tag-category.service'; import { TagCategoryDto } from 'src/app/services/tag/tag-category/tag-category.dto'; import { Subject, takeUntil } from 'rxjs'; +import { FormControl } from '@angular/forms'; @Component({ selector: 'app-tag-dialog', @@ -25,6 +26,7 @@ export class TagDialogComponent implements OnInit { isLoading = false; isEdit = false; showNameWarning = false; + nameControl = new FormControl(); categories: TagCategoryDto[] = []; @@ -97,6 +99,6 @@ export class TagDialogComponent implements OnInit { private showWarning() { this.showNameWarning = true; - this.name = ''; + this.nameControl.setErrors({"invalid": true}); } } diff --git a/alcs-frontend/src/app/services/tag/tag-category/tag-category.service.ts b/alcs-frontend/src/app/services/tag/tag-category/tag-category.service.ts index 43449f1fc..c9eab99cb 100644 --- a/alcs-frontend/src/app/services/tag/tag-category/tag-category.service.ts +++ b/alcs-frontend/src/app/services/tag/tag-category/tag-category.service.ts @@ -50,8 +50,8 @@ export class TagCategoryService { return await firstValueFrom(this.http.post(`${this.url}`, createDto)); } catch (e) { const res = e as HttpErrorResponse; - if (res.error.statusCode === HttpStatusCode.Conflict && res.error.message.includes('duplicate key')) { - throw e as HttpErrorResponse; + if (res.error.statusCode === HttpStatusCode.Conflict) { + throw res; } else { console.error(e); this.toastService.showErrorToast('Failed to create tag category'); @@ -65,8 +65,8 @@ export class TagCategoryService { return await firstValueFrom(this.http.patch(`${this.url}/${uuid}`, updateDto)); } catch (e) { const res = e as HttpErrorResponse; - if (res.error.statusCode === HttpStatusCode.Conflict && res.error.message.includes('duplicate key')) { - throw e as HttpErrorResponse; + if (res.error.statusCode === HttpStatusCode.Conflict) { + throw res; } else { console.error(e); this.toastService.showErrorToast('Failed to update tag category'); diff --git a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.spec.ts b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.spec.ts index 6025f707a..c0910e17c 100644 --- a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.spec.ts +++ b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.spec.ts @@ -7,6 +7,7 @@ import { initTagCategoryMockEntity } from '../../../../test/mocks/mockEntities'; import { mockKeyCloakProviders } from '../../../../test/mocks/mockTypes'; import { TagCategory } from './tag-category.entity'; import { TagCategoryDto } from './tag-category.dto'; +import { UpdateResult } from 'typeorm'; describe('TagCategoryController', () => { let controller: TagCategoryController; @@ -62,20 +63,17 @@ describe('TagCategoryController', () => { name: mockCategoryTag.name, } as TagCategoryDto; - const result = await controller.update( - mockCategoryTag.uuid, - categoryToUpdate, - ); + const result = await controller.update(mockCategoryTag.uuid, categoryToUpdate); expect(tagCategoryService.update).toHaveBeenCalledTimes(1); expect(result).toEqual(mockCategoryTag); }); it('should delete a tag category', async () => { - tagCategoryService.delete.mockResolvedValue(mockCategoryTag); + tagCategoryService.delete.mockResolvedValue({} as UpdateResult); const result = await controller.delete(mockCategoryTag.uuid); expect(tagCategoryService.delete).toHaveBeenCalledTimes(1); - expect(result).toEqual(mockCategoryTag); + expect(result).toBeDefined(); }); }); diff --git a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts index 263097760..cf9a887f2 100644 --- a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts +++ b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts @@ -1,16 +1,4 @@ -import { - Body, - Controller, - Delete, - Get, - HttpException, - HttpStatus, - Param, - Patch, - Post, - Query, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiOAuth2 } from '@nestjs/swagger'; import * as config from 'config'; import { RolesGuard } from '../../../common/authorization/roles-guard.service'; @@ -18,7 +6,6 @@ import { UserRoles } from '../../../common/authorization/roles.decorator'; import { AUTH_ROLE } from '../../../common/authorization/roles'; import { TagCategoryDto } from './tag-category.dto'; import { TagCategoryService } from './tag-category.service'; -import { QueryFailedError } from 'typeorm'; @Controller('tag-category') @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @@ -40,45 +27,18 @@ export class TagCategoryController { @Post('') @UserRoles(AUTH_ROLE.ADMIN) async create(@Body() createDto: TagCategoryDto) { - try { - return await this.service.create(createDto); - } catch (e) { - if (e.constructor === QueryFailedError) { - const msg = (e as QueryFailedError).message; - throw new HttpException(msg, HttpStatus.CONFLICT); - } else { - throw e; - } - } + return await this.service.create(createDto); } @Patch('/:uuid') @UserRoles(AUTH_ROLE.ADMIN) async update(@Param('uuid') uuid: string, @Body() updateDto: TagCategoryDto) { - try { - return await this.service.update(uuid, updateDto); - } catch (e) { - if (e.constructor === QueryFailedError) { - const msg = (e as QueryFailedError).message; - throw new HttpException(msg, HttpStatus.CONFLICT); - } else { - throw e; - } - } + return await this.service.update(uuid, updateDto); } @Delete('/:uuid') @UserRoles(AUTH_ROLE.ADMIN) async delete(@Param('uuid') uuid: string) { - try { - return await this.service.delete(uuid); - } catch (e) { - if (e.constructor === QueryFailedError) { - const msg = (e as QueryFailedError).message; - throw new HttpException(msg, HttpStatus.CONFLICT); - } else { - throw e; - } - } + return await this.service.delete(uuid); } } diff --git a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.entity.ts b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.entity.ts index cd50e163e..f866e2a80 100644 --- a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.entity.ts +++ b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.entity.ts @@ -16,6 +16,6 @@ export class TagCategory extends Base { uuid: string; @AutoMap() - @Column({ unique: true }) + @Column() name: string; } diff --git a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.spec.ts b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.spec.ts index 9616a274e..fb0b496d2 100644 --- a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.spec.ts +++ b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.spec.ts @@ -48,6 +48,7 @@ describe('TagCategoryService', () => { }).compile(); service = module.get(TagCategoryService); + tagCategoryRepositoryMock.find.mockResolvedValue([]); tagCategoryRepositoryMock.findOne.mockResolvedValue(mockTagCategoryEntity); tagCategoryRepositoryMock.findOneOrFail.mockResolvedValue(mockTagCategoryEntity); tagCategoryRepositoryMock.save.mockResolvedValue(mockTagCategoryEntity); diff --git a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.ts b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.ts index 7f977ff78..37aaa79d8 100644 --- a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.ts +++ b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.service.ts @@ -35,6 +35,9 @@ export class TagCategoryService { } async create(dto: TagCategoryDto) { + if (await this.hasName(dto)) { + throw new ServiceConflictException('There is already a category with this name. Unable to create.'); + } const newTagCategory = new TagCategory(); newTagCategory.name = dto.name; return this.repository.save(newTagCategory); @@ -47,6 +50,10 @@ export class TagCategoryService { } async update(uuid: string, updateDto: TagCategoryDto) { + updateDto.uuid = uuid; + if (await this.hasName(updateDto)) { + throw new ServiceConflictException('There is already a category with this name. Unable to update.'); + } const tagCategory = await this.getOneOrFail(uuid); tagCategory.name = updateDto.name; return await this.repository.save(tagCategory); @@ -58,7 +65,7 @@ export class TagCategoryService { if (await this.isAssociated(tagCategory)) { throw new ServiceConflictException('Category is associated with tags. Unable to delete.'); } - return await this.repository.remove(tagCategory); + return await this.repository.softDelete(uuid); } async isAssociated(tagCategory: TagCategory) { @@ -66,4 +73,14 @@ export class TagCategoryService { return associatedTags.length > 0; } + + async hasName(tag: TagCategoryDto) { + let tags = await this.repository.find({ + where: { name: tag.name }, + }); + if (tag.uuid) { + tags = tags.filter((t) => t.uuid !== tag.uuid); + } + return tags.length > 0; + } } diff --git a/services/apps/alcs/src/alcs/tag/tag.controller.spec.ts b/services/apps/alcs/src/alcs/tag/tag.controller.spec.ts index 8042769a4..7191b00ad 100644 --- a/services/apps/alcs/src/alcs/tag/tag.controller.spec.ts +++ b/services/apps/alcs/src/alcs/tag/tag.controller.spec.ts @@ -48,6 +48,7 @@ describe('TagController', () => { it('should create a tag', async () => { const dto: TagDto = { + uuid: mockTag.uuid, name: mockTag.name, category: mockTag.category ? { diff --git a/services/apps/alcs/src/alcs/tag/tag.controller.ts b/services/apps/alcs/src/alcs/tag/tag.controller.ts index 27cc01ff7..a29446a9b 100644 --- a/services/apps/alcs/src/alcs/tag/tag.controller.ts +++ b/services/apps/alcs/src/alcs/tag/tag.controller.ts @@ -1,16 +1,4 @@ -import { - Body, - Controller, - Delete, - Get, - HttpException, - HttpStatus, - Param, - Patch, - Post, - Query, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiOAuth2 } from '@nestjs/swagger'; import * as config from 'config'; import { RolesGuard } from '../../common/authorization/roles-guard.service'; @@ -18,7 +6,6 @@ import { UserRoles } from '../../common/authorization/roles.decorator'; import { TagService } from './tag.service'; import { AUTH_ROLE } from '../../common/authorization/roles'; import { TagDto } from './tag.dto'; -import { QueryFailedError } from 'typeorm'; @Controller('tag') @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @@ -40,45 +27,18 @@ export class TagController { @Post('') @UserRoles(AUTH_ROLE.ADMIN) async create(@Body() createDto: TagDto) { - try { - return await this.service.create(createDto); - } catch (e) { - if (e.constructor === QueryFailedError) { - const msg = (e as QueryFailedError).message; - throw new HttpException(msg, HttpStatus.CONFLICT); - } else { - throw e; - } - } + return await this.service.create(createDto); } @Patch('/:uuid') @UserRoles(AUTH_ROLE.ADMIN) async update(@Param('uuid') uuid: string, @Body() updateDto: TagDto) { - try { - return await this.service.update(uuid, updateDto); - } catch (e) { - if (e.constructor === QueryFailedError) { - const msg = (e as QueryFailedError).message; - throw new HttpException(msg, HttpStatus.CONFLICT); - } else { - throw e; - } - } + return await this.service.update(uuid, updateDto); } @Delete('/:uuid') @UserRoles(AUTH_ROLE.ADMIN) async delete(@Param('uuid') uuid: string) { - try { - return await this.service.delete(uuid); - } catch (e) { - if (e.constructor === QueryFailedError) { - const msg = (e as QueryFailedError).message; - throw new HttpException(msg, HttpStatus.CONFLICT); - } else { - throw e; - } - } + return await this.service.delete(uuid); } } diff --git a/services/apps/alcs/src/alcs/tag/tag.dto.ts b/services/apps/alcs/src/alcs/tag/tag.dto.ts index 8088d8dc6..75ce8d342 100644 --- a/services/apps/alcs/src/alcs/tag/tag.dto.ts +++ b/services/apps/alcs/src/alcs/tag/tag.dto.ts @@ -2,6 +2,9 @@ import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; import { TagCategoryDto } from './tag-category/tag-category.dto'; export class TagDto { + @IsString() + uuid: string; + @IsString() name: string; diff --git a/services/apps/alcs/src/alcs/tag/tag.entity.ts b/services/apps/alcs/src/alcs/tag/tag.entity.ts index 80f1d2620..071901c6a 100644 --- a/services/apps/alcs/src/alcs/tag/tag.entity.ts +++ b/services/apps/alcs/src/alcs/tag/tag.entity.ts @@ -19,7 +19,7 @@ export class Tag extends Base { uuid: string; @AutoMap() - @Column({ unique: true }) + @Column() name: string; @AutoMap() @@ -33,7 +33,7 @@ export class Tag extends Base { @ManyToMany(() => Application, (application) => application.tags) applications: Application[]; - + @ManyToMany(() => NoticeOfIntent, (noticeOfIntent) => noticeOfIntent.tags) noticeOfIntents: NoticeOfIntent[]; } diff --git a/services/apps/alcs/src/alcs/tag/tag.service.spec.ts b/services/apps/alcs/src/alcs/tag/tag.service.spec.ts index b853208c7..60a49469e 100644 --- a/services/apps/alcs/src/alcs/tag/tag.service.spec.ts +++ b/services/apps/alcs/src/alcs/tag/tag.service.spec.ts @@ -52,6 +52,7 @@ describe('TagCategoryService', () => { tagRepositoryMock.findOneOrFail.mockResolvedValue(mockTagEntity); tagRepositoryMock.findOne.mockResolvedValue(mockTagEntity); + tagRepositoryMock.find.mockResolvedValue([]); tagRepositoryMock.save.mockResolvedValue(mockTagEntity); tagCategoryRepositoryMock.findOne.mockResolvedValue(mockTagCategoryEntity); tagCategoryRepositoryMock = module.get(getRepositoryToken(TagCategory)); @@ -67,6 +68,7 @@ describe('TagCategoryService', () => { it('should call save when an Tag is updated', async () => { const payload: TagDto = { + uuid: mockTagEntity.uuid, name: mockTagEntity.name, isActive: mockTagEntity.isActive, category: mockTagCategoryEntity, @@ -80,6 +82,7 @@ describe('TagCategoryService', () => { it('should call save when tag successfully create', async () => { const payload: TagDto = { + uuid: mockTagEntity.uuid, name: mockTagEntity.name, isActive: mockTagEntity.isActive, category: mockTagCategoryEntity, diff --git a/services/apps/alcs/src/alcs/tag/tag.service.ts b/services/apps/alcs/src/alcs/tag/tag.service.ts index ff3f13c2c..5563c5878 100644 --- a/services/apps/alcs/src/alcs/tag/tag.service.ts +++ b/services/apps/alcs/src/alcs/tag/tag.service.ts @@ -38,6 +38,9 @@ export class TagService { } async create(dto: TagDto) { + if (await this.hasName(dto)) { + throw new ServiceConflictException('There is already a tag with this name. Unable to create.'); + } const category = dto.category ? await this.categoryRepository.findOne({ where: { @@ -60,6 +63,10 @@ export class TagService { } async update(uuid: string, updateDto: TagDto) { + updateDto.uuid = uuid; + if (await this.hasName(updateDto)) { + throw new ServiceConflictException('There is already a tag with this name. Unable to update.'); + } const category = updateDto.category ? await this.categoryRepository.findOne({ where: { @@ -93,4 +100,14 @@ export class TagService { return (tag.applications && tag.applications.length > 0) || (tag.noticeOfIntents && tag.noticeOfIntents.length > 0); } + + async hasName(tag: TagDto) { + let tags = await this.repository.find({ + where: { name: tag.name }, + }); + if (tag.uuid) { + tags = tags.filter((t) => t.uuid !== tag.uuid); + } + return tags.length > 0; + } } diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1730856145155-remove_unique_constraints.ts b/services/apps/alcs/src/providers/typeorm/migrations/1730856145155-remove_unique_constraints.ts new file mode 100644 index 000000000..5a0326f70 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1730856145155-remove_unique_constraints.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveUniqueConstraints1730856145155 implements MigrationInterface { + name = 'RemoveUniqueConstraints1730856145155' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "alcs"."tag_category" DROP CONSTRAINT "UQ_f48a9fe1f705a7c2a60856d395a"`); + await queryRunner.query(`ALTER TABLE "alcs"."tag" DROP CONSTRAINT "UQ_6a9775008add570dc3e5a0bab7b"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "alcs"."tag" ADD CONSTRAINT "UQ_6a9775008add570dc3e5a0bab7b" UNIQUE ("name")`); + await queryRunner.query(`ALTER TABLE "alcs"."tag_category" ADD CONSTRAINT "UQ_f48a9fe1f705a7c2a60856d395a" UNIQUE ("name")`); + } + +}