From d976c86b95102c97780fa958520c0a9b8c3ab973 Mon Sep 17 00:00:00 2001 From: Yunus Date: Thu, 28 May 2026 19:23:35 +0100 Subject: [PATCH] feat: implement evidence moderator flags and isHidden logic --- package-lock.json | 16 +-- src/claims/entities/evidence-flag.entity.ts | 3 + src/claims/entities/evidence.entity.ts | 3 + src/claims/evidence-flag.service.spec.ts | 125 ++++++++++++++++++++ src/claims/evidence-flag.service.ts | 20 +++- src/claims/evidence.service.spec.ts | 123 +++++++++++++++++++ src/claims/evidence.service.ts | 37 ++++-- 7 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 src/claims/evidence-flag.service.spec.ts create mode 100644 src/claims/evidence.service.spec.ts diff --git a/package-lock.json b/package-lock.json index 19f9494..42b042c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3715,7 +3715,7 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3757,7 +3757,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3774,7 +3773,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3791,7 +3789,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3808,7 +3805,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3825,7 +3821,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3842,7 +3837,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3859,7 +3853,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3876,7 +3869,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3893,7 +3885,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3910,7 +3901,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3924,14 +3914,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" diff --git a/src/claims/entities/evidence-flag.entity.ts b/src/claims/entities/evidence-flag.entity.ts index 3cfe37f..9258435 100644 --- a/src/claims/entities/evidence-flag.entity.ts +++ b/src/claims/entities/evidence-flag.entity.ts @@ -30,6 +30,9 @@ export class EvidenceFlag { @Column({ nullable: true }) flaggedBy?: string; + @Column({ default: false }) + isModerator: boolean; + @CreateDateColumn() createdAt: Date; } diff --git a/src/claims/entities/evidence.entity.ts b/src/claims/entities/evidence.entity.ts index f265c33..fe6079a 100644 --- a/src/claims/entities/evidence.entity.ts +++ b/src/claims/entities/evidence.entity.ts @@ -27,6 +27,9 @@ export class Evidence { @Column({ default: 1 }) latestVersion: number; + @Column({ default: false }) + isHidden: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/src/claims/evidence-flag.service.spec.ts b/src/claims/evidence-flag.service.spec.ts new file mode 100644 index 0000000..c550fb8 --- /dev/null +++ b/src/claims/evidence-flag.service.spec.ts @@ -0,0 +1,125 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EvidenceFlagService } from './evidence-flag.service'; +import { EvidenceFlag } from './entities/evidence-flag.entity'; +import { Evidence } from './entities/evidence.entity'; +import { NotFoundException } from '@nestjs/common'; + +describe('EvidenceFlagService', () => { + let service: EvidenceFlagService; + let flagRepo: Repository; + let evidenceRepo: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EvidenceFlagService, + { + provide: getRepositoryToken(EvidenceFlag), + useClass: Repository, + }, + { + provide: getRepositoryToken(Evidence), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(EvidenceFlagService); + flagRepo = module.get>(getRepositoryToken(EvidenceFlag)); + evidenceRepo = module.get>(getRepositoryToken(Evidence)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createFlag', () => { + const evidenceId = 'evidence-1'; + const reason = 'spam'; + const flaggedBy = 'user-1'; + + it('should throw NotFoundException if evidence does not exist', async () => { + jest.spyOn(evidenceRepo, 'findOneBy').mockResolvedValue(null); + + await expect(service.createFlag(evidenceId, reason)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should create a flag and not hide evidence if isModerator is false', async () => { + const evidence = { id: evidenceId, isHidden: false } as Evidence; + const flag = { + id: 'flag-1', + evidenceId, + reason, + flaggedBy, + isModerator: false, + } as any; + + jest.spyOn(evidenceRepo, 'findOneBy').mockResolvedValue(evidence); + jest.spyOn(flagRepo, 'create').mockReturnValue(flag); + jest.spyOn(flagRepo, 'save').mockResolvedValue(flag); + const evidenceSaveSpy = jest.spyOn(evidenceRepo, 'save').mockResolvedValue(evidence); + + const result = await service.createFlag(evidenceId, reason, flaggedBy, false); + + expect(result).toEqual(flag); + expect(evidenceSaveSpy).not.toHaveBeenCalled(); + expect(flagRepo.create).toHaveBeenCalledWith({ + evidenceId, + reason, + flaggedBy, + }); + expect(result.isModerator).toBe(false); + }); + + it('should create a flag and hide evidence if isModerator is true', async () => { + const evidence = { id: evidenceId, isHidden: false } as Evidence; + const flag = { + id: 'flag-1', + evidenceId, + reason, + flaggedBy, + isModerator: true, + } as any; + + jest.spyOn(evidenceRepo, 'findOneBy').mockResolvedValue(evidence); + jest.spyOn(flagRepo, 'create').mockReturnValue(flag); + jest.spyOn(flagRepo, 'save').mockResolvedValue(flag); + const evidenceSaveSpy = jest.spyOn(evidenceRepo, 'save').mockResolvedValue({ + ...evidence, + isHidden: true, + } as Evidence); + + const result = await service.createFlag(evidenceId, reason, flaggedBy, true); + + expect(result).toEqual(flag); + expect(evidenceSaveSpy).toHaveBeenCalledWith(expect.objectContaining({ isHidden: true })); + expect(flagRepo.create).toHaveBeenCalledWith({ + evidenceId, + reason, + flaggedBy, + }); + expect(result.isModerator).toBe(true); + }); + }); + + describe('getFlagsForEvidence', () => { + it('should return flags for an evidence', async () => { + const evidenceId = 'evidence-1'; + const flags = [{ id: 'flag-1', evidenceId }] as EvidenceFlag[]; + + jest.spyOn(flagRepo, 'find').mockResolvedValue(flags); + + const result = await service.getFlagsForEvidence(evidenceId); + + expect(result).toEqual(flags); + expect(flagRepo.find).toHaveBeenCalledWith({ + where: { evidenceId }, + order: { createdAt: 'ASC' }, + }); + }); + }); +}); diff --git a/src/claims/evidence-flag.service.ts b/src/claims/evidence-flag.service.ts index 4038ce9..09dfe33 100644 --- a/src/claims/evidence-flag.service.ts +++ b/src/claims/evidence-flag.service.ts @@ -13,13 +13,29 @@ export class EvidenceFlagService { private readonly evidenceRepo: Repository, ) {} - async createFlag(evidenceId: string, reason: string, flaggedBy?: string): Promise { + async createFlag( + evidenceId: string, + reason: string, + flaggedBy?: string, + isModerator: boolean = false, + ): Promise { const evidence = await this.evidenceRepo.findOneBy({ id: evidenceId }); if (!evidence) { throw new NotFoundException(`Evidence with ID ${evidenceId} not found`); } - const flag = this.flagRepo.create({ evidenceId, reason, flaggedBy }); + // If a moderator flags evidence, we automatically hide it + if (isModerator) { + evidence.isHidden = true; + await this.evidenceRepo.save(evidence); + } + + const flag = this.flagRepo.create({ + evidenceId, + reason, + flaggedBy, + }); + (flag as any).isModerator = isModerator; return this.flagRepo.save(flag); } diff --git a/src/claims/evidence.service.spec.ts b/src/claims/evidence.service.spec.ts new file mode 100644 index 0000000..f347b15 --- /dev/null +++ b/src/claims/evidence.service.spec.ts @@ -0,0 +1,123 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EvidenceService } from './evidence.service'; +import { Evidence } from './entities/evidence.entity'; +import { EvidenceVersion } from './entities/evidence-version.entity'; +import { AuditTrailService } from '../audit/services/audit-trail.service'; + +describe('EvidenceService', () => { + let service: EvidenceService; + let evidenceRepo: Repository; + let evidenceVersionRepo: Repository; + let auditTrailService: AuditTrailService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EvidenceService, + { + provide: getRepositoryToken(Evidence), + useClass: Repository, + }, + { + provide: getRepositoryToken(EvidenceVersion), + useClass: Repository, + }, + { + provide: AuditTrailService, + useValue: { + log: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(EvidenceService); + evidenceRepo = module.get>(getRepositoryToken(Evidence)); + evidenceVersionRepo = module.get>(getRepositoryToken(EvidenceVersion)); + auditTrailService = module.get(AuditTrailService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getEvidence', () => { + it('should return evidence if not hidden', async () => { + const evidence = { id: 'ev-1', isHidden: false } as Evidence; + jest.spyOn(evidenceRepo, 'findOne').mockResolvedValue(evidence); + + const result = await service.getEvidence('ev-1'); + + expect(result).toEqual(evidence); + expect(evidenceRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ev-1', isHidden: false }, + }), + ); + }); + + it('should return null if hidden and includeHidden is false', async () => { + jest.spyOn(evidenceRepo, 'findOne').mockResolvedValue(null); + + const result = await service.getEvidence('ev-1'); + + expect(result).toBeNull(); + expect(evidenceRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ev-1', isHidden: false }, + }), + ); + }); + + it('should return evidence if hidden and includeHidden is true', async () => { + const evidence = { id: 'ev-1', isHidden: true } as Evidence; + jest.spyOn(evidenceRepo, 'findOne').mockResolvedValue(evidence); + + const result = await service.getEvidence('ev-1', true); + + expect(result).toEqual(evidence); + expect(evidenceRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ev-1' }, + }), + ); + }); + }); + + describe('getEvidenceForClaim', () => { + it('should filter out hidden evidence by default', async () => { + const claimId = 'claim-1'; + const evidences = [{ id: 'ev-1', isHidden: false }] as Evidence[]; + jest.spyOn(evidenceRepo, 'find').mockResolvedValue(evidences); + + const result = await service.getEvidenceForClaim(claimId); + + expect(result).toEqual(evidences); + expect(evidenceRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { claimId, isHidden: false }, + }), + ); + }); + + it('should include hidden evidence if includeHidden is true', async () => { + const claimId = 'claim-1'; + const evidences = [ + { id: 'ev-1', isHidden: false }, + { id: 'ev-2', isHidden: true }, + ] as Evidence[]; + jest.spyOn(evidenceRepo, 'find').mockResolvedValue(evidences); + + const result = await service.getEvidenceForClaim(claimId, true); + + expect(result).toEqual(evidences); + expect(evidenceRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { claimId }, + }), + ); + }); + }); +}); diff --git a/src/claims/evidence.service.ts b/src/claims/evidence.service.ts index db05034..ecfe06d 100644 --- a/src/claims/evidence.service.ts +++ b/src/claims/evidence.service.ts @@ -98,9 +98,14 @@ export class EvidenceService { /** * Get evidence with all versions */ - async getEvidence(evidenceId: string): Promise { + async getEvidence(evidenceId: string, includeHidden: boolean = false): Promise { + const where: any = { id: evidenceId }; + if (!includeHidden) { + where.isHidden = false; + } + return this.evidenceRepository.findOne({ - where: { id: evidenceId }, + where, relations: ['versions'], order: { versions: { version: 'ASC' } }, }); @@ -109,8 +114,16 @@ export class EvidenceService { /** * Get latest version of evidence */ - async getLatestEvidenceVersion(evidenceId: string): Promise { - const evidence = await this.evidenceRepository.findOneBy({ id: evidenceId }); + async getLatestEvidenceVersion( + evidenceId: string, + includeHidden: boolean = false, + ): Promise { + const where: any = { id: evidenceId }; + if (!includeHidden) { + where.isHidden = false; + } + + const evidence = await this.evidenceRepository.findOneBy(where); if (!evidence) { return null; } @@ -123,9 +136,14 @@ export class EvidenceService { /** * Get all evidence for a claim */ - async getEvidenceForClaim(claimId: string): Promise { + async getEvidenceForClaim(claimId: string, includeHidden: boolean = false): Promise { + const where: any = { claimId }; + if (!includeHidden) { + where.isHidden = false; + } + return this.evidenceRepository.find({ - where: { claimId }, + where, relations: ['versions'], order: { createdAt: 'ASC', versions: { version: 'ASC' } }, }); @@ -134,8 +152,11 @@ export class EvidenceService { /** * Get latest evidence version for a claim (assuming one evidence per claim for simplicity) */ - async getLatestEvidenceForClaim(claimId: string): Promise { - const evidences = await this.getEvidenceForClaim(claimId); + async getLatestEvidenceForClaim( + claimId: string, + includeHidden: boolean = false, + ): Promise { + const evidences = await this.getEvidenceForClaim(claimId, includeHidden); if (evidences.length === 0) { return null; }