diff --git a/apps/server/src/modules/deletion/deletion-api.module.ts b/apps/server/src/modules/deletion/deletion-api.module.ts index 5e4a6cf427d..5ee84557ff3 100644 --- a/apps/server/src/modules/deletion/deletion-api.module.ts +++ b/apps/server/src/modules/deletion/deletion-api.module.ts @@ -19,6 +19,7 @@ import { FilesStorageClientModule } from '@modules/files-storage-client'; import { DeletionRequestsController } from './controller/deletion-requests.controller'; import { DeletionExecutionsController } from './controller/deletion-executions.controller'; import { DeletionRequestUc } from './uc'; +import { NewsModule } from '../news'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { DeletionRequestUc } from './uc'; RegistrationPinModule, FilesStorageClientModule, TaskModule, + NewsModule, RocketChatModule.forRoot({ uri: Configuration.get('ROCKET_CHAT_URI') as string, adminId: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts index 24a4998d33f..f82364a4e83 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -18,6 +18,7 @@ import { FilesStorageClientAdapterService } from '@src/modules/files-storage-cli import { DomainName, OperationType } from '@shared/domain/types'; import { TaskService } from '@modules/task'; import { DomainOperationBuilder } from '@shared/domain/builder'; +import { NewsService } from '@src/modules/news/service/news.service'; import { DeletionStatusModel } from '../domain/types'; import { DeletionLogService } from '../services/deletion-log.service'; import { DeletionRequestService } from '../services'; @@ -47,6 +48,7 @@ describe(DeletionRequestUc.name, () => { let filesStorageClientAdapterService: DeepMocked; let dashboardService: DeepMocked; let taskService: DeepMocked; + let newsService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -124,6 +126,7 @@ describe(DeletionRequestUc.name, () => { provide: TaskService, useValue: createMock(), }, + { provide: NewsService, useValue: createMock() }, ], }).compile(); @@ -145,6 +148,7 @@ describe(DeletionRequestUc.name, () => { filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); dashboardService = module.get(DashboardService); taskService = module.get(TaskService); + newsService = module.get(NewsService); await setupEntities(); }); @@ -241,6 +245,10 @@ describe(DeletionRequestUc.name, () => { new ObjectId().toHexString(), ]); + const newsUpdated = DomainOperationBuilder.build(DomainName.LESSONS, OperationType.UPDATE, 1, [ + new ObjectId().toHexString(), + ]); + const parentEmail = 'parent@parent.eu'; const pseudonymsDeleted = DomainOperationBuilder.build(DomainName.PSEUDONYMS, OperationType.DELETE, 1, [ @@ -307,6 +315,7 @@ describe(DeletionRequestUc.name, () => { taskService.removeCreatorIdFromTasks.mockResolvedValueOnce(tasksModifiedByRemoveCreatorId); taskService.removeUserFromFinished.mockResolvedValueOnce(tasksModifiedByRemoveUserFromFinished); taskService.deleteTasksByOnlyCreator.mockResolvedValueOnce(tasksDeleted); + newsService.deleteCreatorOrUpdaterReference.mockResolvedValueOnce(newsUpdated); return { deletionRequestToExecute, @@ -534,6 +543,16 @@ describe(DeletionRequestUc.name, () => { expect(taskService.removeUserFromFinished).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); }); + it('should call newsService.deleteCreatorOrUpdaterReference to update News without creatorId', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(newsService.deleteCreatorOrUpdaterReference).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { const { deletionRequestToExecute } = setup(); @@ -541,7 +560,7 @@ describe(DeletionRequestUc.name, () => { await uc.executeDeletionRequests(); - expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(12); + expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(13); }); }); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts index 84678afc6bb..7535e30deaa 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -16,6 +16,7 @@ import { FilesStorageClientAdapterService } from '@modules/files-storage-client' import { TaskService } from '@modules/task'; import { DomainOperation } from '@shared/domain/interface'; import { DomainOperationBuilder } from '@shared/domain/builder/domain-operation.builder'; +import { NewsService } from '@src/modules/news/service/news.service'; import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; import { DeletionRequestBodyProps, DeletionRequestLogResponse, DeletionRequestResponse } from '../controller/dto'; import { DeletionRequest, DeletionLog } from '../domain'; @@ -41,7 +42,8 @@ export class DeletionRequestUc { private readonly registrationPinService: RegistrationPinService, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, private readonly dashboardService: DashboardService, - private readonly taskService: TaskService + private readonly taskService: TaskService, + private readonly newsService: NewsService ) { this.logger.setContext(DeletionRequestUc.name); } @@ -111,6 +113,7 @@ export class DeletionRequestUc { this.removeUserFromRocketChat(deletionRequest), this.removeUsersDashboard(deletionRequest), this.removeUserFromTasks(deletionRequest), + this.removeUsersDataFromNews(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -370,4 +373,17 @@ export class DeletionRequestUc { modifiedTasksRef ); } + + private async removeUsersDataFromNews(deletionRequest: DeletionRequest) { + this.logger.debug({ action: 'removeUsersDataFromNews', deletionRequest }); + const newsModified = await this.newsService.deleteCreatorOrUpdaterReference(deletionRequest.targetRefId); + + await this.logDeletion( + deletionRequest, + newsModified.domain, + newsModified.operation, + newsModified.count, + newsModified.refs + ); + } } diff --git a/apps/server/src/modules/news/controller/dto/news.response.ts b/apps/server/src/modules/news/controller/dto/news.response.ts index 766f40e19c2..0d9fe9e13b2 100644 --- a/apps/server/src/modules/news/controller/dto/news.response.ts +++ b/apps/server/src/modules/news/controller/dto/news.response.ts @@ -103,7 +103,7 @@ export class NewsResponse { @ApiProperty({ description: 'Reference to the User that created the News entity', }) - creator: UserInfoResponse; + creator?: UserInfoResponse; @ApiPropertyOptional({ description: 'Reference to the User that updated the News entity', diff --git a/apps/server/src/modules/news/mapper/news.mapper.ts b/apps/server/src/modules/news/mapper/news.mapper.ts index 30bf13e6540..f410e1183fe 100644 --- a/apps/server/src/modules/news/mapper/news.mapper.ts +++ b/apps/server/src/modules/news/mapper/news.mapper.ts @@ -10,7 +10,6 @@ export class NewsMapper { static mapToResponse(news: News): NewsResponse { const target = TargetInfoMapper.mapToResponse(news.target); const school = SchoolInfoMapper.mapToResponse(news.school); - const creator = UserInfoMapper.mapToResponse(news.creator); const dto = new NewsResponse({ id: news.id, @@ -23,12 +22,14 @@ export class NewsMapper { targetModel: news.targetModel, target, school, - creator, createdAt: news.createdAt, updatedAt: news.updatedAt, permissions: news.permissions, }); + if (news.creator) { + dto.creator = UserInfoMapper.mapToResponse(news.creator); + } if (news.updater) { dto.updater = UserInfoMapper.mapToResponse(news.updater); } diff --git a/apps/server/src/modules/news/news.module.ts b/apps/server/src/modules/news/news.module.ts index 3766b26052e..d57868e580b 100644 --- a/apps/server/src/modules/news/news.module.ts +++ b/apps/server/src/modules/news/news.module.ts @@ -5,11 +5,12 @@ import { AuthorizationModule } from '@modules/authorization'; import { NewsController } from './controller/news.controller'; import { TeamNewsController } from './controller/team-news.controller'; import { NewsUc } from './uc/news.uc'; +import { NewsService } from './service/news.service'; @Module({ imports: [AuthorizationModule, LoggerModule], controllers: [NewsController, TeamNewsController], - providers: [NewsUc, NewsRepo], - exports: [NewsUc], + providers: [NewsUc, NewsRepo, NewsService], + exports: [NewsUc, NewsService], }) export class NewsModule {} diff --git a/apps/server/src/modules/news/service/news.service.spec.ts b/apps/server/src/modules/news/service/news.service.spec.ts new file mode 100644 index 00000000000..2c78d4fb87d --- /dev/null +++ b/apps/server/src/modules/news/service/news.service.spec.ts @@ -0,0 +1,151 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities, teamNewsFactory, userFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { NewsRepo } from '@shared/repo'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { DomainName, OperationType } from '@shared/domain/types'; +import { NewsService } from './news.service'; + +describe(NewsService.name, () => { + let module: TestingModule; + let service: NewsService; + let repo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + NewsService, + { + provide: NewsRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(NewsService); + repo = module.get(NewsRepo); + + await setupEntities(); + }); + + afterEach(() => { + repo.findByCreatorOrUpdaterId.mockClear(); + repo.save.mockClear(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('deleteCreatorReference', () => { + const setup = () => { + const user1 = userFactory.build(); + const user2 = userFactory.build(); + const anotherUserId = new ObjectId().toHexString(); + + const news1 = teamNewsFactory.buildWithId({ + creator: user1, + }); + const news2 = teamNewsFactory.buildWithId({ + updater: user2, + }); + const news3 = teamNewsFactory.buildWithId({ + creator: user1, + updater: user2, + }); + + const expectedResultWithDeletedCreator = DomainOperationBuilder.build(DomainName.NEWS, OperationType.UPDATE, 2, [ + news1.id, + news3.id, + ]); + + const expectedResultWithDeletedUpdater = DomainOperationBuilder.build(DomainName.NEWS, OperationType.UPDATE, 2, [ + news2.id, + news3.id, + ]); + + const expectedResultWithoutUpdatedNews = DomainOperationBuilder.build( + DomainName.NEWS, + OperationType.UPDATE, + 0, + [] + ); + + return { + anotherUserId, + expectedResultWithDeletedCreator, + expectedResultWithDeletedUpdater, + expectedResultWithoutUpdatedNews, + user1, + user2, + news1, + news2, + news3, + }; + }; + + describe('when user is creator of news', () => { + it('it should be removed from news', async () => { + const { user1, news1, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news1, news3], 2]); + + await service.deleteCreatorOrUpdaterReference(user1.id); + + expect(news1.creator).toBeUndefined(); + expect(news3.creator).toBeUndefined(); + }); + + it('it should return response for 2 news updated', async () => { + const { expectedResultWithDeletedCreator, user1, news1, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news1, news3], 2]); + + const result = await service.deleteCreatorOrUpdaterReference(user1.id); + + expect(result).toEqual(expectedResultWithDeletedCreator); + }); + }); + + describe('when user is updater of news', () => { + it('user should be removed from updater', async () => { + const { user2, news2, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news2, news3], 2]); + + await service.deleteCreatorOrUpdaterReference(user2.id); + + expect(news2.updater).toBeUndefined(); + expect(news3.updater).toBeUndefined(); + }); + + it('it should return response for 2 news updated', async () => { + const { expectedResultWithDeletedUpdater, user2, news2, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news2, news3], 2]); + + const result = await service.deleteCreatorOrUpdaterReference(user2.id); + + expect(result).toEqual(expectedResultWithDeletedUpdater); + }); + }); + + describe('when user is neither creator nor updater', () => { + it('should return response with 0 updated news', async () => { + const { anotherUserId, expectedResultWithoutUpdatedNews } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[], 0]); + + const result = await service.deleteCreatorOrUpdaterReference(anotherUserId); + + expect(result).toEqual(expectedResultWithoutUpdatedNews); + }); + }); + }); +}); diff --git a/apps/server/src/modules/news/service/news.service.ts b/apps/server/src/modules/news/service/news.service.ts new file mode 100644 index 00000000000..5a2482886d5 --- /dev/null +++ b/apps/server/src/modules/news/service/news.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { DomainName, EntityId, OperationType, StatusModel } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; +import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { NewsRepo } from '@shared/repo'; +import { DomainOperationBuilder } from '@shared/domain/builder'; +import { DomainOperation } from '@shared/domain/interface'; +import { News } from '@shared/domain/entity'; + +@Injectable() +export class NewsService { + constructor(private readonly newsRepo: NewsRepo, private readonly logger: Logger) { + this.logger.setContext(NewsService.name); + } + + public async deleteCreatorOrUpdaterReference(userId: EntityId): Promise { + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Deleting user data from News', + DomainName.NEWS, + userId, + StatusModel.PENDING + ) + ); + + const [newsWithUserData, counterOfNews] = await this.newsRepo.findByCreatorOrUpdaterId(userId); + + newsWithUserData.forEach((newsEntity) => { + newsEntity.removeCreatorReference(userId); + newsEntity.removeUpdaterReference(userId); + }); + + await this.newsRepo.save(newsWithUserData); + + const result = DomainOperationBuilder.build( + DomainName.NEWS, + OperationType.UPDATE, + counterOfNews, + this.getNewsId(newsWithUserData) + ); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Successfully removed user data from News', + DomainName.NEWS, + userId, + StatusModel.FINISHED, + counterOfNews, + 0 + ) + ); + return result; + } + + private getNewsId(news: News[]): EntityId[] { + return news.map((item) => item.id); + } +} diff --git a/apps/server/src/shared/domain/entity/news.entity.spec.ts b/apps/server/src/shared/domain/entity/news.entity.spec.ts new file mode 100644 index 00000000000..36ced085831 --- /dev/null +++ b/apps/server/src/shared/domain/entity/news.entity.spec.ts @@ -0,0 +1,47 @@ +import { setupEntities, teamNewsFactory, userFactory } from '@shared/testing'; +import { News } from './news.entity'; + +describe(News.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('removeCreatorReference', () => { + describe('when called on a news that contains some creator with given refId', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const news = teamNewsFactory.build({ + creator, + }); + + return { creator, news }; + }; + it('should properly remove this creator reference', () => { + const { creator, news } = setup(); + + news.removeCreatorReference(creator.id); + + expect(news.creator).toEqual(undefined); + }); + }); + }); + describe('removeUpdaterReference', () => { + describe('when called on a news that contains some creator with given refId', () => { + const setup = () => { + const updater = userFactory.buildWithId(); + const news = teamNewsFactory.build({ + updater, + }); + + return { updater, news }; + }; + it('should properly remove this updater reference', () => { + const { updater, news } = setup(); + + news.removeUpdaterReference(updater.id); + + expect(news.updater).toEqual(undefined); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/news.entity.ts b/apps/server/src/shared/domain/entity/news.entity.ts index 682192770a1..64ca7e7da9d 100644 --- a/apps/server/src/shared/domain/entity/news.entity.ts +++ b/apps/server/src/shared/domain/entity/news.entity.ts @@ -12,7 +12,7 @@ export interface NewsProperties { content: string; displayAt: Date; school: EntityId | SchoolEntity; - creator: EntityId | User; + creator?: EntityId | User; target: EntityId | NewsTarget; externalId?: string; @@ -61,14 +61,26 @@ export abstract class News extends BaseEntityWithTimestamps { @ManyToOne(() => SchoolEntity, { fieldName: 'schoolId' }) school!: SchoolEntity; - @ManyToOne('User', { fieldName: 'creatorId' }) - creator!: User; + @ManyToOne('User', { fieldName: 'creatorId', nullable: true }) + creator?: User; @ManyToOne('User', { fieldName: 'updaterId', nullable: true }) updater?: User; permissions: string[] = []; + public removeCreatorReference(creatorId: EntityId): void { + if (creatorId === this.creator?.id) { + this.creator = undefined; + } + } + + public removeUpdaterReference(updaterId: EntityId): void { + if (updaterId === this.updater?.id) { + this.updater = undefined; + } + } + constructor(props: NewsProperties) { super(); this.title = props.title; diff --git a/apps/server/src/shared/domain/types/domain-name.enum.ts b/apps/server/src/shared/domain/types/domain-name.enum.ts index 3d3db093e38..320117b5b3c 100644 --- a/apps/server/src/shared/domain/types/domain-name.enum.ts +++ b/apps/server/src/shared/domain/types/domain-name.enum.ts @@ -14,4 +14,5 @@ export const enum DomainName { TASK = 'task', TEAMS = 'teams', USER = 'user', + NEWS = 'news', } diff --git a/apps/server/src/shared/repo/news/news-scope.ts b/apps/server/src/shared/repo/news/news-scope.ts index 7f999205fa3..714b1168f28 100644 --- a/apps/server/src/shared/repo/news/news-scope.ts +++ b/apps/server/src/shared/repo/news/news-scope.ts @@ -41,4 +41,11 @@ export class NewsScope extends Scope { } return this; } + + byUpdater(updaterId: EntityId): NewsScope { + if (updaterId !== undefined) { + this.addQuery({ updater: updaterId }); + } + return this; + } } diff --git a/apps/server/src/shared/repo/news/news.repo.integration.spec.ts b/apps/server/src/shared/repo/news/news.repo.integration.spec.ts index 41df6d26b7c..25240d5a1c6 100644 --- a/apps/server/src/shared/repo/news/news.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/news/news.repo.integration.spec.ts @@ -223,8 +223,9 @@ describe('NewsRepo', () => { targetIds: [news.target.id], }; const pagination = { skip: 0, limit: 20 }; + const creatorId = news.creator?.id as string; - const [result, count] = await repo.findAllUnpublishedByUser([target], news.creator.id, { pagination }); + const [result, count] = await repo.findAllUnpublishedByUser([target], creatorId, { pagination }); expect(count).toBeGreaterThanOrEqual(result.length); expect(result.length).toEqual(1); @@ -241,7 +242,8 @@ describe('NewsRepo', () => { targetModel: NewsTargetModel.School, targetIds: [news.target.id], }; - const [result, count] = await repo.findAllUnpublishedByUser([target], news.creator.id, { pagination }); + const creatorId = news.creator?.id as string; + const [result, count] = await repo.findAllUnpublishedByUser([target], creatorId, { pagination }); expect(count).toBeGreaterThanOrEqual(result.length); expect(result.length).toEqual(1); expect(result[0].id).toEqual(news.id); @@ -256,8 +258,9 @@ describe('NewsRepo', () => { targetModel: NewsTargetModel.Course, targetIds: [news.target.id], }; + const creatorId = news.creator?.id as string; const pagination = { skip: 0, limit: 20 }; - const [result, count] = await repo.findAllUnpublishedByUser([target], news.creator.id, { pagination }); + const [result, count] = await repo.findAllUnpublishedByUser([target], creatorId, { pagination }); expect(count).toBeGreaterThanOrEqual(result.length); expect(result.length).toEqual(1); expect(result[0].id).toEqual(news.id); @@ -301,4 +304,49 @@ describe('NewsRepo', () => { await expect(repo.findOneById(failNewsId)).rejects.toThrow(NotFoundError); }); }); + + describe('findByCreatorOrUpdaterId', () => { + const setup = async () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const news1 = teamNewsFactory.build({ + creator: user1, + }); + const news2 = teamNewsFactory.build({ + updater: user2, + }); + const news3 = teamNewsFactory.build({ + updater: user1, + }); + + await em.persistAndFlush([news1, news2, news3]); + em.clear(); + + return { news1, news2, news3, user1, user2 }; + }; + it('should find a news entity by creatorId and updaterId', async () => { + const { news1, user1, news3 } = await setup(); + + const result = await repo.findByCreatorOrUpdaterId(user1.id); + expect(result).toBeDefined(); + expect(result[0][0].id).toEqual(news1.id); + expect(result[0][1].id).toEqual(news3.id); + expect(result[0].length).toEqual(2); + }); + + it('should find a news entity by updaterId', async () => { + const { user2, news2 } = await setup(); + + const result = await repo.findByCreatorOrUpdaterId(user2.id); + expect(result).toBeDefined(); + expect(result[0][0].id).toEqual(news2.id); + expect(result[0].length).toEqual(1); + }); + + it('should throw an exception if not found', async () => { + const failNewsId = new ObjectId().toString(); + const result = await repo.findByCreatorOrUpdaterId(failNewsId); + expect(result[1]).toEqual(0); + }); + }); }); diff --git a/apps/server/src/shared/repo/news/news.repo.ts b/apps/server/src/shared/repo/news/news.repo.ts index d4cc86cc8f8..7c3e27c176e 100644 --- a/apps/server/src/shared/repo/news/news.repo.ts +++ b/apps/server/src/shared/repo/news/news.repo.ts @@ -74,4 +74,13 @@ export class NewsRepo extends BaseRepo { await this._em.populate(courseNews, [discriminatorColumn]); return [newsEntities, count]; } + + async findByCreatorOrUpdaterId(userId: EntityId): Promise> { + const scope = new NewsScope('$or'); + scope.byCreator(userId); + scope.byUpdater(userId); + + const countedNewsList = await this.findNewsAndCount(scope.query); + return countedNewsList; + } }