From 79c657b6f8aab6b4cc2c086b8d9f9f32822c1896 Mon Sep 17 00:00:00 2001 From: Meshmulla Date: Sat, 21 Feb 2026 11:02:32 +0100 Subject: [PATCH 1/2] Medical Record Management --- .../dto/create-medical-record.dto.ts | 8 +- .../dto/update-medical-record.dto.ts | 7 +- .../medical-records/dto/verify-record.dto.ts | 21 + .../entities/medical-record.entity.ts | 52 +- .../entities/record-version.entity.ts | 37 ++ .../medical-records-export.service.ts | 37 +- .../medical-records.controller.spec.ts | 246 ++++++++ .../medical-records.controller.ts | 60 +- .../medical-records/medical-records.module.ts | 7 +- .../medical-records.service.spec.ts | 555 ++++++++++++++++++ .../medical-records.service.ts | 281 ++++++++- 11 files changed, 1277 insertions(+), 34 deletions(-) create mode 100644 backend/src/modules/medical-records/dto/verify-record.dto.ts create mode 100644 backend/src/modules/medical-records/entities/record-version.entity.ts create mode 100644 backend/src/modules/medical-records/medical-records.controller.spec.ts create mode 100644 backend/src/modules/medical-records/medical-records.service.spec.ts diff --git a/backend/src/modules/medical-records/dto/create-medical-record.dto.ts b/backend/src/modules/medical-records/dto/create-medical-record.dto.ts index c2a394ea..24b91ca7 100644 --- a/backend/src/modules/medical-records/dto/create-medical-record.dto.ts +++ b/backend/src/modules/medical-records/dto/create-medical-record.dto.ts @@ -6,7 +6,7 @@ import { IsUUID, IsArray, } from 'class-validator'; -import { RecordType } from '../entities/medical-record.entity'; +import { RecordType, AccessLevel } from '../entities/medical-record.entity'; export class CreateMedicalRecordDto { @IsUUID() @@ -20,7 +20,7 @@ export class CreateMedicalRecordDto { recordType: RecordType; @IsDateString() - date: string; + visitDate: string; @IsString() diagnosis: string; @@ -36,4 +36,8 @@ export class CreateMedicalRecordDto { @IsArray() @IsString({ each: true }) attachments?: string[]; + + @IsOptional() + @IsEnum(AccessLevel) + accessLevel?: AccessLevel; } diff --git a/backend/src/modules/medical-records/dto/update-medical-record.dto.ts b/backend/src/modules/medical-records/dto/update-medical-record.dto.ts index 03bd939e..ce29614a 100644 --- a/backend/src/modules/medical-records/dto/update-medical-record.dto.ts +++ b/backend/src/modules/medical-records/dto/update-medical-record.dto.ts @@ -1,6 +1,11 @@ import { PartialType } from '@nestjs/mapped-types'; +import { IsOptional, IsString } from 'class-validator'; import { CreateMedicalRecordDto } from './create-medical-record.dto'; export class UpdateMedicalRecordDto extends PartialType( CreateMedicalRecordDto, -) {} +) { + @IsOptional() + @IsString() + changeReason?: string; +} diff --git a/backend/src/modules/medical-records/dto/verify-record.dto.ts b/backend/src/modules/medical-records/dto/verify-record.dto.ts new file mode 100644 index 00000000..a8905f1c --- /dev/null +++ b/backend/src/modules/medical-records/dto/verify-record.dto.ts @@ -0,0 +1,21 @@ +import { IsUUID, IsString, IsOptional } from 'class-validator'; + +export class VerifyRecordDto { + @IsUUID() + vetId: string; + + @IsString() + digitalSignature: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class RevokeVerificationDto { + @IsUUID() + vetId: string; + + @IsString() + reason: string; +} diff --git a/backend/src/modules/medical-records/entities/medical-record.entity.ts b/backend/src/modules/medical-records/entities/medical-record.entity.ts index 2eb2d31c..3ad5f1b0 100644 --- a/backend/src/modules/medical-records/entities/medical-record.entity.ts +++ b/backend/src/modules/medical-records/entities/medical-record.entity.ts @@ -7,6 +7,7 @@ import { DeleteDateColumn, ManyToOne, JoinColumn, + VersionColumn, } from 'typeorm'; import { Pet } from '../../pets/entities/pet.entity'; import { Vet } from '../../vets/entities/vet.entity'; @@ -16,9 +17,21 @@ export enum RecordType { SURGERY = 'surgery', EMERGENCY = 'emergency', DIAGNOSTIC = 'diagnostic', + VACCINATION = 'vaccination', + DENTAL = 'dental', + LABORATORY = 'laboratory', + IMAGING = 'imaging', + PRESCRIPTION = 'prescription', + FOLLOW_UP = 'follow_up', OTHER = 'other', } +export enum AccessLevel { + PUBLIC = 'public', + RESTRICTED = 'restricted', + CONFIDENTIAL = 'confidential', +} + @Entity('medical_records') export class MedicalRecord { @PrimaryGeneratedColumn('uuid') @@ -44,8 +57,8 @@ export class MedicalRecord { }) recordType: RecordType; - @Column({ type: 'date' }) - date: Date; + @Column({ type: 'date', name: 'visit_date' }) + visitDate: Date; @Column({ type: 'text' }) diagnosis: string; @@ -62,6 +75,41 @@ export class MedicalRecord { @Column({ nullable: true }) qrCode: string; + // --- Vet Verification / Signature --- + @Column({ type: 'boolean', default: false }) + verified: boolean; + + @Column({ type: 'timestamp', nullable: true }) + verifiedAt: Date; + + @Column({ type: 'uuid', nullable: true }) + verifiedByVetId: string; + + @ManyToOne(() => Vet, { nullable: true }) + @JoinColumn({ name: 'verifiedByVetId' }) + verifiedByVet: Vet; + + @Column({ type: 'text', nullable: true }) + digitalSignature: string; + + // --- Record Versioning --- + @VersionColumn() + version: number; + + @Column({ type: 'uuid', nullable: true }) + previousVersionId: string; + + // --- HIPAA Compliance --- + @Column({ + type: 'enum', + enum: AccessLevel, + default: AccessLevel.RESTRICTED, + }) + accessLevel: AccessLevel; + + @Column({ nullable: true }) + encryptionKeyId: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/modules/medical-records/entities/record-version.entity.ts b/backend/src/modules/medical-records/entities/record-version.entity.ts new file mode 100644 index 00000000..3e890e98 --- /dev/null +++ b/backend/src/modules/medical-records/entities/record-version.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { MedicalRecord } from './medical-record.entity'; + +@Entity('medical_record_versions') +export class RecordVersion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + recordId: string; + + @ManyToOne(() => MedicalRecord) + @JoinColumn({ name: 'recordId' }) + record: MedicalRecord; + + @Column({ type: 'int' }) + version: number; + + @Column({ type: 'jsonb' }) + snapshot: Record; + + @Column({ type: 'uuid', nullable: true }) + changedBy: string; + + @Column({ type: 'text', nullable: true }) + changeReason: string; + + @CreateDateColumn() + changedAt: Date; +} diff --git a/backend/src/modules/medical-records/medical-records-export.service.ts b/backend/src/modules/medical-records/medical-records-export.service.ts index 81b0bc68..dc06e7f6 100644 --- a/backend/src/modules/medical-records/medical-records-export.service.ts +++ b/backend/src/modules/medical-records/medical-records-export.service.ts @@ -127,7 +127,7 @@ export class MedicalRecordsExportService { .text(`Record ${i + 1}: ${r.recordType}`, { underline: true }); doc.moveDown(0.5); doc.fontSize(10); - doc.text(`Date: ${new Date(r.date).toLocaleDateString()}`); + doc.text(`Visit Date: ${new Date(r.visitDate).toLocaleDateString()}`); doc.text(`Pet: ${(r.pet as { name?: string })?.name ?? 'N/A'}`); if (r.vet) { const vet = r.vet as { vetName?: string; clinicName?: string }; @@ -136,6 +136,17 @@ export class MedicalRecordsExportService { doc.text(`Diagnosis: ${r.diagnosis}`); doc.text(`Treatment: ${r.treatment}`); if (r.notes) doc.text(`Notes: ${r.notes}`); + + // Verification status + doc.text(`Verified: ${r.verified ? 'Yes' : 'No'}`); + if (r.verified && r.verifiedAt) { + doc.text(`Verified At: ${new Date(r.verifiedAt).toLocaleDateString()}`); + if (r.verifiedByVet) { + const verifier = r.verifiedByVet as { vetName?: string }; + doc.text(`Verified By: ${verifier.vetName ?? 'N/A'}`); + } + } + if (includeAttachments && r.attachments?.length) { doc.moveDown(0.5).text('Attachments:', { continued: false }); r.attachments.forEach((a) => doc.text(` - ${a}`, { indent: 20 })); @@ -159,10 +170,14 @@ export class MedicalRecordsExportService { vetId: r.vetId ?? '', vetName: (r.vet as { vetName?: string })?.vetName ?? '', recordType: r.recordType, - date: new Date(r.date).toISOString().split('T')[0], + visitDate: new Date(r.visitDate).toISOString().split('T')[0], diagnosis: r.diagnosis, treatment: r.treatment, notes: r.notes ?? '', + verified: r.verified ? 'Yes' : 'No', + verifiedAt: r.verifiedAt + ? new Date(r.verifiedAt).toISOString() + : '', attachments: include && r.attachments?.length ? r.attachments.join('; ') : '', })); @@ -175,10 +190,12 @@ export class MedicalRecordsExportService { 'vetId', 'vetName', 'recordType', - 'date', + 'visitDate', 'diagnosis', 'treatment', 'notes', + 'verified', + 'verifiedAt', ...(include ? ['attachments'] : []), ], }); @@ -204,7 +221,7 @@ export class MedicalRecordsExportService { const docRef = { resourceType: 'DocumentReference', id, - status: 'current', + status: r.verified ? 'current' : 'preliminary', type: { coding: [ { @@ -218,8 +235,14 @@ export class MedicalRecordsExportService { reference: `Patient/${r.petId}`, display: (r.pet as { name?: string })?.name, }, - date: new Date(r.date).toISOString(), + date: new Date(r.visitDate).toISOString(), description: `${r.recordType}: ${r.diagnosis}`, + authenticator: r.verified + ? { + reference: `Practitioner/${r.verifiedByVetId}`, + display: (r.verifiedByVet as { vetName?: string })?.vetName, + } + : undefined, content: [ { attachment: { @@ -233,8 +256,8 @@ export class MedicalRecordsExportService { ], context: { period: { - start: new Date(r.date).toISOString(), - end: new Date(r.date).toISOString(), + start: new Date(r.visitDate).toISOString(), + end: new Date(r.visitDate).toISOString(), }, facilityType: (r.vet as { clinicName?: string })?.clinicName, practiceSetting: 'veterinary', diff --git a/backend/src/modules/medical-records/medical-records.controller.spec.ts b/backend/src/modules/medical-records/medical-records.controller.spec.ts new file mode 100644 index 00000000..e938666e --- /dev/null +++ b/backend/src/modules/medical-records/medical-records.controller.spec.ts @@ -0,0 +1,246 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MedicalRecordsController } from './medical-records.controller'; +import { MedicalRecordsService } from './medical-records.service'; +import { MedicalRecordsExportService } from './medical-records-export.service'; +import { RecordType } from './entities/medical-record.entity'; +import { PetSpecies } from '../pets/entities/pet.entity'; + +describe('MedicalRecordsController', () => { + let controller: MedicalRecordsController; + + const mockService = { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + verifyRecord: jest.fn(), + revokeVerification: jest.fn(), + getRecordVersions: jest.fn(), + getRecordVersion: jest.fn(), + getQRCode: jest.fn(), + getTemplatesByPetType: jest.fn(), + createTemplate: jest.fn(), + saveAttachment: jest.fn(), + }; + + const mockExportService = { + export: jest.fn(), + sendExportByEmail: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MedicalRecordsController], + providers: [ + { provide: MedicalRecordsService, useValue: mockService }, + { provide: MedicalRecordsExportService, useValue: mockExportService }, + ], + }).compile(); + + controller = module.get(MedicalRecordsController); + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a medical record', async () => { + const dto = { + petId: 'pet-1', + recordType: RecordType.CHECKUP, + visitDate: '2026-02-21', + diagnosis: 'Healthy', + treatment: 'None', + }; + const result = { id: 'rec-1', ...dto }; + mockService.create.mockResolvedValue(result); + + expect(await controller.create(dto)).toEqual(result); + expect(mockService.create).toHaveBeenCalledWith(dto); + }); + + it('should handle file uploads on create', async () => { + const dto = { + petId: 'pet-1', + recordType: RecordType.CHECKUP, + visitDate: '2026-02-21', + diagnosis: 'Healthy', + treatment: 'None', + }; + const files = [ + { originalname: 'xray.jpg', mimetype: 'image/jpeg', size: 100 }, + ] as Express.Multer.File[]; + mockService.saveAttachment.mockResolvedValue('uploads/medical-records/uuid-xray.jpg'); + mockService.create.mockResolvedValue({ id: 'rec-1', ...dto, attachments: ['uploads/medical-records/uuid-xray.jpg'] }); + + const result = await controller.create(dto, files); + + expect(mockService.saveAttachment).toHaveBeenCalledWith(files[0]); + expect(result.attachments).toHaveLength(1); + }); + }); + + describe('findAll', () => { + it('should return all records', async () => { + const records = [{ id: 'rec-1' }, { id: 'rec-2' }]; + mockService.findAll.mockResolvedValue(records); + + const result = await controller.findAll(); + + expect(result).toHaveLength(2); + expect(mockService.findAll).toHaveBeenCalledWith( + undefined, undefined, undefined, undefined, + ); + }); + + it('should pass query filters to service', async () => { + mockService.findAll.mockResolvedValue([]); + + await controller.findAll('pet-1', RecordType.SURGERY, '2026-01-01', '2026-12-31'); + + expect(mockService.findAll).toHaveBeenCalledWith( + 'pet-1', RecordType.SURGERY, '2026-01-01', '2026-12-31', + ); + }); + }); + + describe('findOne', () => { + it('should return a single record', async () => { + const record = { id: 'rec-1', diagnosis: 'Healthy' }; + mockService.findOne.mockResolvedValue(record); + + expect(await controller.findOne('rec-1')).toEqual(record); + }); + }); + + describe('update', () => { + it('should update and return the record', async () => { + const updated = { id: 'rec-1', diagnosis: 'Updated' }; + mockService.update.mockResolvedValue(updated); + + const result = await controller.update('rec-1', { diagnosis: 'Updated' }); + + expect(result).toEqual(updated); + expect(mockService.update).toHaveBeenCalledWith('rec-1', { diagnosis: 'Updated' }); + }); + }); + + describe('remove', () => { + it('should remove a record', async () => { + mockService.remove.mockResolvedValue(undefined); + + await controller.remove('rec-1'); + + expect(mockService.remove).toHaveBeenCalledWith('rec-1'); + }); + }); + + // --- Verification --- + + describe('verifyRecord', () => { + it('should verify a record', async () => { + const verifyDto = { vetId: 'vet-1', digitalSignature: 'sig-123' }; + const verified = { id: 'rec-1', verified: true, verifiedByVetId: 'vet-1' }; + mockService.verifyRecord.mockResolvedValue(verified); + + const result = await controller.verifyRecord('rec-1', verifyDto); + + expect(result.verified).toBe(true); + expect(mockService.verifyRecord).toHaveBeenCalledWith('rec-1', verifyDto); + }); + }); + + describe('revokeVerification', () => { + it('should revoke verification', async () => { + const revokeDto = { vetId: 'vet-1', reason: 'Error found' }; + const revoked = { id: 'rec-1', verified: false }; + mockService.revokeVerification.mockResolvedValue(revoked); + + const result = await controller.revokeVerification('rec-1', revokeDto); + + expect(result.verified).toBe(false); + expect(mockService.revokeVerification).toHaveBeenCalledWith('rec-1', revokeDto); + }); + }); + + // --- Versioning --- + + describe('getVersions', () => { + it('should return version history', async () => { + const versions = [ + { id: 'v-2', version: 2 }, + { id: 'v-1', version: 1 }, + ]; + mockService.getRecordVersions.mockResolvedValue(versions); + + const result = await controller.getVersions('rec-1'); + + expect(result).toHaveLength(2); + expect(mockService.getRecordVersions).toHaveBeenCalledWith('rec-1'); + }); + }); + + describe('getVersion', () => { + it('should return a specific version', async () => { + const version = { id: 'v-1', version: 1, snapshot: {} }; + mockService.getRecordVersion.mockResolvedValue(version); + + const result = await controller.getVersion('rec-1', 'v-1'); + + expect(result).toEqual(version); + expect(mockService.getRecordVersion).toHaveBeenCalledWith('rec-1', 'v-1'); + }); + }); + + // --- Templates --- + + describe('getTemplates', () => { + it('should return templates for a pet type', async () => { + const templates = [{ id: 't-1', petType: PetSpecies.DOG }]; + mockService.getTemplatesByPetType.mockResolvedValue(templates); + + const result = await controller.getTemplates(PetSpecies.DOG); + + expect(result).toEqual(templates); + expect(mockService.getTemplatesByPetType).toHaveBeenCalledWith(PetSpecies.DOG); + }); + }); + + describe('createTemplate', () => { + it('should create a new template', async () => { + const created = { + id: 't-1', + petType: PetSpecies.CAT, + recordType: RecordType.CHECKUP, + templateFields: { weight: 'number' }, + }; + mockService.createTemplate.mockResolvedValue(created); + + const result = await controller.createTemplate( + PetSpecies.CAT, + RecordType.CHECKUP, + { weight: 'number' }, + 'Cat checkup', + ); + + expect(result).toEqual(created); + expect(mockService.createTemplate).toHaveBeenCalledWith( + PetSpecies.CAT, + RecordType.CHECKUP, + { weight: 'number' }, + 'Cat checkup', + ); + }); + }); + + // --- QR Code --- + + describe('getQRCode', () => { + it('should return QR code for a record', async () => { + mockService.getQRCode.mockResolvedValue('data:image/png;base64,qr'); + + const result = await controller.getQRCode('rec-1'); + + expect(result).toBe('data:image/png;base64,qr'); + }); + }); +}); diff --git a/backend/src/modules/medical-records/medical-records.controller.ts b/backend/src/modules/medical-records/medical-records.controller.ts index 863fe6f1..f5048a62 100644 --- a/backend/src/modules/medical-records/medical-records.controller.ts +++ b/backend/src/modules/medical-records/medical-records.controller.ts @@ -17,6 +17,7 @@ import { MedicalRecordsService } from './medical-records.service'; import { MedicalRecordsExportService } from './medical-records-export.service'; import { CreateMedicalRecordDto } from './dto/create-medical-record.dto'; import { UpdateMedicalRecordDto } from './dto/update-medical-record.dto'; +import { VerifyRecordDto, RevokeVerificationDto } from './dto/verify-record.dto'; import { ExportMedicalRecordsDto, EmailExportMedicalRecordsDto, @@ -30,7 +31,7 @@ export class MedicalRecordsController { constructor( private readonly medicalRecordsService: MedicalRecordsService, private readonly exportService: MedicalRecordsExportService, - ) {} + ) { } @Post() @UseInterceptors(FilesInterceptor('files', 10)) @@ -69,6 +70,21 @@ export class MedicalRecordsController { return this.medicalRecordsService.getTemplatesByPetType(petType); } + @Post('templates') + createTemplate( + @Body('petType') petType: PetSpecies, + @Body('recordType') recordType: RecordType, + @Body('templateFields') templateFields: Record, + @Body('description') description?: string, + ) { + return this.medicalRecordsService.createTemplate( + petType, + recordType, + templateFields, + description, + ); + } + /** * Export medical records as PDF, CSV, or FHIR. * GET: use query params (format, petId, recordType, startDate, endDate). @@ -86,9 +102,9 @@ export class MedicalRecordsController { ) { const recordIds = recordIdsStr ? recordIdsStr - .split(',') - .map((s) => s.trim()) - .filter(Boolean) + .split(',') + .map((s) => s.trim()) + .filter(Boolean) : undefined; const dto: ExportMedicalRecordsDto = { format, @@ -123,7 +139,6 @@ export class MedicalRecordsController { @Post('export/email') async exportEmail( @Body() dto: EmailExportMedicalRecordsDto, - // In a real app, inject current user and use their email if dto.to is missing @Query('userEmail') userEmail?: string, ) { const recipient = dto.to || userEmail; @@ -135,6 +150,41 @@ export class MedicalRecordsController { return this.exportService.sendExportByEmail(dto, recipient); } + // --- Vet Verification / Signature --- + + @Post(':id/verify') + verifyRecord( + @Param('id') id: string, + @Body() verifyRecordDto: VerifyRecordDto, + ) { + return this.medicalRecordsService.verifyRecord(id, verifyRecordDto); + } + + @Post(':id/revoke-verification') + revokeVerification( + @Param('id') id: string, + @Body() revokeDto: RevokeVerificationDto, + ) { + return this.medicalRecordsService.revokeVerification(id, revokeDto); + } + + // --- Record Versioning --- + + @Get(':id/versions') + getVersions(@Param('id') id: string) { + return this.medicalRecordsService.getRecordVersions(id); + } + + @Get(':id/versions/:versionId') + getVersion( + @Param('id') id: string, + @Param('versionId') versionId: string, + ) { + return this.medicalRecordsService.getRecordVersion(id, versionId); + } + + // --- Core record endpoints --- + @Get(':id') findOne(@Param('id') id: string) { return this.medicalRecordsService.findOne(id); diff --git a/backend/src/modules/medical-records/medical-records.module.ts b/backend/src/modules/medical-records/medical-records.module.ts index 48768951..401f2abe 100644 --- a/backend/src/modules/medical-records/medical-records.module.ts +++ b/backend/src/modules/medical-records/medical-records.module.ts @@ -6,14 +6,17 @@ import { MedicalRecordsController } from './medical-records.controller'; import { MedicalRecordsExportService } from './medical-records-export.service'; import { MedicalRecord } from './entities/medical-record.entity'; import { RecordTemplate } from './entities/record-template.entity'; +import { RecordVersion } from './entities/record-version.entity'; +import { AuditModule } from '../audit/audit.module'; @Module({ imports: [ - TypeOrmModule.forFeature([MedicalRecord, RecordTemplate]), + TypeOrmModule.forFeature([MedicalRecord, RecordTemplate, RecordVersion]), ConfigModule, + AuditModule, ], controllers: [MedicalRecordsController], providers: [MedicalRecordsService, MedicalRecordsExportService], exports: [MedicalRecordsService], }) -export class MedicalRecordsModule {} +export class MedicalRecordsModule { } diff --git a/backend/src/modules/medical-records/medical-records.service.spec.ts b/backend/src/modules/medical-records/medical-records.service.spec.ts new file mode 100644 index 00000000..6dffe0a2 --- /dev/null +++ b/backend/src/modules/medical-records/medical-records.service.spec.ts @@ -0,0 +1,555 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { MedicalRecordsService } from './medical-records.service'; +import { MedicalRecord, RecordType, AccessLevel } from './entities/medical-record.entity'; +import { RecordTemplate } from './entities/record-template.entity'; +import { RecordVersion } from './entities/record-version.entity'; +import { AuditService } from '../audit/audit.service'; +import { PetSpecies } from '../pets/entities/pet.entity'; + +// Mock qrcode module +jest.mock('qrcode', () => ({ + toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,mockqrcode'), +})); + +describe('MedicalRecordsService', () => { + let service: MedicalRecordsService; + + const mockMedicalRecordRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + softRemove: jest.fn(), + }; + + const mockTemplateRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + }; + + const mockVersionRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + }; + + const mockAuditService = { + log: jest.fn().mockResolvedValue({}), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MedicalRecordsService, + { provide: getRepositoryToken(MedicalRecord), useValue: mockMedicalRecordRepo }, + { provide: getRepositoryToken(RecordTemplate), useValue: mockTemplateRepo }, + { provide: getRepositoryToken(RecordVersion), useValue: mockVersionRepo }, + { provide: AuditService, useValue: mockAuditService }, + ], + }).compile(); + + service = module.get(MedicalRecordsService); + jest.clearAllMocks(); + }); + + // --- CREATE --- + + describe('create', () => { + const dto = { + petId: 'pet-1', + vetId: 'vet-1', + recordType: RecordType.CHECKUP, + visitDate: '2026-02-21', + diagnosis: 'Healthy', + treatment: 'None', + notes: 'Annual checkup', + }; + + it('should create a medical record, generate QR, and create version snapshot', async () => { + const created = { + id: 'rec-1', + ...dto, + verified: false, + version: 1, + qrCode: null, + }; + const withQr = { ...created, qrCode: 'data:image/png;base64,mockqrcode' }; + const withRelations = { ...withQr, pet: { name: 'Buddy' }, vet: { vetName: 'Dr. Smith' } }; + + mockMedicalRecordRepo.create.mockReturnValue(created); + mockMedicalRecordRepo.save.mockResolvedValue(created); + // findOne for QR generation, then for version snapshot, then for return + mockMedicalRecordRepo.findOne + .mockResolvedValueOnce(created) // generateQRCode -> findOne + .mockResolvedValueOnce(withQr) // createVersionSnapshot -> findOne + .mockResolvedValueOnce(withRelations); // final findOne + + mockVersionRepo.create.mockReturnValue({ id: 'v-1', recordId: 'rec-1', version: 1 }); + mockVersionRepo.save.mockResolvedValue({ id: 'v-1', recordId: 'rec-1', version: 1 }); + + const result = await service.create(dto, 'user-1'); + + expect(mockMedicalRecordRepo.create).toHaveBeenCalledWith(dto); + expect(mockMedicalRecordRepo.save).toHaveBeenCalled(); + expect(mockVersionRepo.create).toHaveBeenCalled(); + expect(mockAuditService.log).toHaveBeenCalledWith( + 'user-1', 'medical_record', 'rec-1', 'create', + ); + expect(result.pet).toBeDefined(); + }); + + it('should create record without userId (no audit log)', async () => { + const created = { id: 'rec-2', ...dto, verified: false, version: 1, qrCode: null }; + mockMedicalRecordRepo.create.mockReturnValue(created); + mockMedicalRecordRepo.save.mockResolvedValue(created); + mockMedicalRecordRepo.findOne.mockResolvedValue(created); + mockVersionRepo.create.mockReturnValue({ id: 'v-1' }); + mockVersionRepo.save.mockResolvedValue({ id: 'v-1' }); + + await service.create(dto); + + expect(mockAuditService.log).not.toHaveBeenCalled(); + }); + }); + + // --- FIND ALL --- + + describe('findAll', () => { + it('should return records filtered by petId', async () => { + const records = [{ id: 'rec-1', petId: 'pet-1' }]; + mockMedicalRecordRepo.find.mockResolvedValue(records); + + const result = await service.findAll('pet-1'); + + expect(mockMedicalRecordRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { petId: 'pet-1' }, + relations: ['pet', 'vet', 'verifiedByVet'], + order: { visitDate: 'DESC' }, + }), + ); + expect(result).toEqual(records); + }); + + it('should return records filtered by recordType', async () => { + mockMedicalRecordRepo.find.mockResolvedValue([]); + + await service.findAll(undefined, RecordType.SURGERY); + + expect(mockMedicalRecordRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { recordType: RecordType.SURGERY }, + }), + ); + }); + + it('should filter by date range using visitDate', async () => { + mockMedicalRecordRepo.find.mockResolvedValue([]); + + await service.findAll(undefined, undefined, '2026-01-01', '2026-12-31'); + + const call = mockMedicalRecordRepo.find.mock.calls[0][0]; + expect(call.where.visitDate).toBeDefined(); + }); + + it('should return all records when no filters provided', async () => { + const records = [{ id: 'rec-1' }, { id: 'rec-2' }]; + mockMedicalRecordRepo.find.mockResolvedValue(records); + + const result = await service.findAll(); + + expect(result).toHaveLength(2); + }); + }); + + // --- FIND ONE --- + + describe('findOne', () => { + it('should return a record by id', async () => { + const record = { id: 'rec-1', pet: { name: 'Buddy' }, vet: { vetName: 'Dr. Smith' } }; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + + const result = await service.findOne('rec-1'); + + expect(result).toEqual(record); + expect(mockMedicalRecordRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'rec-1' }, + relations: ['pet', 'vet', 'verifiedByVet'], + }); + }); + + it('should throw NotFoundException if record not found', async () => { + mockMedicalRecordRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + // --- FIND BY IDS --- + + describe('findByIds', () => { + it('should return records matching ids', async () => { + const records = [{ id: 'rec-1' }, { id: 'rec-2' }]; + mockMedicalRecordRepo.find.mockResolvedValue(records); + + const result = await service.findByIds(['rec-1', 'rec-2']); + + expect(result).toHaveLength(2); + }); + + it('should return empty array for empty ids', async () => { + const result = await service.findByIds([]); + + expect(result).toEqual([]); + }); + }); + + // --- UPDATE --- + + describe('update', () => { + it('should update a non-verified record and create version snapshot', async () => { + const existing = { + id: 'rec-1', + verified: false, + version: 1, + diagnosis: 'Old diagnosis', + }; + mockMedicalRecordRepo.findOne + .mockResolvedValueOnce(existing) // findOne in update + .mockResolvedValueOnce(existing); // findOne in createVersionSnapshot + mockVersionRepo.create.mockReturnValue({ id: 'v-2', version: 1 }); + mockVersionRepo.save.mockResolvedValue({ id: 'v-2', version: 1 }); + mockMedicalRecordRepo.save.mockResolvedValue({ + ...existing, + diagnosis: 'New diagnosis', + version: 2, + }); + + const result = await service.update('rec-1', { + diagnosis: 'New diagnosis', + changeReason: 'Updated diagnosis after lab results', + }, 'user-1'); + + expect(result.diagnosis).toBe('New diagnosis'); + expect(mockVersionRepo.create).toHaveBeenCalled(); + expect(mockAuditService.log).toHaveBeenCalledWith( + 'user-1', 'medical_record', 'rec-1', 'update', + ); + }); + + it('should throw ForbiddenException when updating a verified record', async () => { + const verified = { id: 'rec-1', verified: true, version: 2 }; + mockMedicalRecordRepo.findOne.mockResolvedValue(verified); + + await expect( + service.update('rec-1', { diagnosis: 'Changed' }), + ).rejects.toThrow(ForbiddenException); + }); + }); + + // --- REMOVE --- + + describe('remove', () => { + it('should soft remove a non-verified record', async () => { + const record = { id: 'rec-1', verified: false }; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + mockMedicalRecordRepo.softRemove.mockResolvedValue(record); + + await service.remove('rec-1', 'user-1'); + + expect(mockMedicalRecordRepo.softRemove).toHaveBeenCalledWith(record); + expect(mockAuditService.log).toHaveBeenCalledWith( + 'user-1', 'medical_record', 'rec-1', 'delete', + ); + }); + + it('should throw ForbiddenException when deleting a verified record', async () => { + const verified = { id: 'rec-1', verified: true }; + mockMedicalRecordRepo.findOne.mockResolvedValue(verified); + + await expect(service.remove('rec-1')).rejects.toThrow(ForbiddenException); + }); + + it('should throw NotFoundException when record does not exist', async () => { + mockMedicalRecordRepo.findOne.mockResolvedValue(null); + + await expect(service.remove('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + // --- VERIFY RECORD --- + + describe('verifyRecord', () => { + const verifyDto = { + vetId: 'vet-1', + digitalSignature: 'sig-data-123', + }; + + it('should verify a record with digital signature hash', async () => { + const record = { + id: 'rec-1', + petId: 'pet-1', + vetId: 'vet-1', + diagnosis: 'Healthy', + treatment: 'None', + visitDate: '2026-02-21', + verified: false, + verifiedAt: null, + verifiedByVetId: null, + digitalSignature: null, + notes: null, + }; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + mockMedicalRecordRepo.save.mockImplementation((r) => Promise.resolve(r)); + + const result = await service.verifyRecord('rec-1', verifyDto, 'user-1'); + + expect(result.verified).toBe(true); + expect(result.verifiedAt).toBeInstanceOf(Date); + expect(result.verifiedByVetId).toBe('vet-1'); + expect(result.digitalSignature).toBeDefined(); + expect(result.digitalSignature).toHaveLength(64); // SHA-256 hex + expect(mockAuditService.log).toHaveBeenCalled(); + }); + + it('should append verification notes to existing notes', async () => { + const record = { + id: 'rec-1', + petId: 'pet-1', + vetId: 'vet-1', + diagnosis: 'Test', + treatment: 'Test', + visitDate: '2026-02-21', + verified: false, + verifiedAt: null, + verifiedByVetId: null, + digitalSignature: null, + notes: 'Existing notes', + }; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + mockMedicalRecordRepo.save.mockImplementation((r) => Promise.resolve(r)); + + const result = await service.verifyRecord('rec-1', { + ...verifyDto, + notes: 'All looks good', + }); + + expect(result.notes).toContain('Existing notes'); + expect(result.notes).toContain('[Verification Note]: All looks good'); + }); + + it('should throw BadRequestException if already verified', async () => { + const record = { id: 'rec-1', verified: true }; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + + await expect( + service.verifyRecord('rec-1', verifyDto), + ).rejects.toThrow(BadRequestException); + }); + }); + + // --- REVOKE VERIFICATION --- + + describe('revokeVerification', () => { + const revokeDto = { + vetId: 'vet-1', + reason: 'Incorrect diagnosis', + }; + + it('should revoke verification and create version snapshot', async () => { + const record = { + id: 'rec-1', + verified: true, + verifiedAt: new Date(), + verifiedByVetId: 'vet-1', + digitalSignature: 'hash-abc', + version: 2, + }; + mockMedicalRecordRepo.findOne + .mockResolvedValueOnce(record) // findOne in revokeVerification + .mockResolvedValueOnce(record); // findOne in createVersionSnapshot + mockVersionRepo.create.mockReturnValue({ id: 'v-3' }); + mockVersionRepo.save.mockResolvedValue({ id: 'v-3' }); + mockMedicalRecordRepo.save.mockImplementation((r) => Promise.resolve(r)); + + const result = await service.revokeVerification('rec-1', revokeDto, 'user-1'); + + expect(result.verified).toBe(false); + expect(result.verifiedAt).toBeNull(); + expect(result.verifiedByVetId).toBeNull(); + expect(result.digitalSignature).toBeNull(); + expect(mockVersionRepo.create).toHaveBeenCalled(); + expect(mockAuditService.log).toHaveBeenCalled(); + }); + + it('should throw BadRequestException if not currently verified', async () => { + const record = { id: 'rec-1', verified: false }; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + + await expect( + service.revokeVerification('rec-1', revokeDto), + ).rejects.toThrow(BadRequestException); + }); + }); + + // --- VERSION HISTORY --- + + describe('getRecordVersions', () => { + it('should return version history for a record', async () => { + const record = { id: 'rec-1' }; + const versions = [ + { id: 'v-2', recordId: 'rec-1', version: 2 }, + { id: 'v-1', recordId: 'rec-1', version: 1 }, + ]; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + mockVersionRepo.find.mockResolvedValue(versions); + + const result = await service.getRecordVersions('rec-1'); + + expect(result).toHaveLength(2); + expect(mockVersionRepo.find).toHaveBeenCalledWith({ + where: { recordId: 'rec-1' }, + order: { version: 'DESC' }, + }); + }); + + it('should throw NotFoundException if record not found', async () => { + mockMedicalRecordRepo.findOne.mockResolvedValue(null); + + await expect(service.getRecordVersions('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('getRecordVersion', () => { + it('should return a specific version', async () => { + const version = { id: 'v-1', recordId: 'rec-1', version: 1, snapshot: {} }; + mockVersionRepo.findOne.mockResolvedValue(version); + + const result = await service.getRecordVersion('rec-1', 'v-1'); + + expect(result).toEqual(version); + }); + + it('should throw NotFoundException if version not found', async () => { + mockVersionRepo.findOne.mockResolvedValue(null); + + await expect( + service.getRecordVersion('rec-1', 'nonexistent'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // --- TEMPLATES --- + + describe('getTemplatesByPetType', () => { + it('should return active templates for a pet type', async () => { + const templates = [ + { id: 't-1', petType: PetSpecies.DOG, recordType: RecordType.CHECKUP, isActive: true }, + ]; + mockTemplateRepo.find.mockResolvedValue(templates); + + const result = await service.getTemplatesByPetType(PetSpecies.DOG); + + expect(mockTemplateRepo.find).toHaveBeenCalledWith({ + where: { petType: PetSpecies.DOG, isActive: true }, + }); + expect(result).toEqual(templates); + }); + }); + + describe('createTemplate', () => { + it('should create a new record template', async () => { + const templateData = { + petType: PetSpecies.CAT, + recordType: RecordType.CHECKUP, + templateFields: { weight: 'number', temperature: 'number' }, + description: 'Cat checkup template', + }; + const created = { id: 't-1', ...templateData, isActive: true }; + mockTemplateRepo.create.mockReturnValue(created); + mockTemplateRepo.save.mockResolvedValue(created); + + const result = await service.createTemplate( + templateData.petType, + templateData.recordType, + templateData.templateFields, + templateData.description, + ); + + expect(mockTemplateRepo.create).toHaveBeenCalledWith({ + petType: PetSpecies.CAT, + recordType: RecordType.CHECKUP, + templateFields: { weight: 'number', temperature: 'number' }, + description: 'Cat checkup template', + }); + expect(result).toEqual(created); + }); + }); + + // --- QR CODE --- + + describe('getQRCode', () => { + it('should return existing QR code', async () => { + const record = { id: 'rec-1', qrCode: 'data:image/png;base64,existing' }; + mockMedicalRecordRepo.findOne.mockResolvedValue(record); + + const result = await service.getQRCode('rec-1'); + + expect(result).toBe('data:image/png;base64,existing'); + }); + + it('should generate QR code if not present', async () => { + const record = { id: 'rec-1', qrCode: null }; + const withQr = { ...record, qrCode: 'data:image/png;base64,mockqrcode' }; + mockMedicalRecordRepo.findOne + .mockResolvedValueOnce(record) // getQRCode -> findOne + .mockResolvedValueOnce(record); // generateQRCode -> findOne + mockMedicalRecordRepo.save.mockResolvedValue(withQr); + + const result = await service.getQRCode('rec-1'); + + expect(result).toBe('data:image/png;base64,mockqrcode'); + }); + }); + + // --- ATTACHMENTS --- + + describe('saveAttachment', () => { + it('should save a valid attachment and return filepath', async () => { + const file = { + originalname: 'xray.jpg', + mimetype: 'image/jpeg', + size: 1024 * 100, // 100KB + } as Express.Multer.File; + + const result = await service.saveAttachment(file); + + expect(result).toContain('uploads/medical-records/'); + expect(result).toContain('xray.jpg'); + }); + + it('should reject disallowed file types', async () => { + const file = { + originalname: 'script.exe', + mimetype: 'application/x-executable', + size: 1024, + } as Express.Multer.File; + + await expect(service.saveAttachment(file)).rejects.toThrow(BadRequestException); + }); + + it('should reject files exceeding size limit', async () => { + const file = { + originalname: 'large-image.png', + mimetype: 'image/png', + size: 60 * 1024 * 1024, // 60MB + } as Express.Multer.File; + + await expect(service.saveAttachment(file)).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/backend/src/modules/medical-records/medical-records.service.ts b/backend/src/modules/medical-records/medical-records.service.ts index 7aacd026..d8e4d67a 100644 --- a/backend/src/modules/medical-records/medical-records.service.ts +++ b/backend/src/modules/medical-records/medical-records.service.ts @@ -2,17 +2,22 @@ import { Injectable, NotFoundException, BadRequestException, + ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Between, IsNull, Not } from 'typeorm'; +import { Repository, Between } from 'typeorm'; import * as QRCode from 'qrcode'; import * as crypto from 'crypto'; import { MedicalRecord } from './entities/medical-record.entity'; import { RecordTemplate } from './entities/record-template.entity'; +import { RecordVersion } from './entities/record-version.entity'; import { CreateMedicalRecordDto } from './dto/create-medical-record.dto'; import { UpdateMedicalRecordDto } from './dto/update-medical-record.dto'; +import { VerifyRecordDto, RevokeVerificationDto } from './dto/verify-record.dto'; import { PetSpecies } from '../pets/entities/pet.entity'; import { RecordType } from './entities/medical-record.entity'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../audit/entities/audit-log.entity'; @Injectable() export class MedicalRecordsService { @@ -21,10 +26,14 @@ export class MedicalRecordsService { private readonly medicalRecordRepository: Repository, @InjectRepository(RecordTemplate) private readonly templateRepository: Repository, - ) {} + @InjectRepository(RecordVersion) + private readonly versionRepository: Repository, + private readonly auditService: AuditService, + ) { } async create( createMedicalRecordDto: CreateMedicalRecordDto, + userId?: string, ): Promise { const record = this.medicalRecordRepository.create(createMedicalRecordDto); const savedRecord = await this.medicalRecordRepository.save(record); @@ -32,6 +41,19 @@ export class MedicalRecordsService { // Generate QR code await this.generateQRCode(savedRecord.id); + // Create initial version snapshot + await this.createVersionSnapshot(savedRecord.id, 1, userId, 'Initial creation'); + + // Audit log + if (userId) { + await this.auditService.log( + userId, + 'medical_record', + savedRecord.id, + AuditAction.CREATE, + ); + } + return this.findOne(savedRecord.id); } @@ -52,20 +74,20 @@ export class MedicalRecordsService { } if (startDate && endDate) { - where.date = Between(new Date(startDate), new Date(endDate)); + where.visitDate = Between(new Date(startDate), new Date(endDate)); } return await this.medicalRecordRepository.find({ where, - relations: ['pet', 'vet'], - order: { date: 'DESC' }, + relations: ['pet', 'vet', 'verifiedByVet'], + order: { visitDate: 'DESC' }, }); } async findOne(id: string): Promise { const record = await this.medicalRecordRepository.findOne({ where: { id }, - relations: ['pet', 'vet'], + relations: ['pet', 'vet', 'verifiedByVet'], }); if (!record) { @@ -79,8 +101,8 @@ export class MedicalRecordsService { if (!ids.length) return []; const records = await this.medicalRecordRepository.find({ where: ids.map((id) => ({ id })), - relations: ['pet', 'vet'], - order: { date: 'DESC' }, + relations: ['pet', 'vet', 'verifiedByVet'], + order: { visitDate: 'DESC' }, }); return records; } @@ -88,21 +110,222 @@ export class MedicalRecordsService { async update( id: string, updateMedicalRecordDto: UpdateMedicalRecordDto, + userId?: string, ): Promise { const record = await this.findOne(id); - Object.assign(record, updateMedicalRecordDto); - return await this.medicalRecordRepository.save(record); + + // If verified, prevent modification unless explicitly allowed + if (record.verified) { + throw new ForbiddenException( + 'Cannot modify a verified medical record. Revoke verification first.', + ); + } + + // Save version snapshot before updating + const { changeReason, ...updateData } = updateMedicalRecordDto; + await this.createVersionSnapshot( + id, + record.version, + userId, + changeReason || 'Record updated', + ); + + Object.assign(record, updateData); + const updated = await this.medicalRecordRepository.save(record); + + // Audit log + if (userId) { + await this.auditService.log( + userId, + 'medical_record', + id, + AuditAction.UPDATE, + ); + } + + return updated; } - async remove(id: string): Promise { + async remove(id: string, userId?: string): Promise { const record = await this.findOne(id); + + if (record.verified) { + throw new ForbiddenException( + 'Cannot delete a verified medical record. Revoke verification first.', + ); + } + await this.medicalRecordRepository.softRemove(record); + + // Audit log + if (userId) { + await this.auditService.log( + userId, + 'medical_record', + id, + AuditAction.DELETE, + ); + } + } + + // --- Vet Verification / Signature --- + + async verifyRecord( + id: string, + verifyRecordDto: VerifyRecordDto, + userId?: string, + ): Promise { + const record = await this.findOne(id); + + if (record.verified) { + throw new BadRequestException('Record is already verified.'); + } + + // Generate cryptographic signature hash + const signaturePayload = [ + record.id, + record.petId, + record.vetId, + record.diagnosis, + record.treatment, + record.visitDate, + verifyRecordDto.vetId, + verifyRecordDto.digitalSignature, + ].join('|'); + + const signatureHash = crypto + .createHash('sha256') + .update(signaturePayload) + .digest('hex'); + + record.verified = true; + record.verifiedAt = new Date(); + record.verifiedByVetId = verifyRecordDto.vetId; + record.digitalSignature = signatureHash; + + if (verifyRecordDto.notes) { + record.notes = record.notes + ? `${record.notes}\n[Verification Note]: ${verifyRecordDto.notes}` + : `[Verification Note]: ${verifyRecordDto.notes}`; + } + + const saved = await this.medicalRecordRepository.save(record); + + // Audit log + if (userId) { + await this.auditService.log( + userId, + 'medical_record', + id, + AuditAction.UPDATE, + ); + } + + return saved; } + async revokeVerification( + id: string, + revokeDto: RevokeVerificationDto, + userId?: string, + ): Promise { + const record = await this.findOne(id); + + if (!record.verified) { + throw new BadRequestException('Record is not currently verified.'); + } + + // Save version snapshot before revoking + await this.createVersionSnapshot( + id, + record.version, + userId, + `Verification revoked: ${revokeDto.reason}`, + ); + + record.verified = false; + record.verifiedAt = null; + record.verifiedByVetId = null; + record.digitalSignature = null; + + const saved = await this.medicalRecordRepository.save(record); + + // Audit log + if (userId) { + await this.auditService.log( + userId, + 'medical_record', + id, + AuditAction.UPDATE, + ); + } + + return saved; + } + + // --- Record Versioning --- + + private async createVersionSnapshot( + recordId: string, + version: number, + changedBy?: string, + changeReason?: string, + ): Promise { + const record = await this.medicalRecordRepository.findOne({ + where: { id: recordId }, + }); + + if (!record) { + throw new NotFoundException(`Medical record with ID ${recordId} not found`); + } + + const snapshot = { ...record }; + delete (snapshot as any).deletedAt; + + const versionEntry = this.versionRepository.create({ + recordId, + version, + snapshot, + changedBy: changedBy || null, + changeReason: changeReason || null, + }); + + return await this.versionRepository.save(versionEntry); + } + + async getRecordVersions(recordId: string): Promise { + // Ensure the record exists + await this.findOne(recordId); + + return await this.versionRepository.find({ + where: { recordId }, + order: { version: 'DESC' }, + }); + } + + async getRecordVersion( + recordId: string, + versionId: string, + ): Promise { + const version = await this.versionRepository.findOne({ + where: { id: versionId, recordId }, + }); + + if (!version) { + throw new NotFoundException( + `Version ${versionId} not found for record ${recordId}`, + ); + } + + return version; + } + + // --- QR Code --- + async generateQRCode(recordId: string): Promise { const record = await this.findOne(recordId); - // Create shareable URL (you can customize this) + // Create shareable URL const shareUrl = `${process.env.APP_URL || 'http://localhost:3000'}/medical-records/share/${recordId}`; // Generate QR code as data URL @@ -125,6 +348,8 @@ export class MedicalRecordsService { return record.qrCode; } + // --- Record Templates --- + async getTemplatesByPetType(petType: PetSpecies): Promise { return await this.templateRepository.find({ where: { petType, isActive: true }, @@ -147,13 +372,39 @@ export class MedicalRecordsService { return await this.templateRepository.save(template); } + // --- Attachments --- + async saveAttachment(file: Express.Multer.File): Promise { - // Generate unique filename + // Validate file type for HIPAA compliance + const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'application/dicom', + 'text/plain', + ]; + + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new BadRequestException( + `File type ${file.mimetype} is not allowed. Allowed types: ${allowedMimeTypes.join(', ')}`, + ); + } + + // Validate file size (max 50MB) + const maxSize = 50 * 1024 * 1024; + if (file.size > maxSize) { + throw new BadRequestException( + `File size exceeds maximum allowed size of 50MB.`, + ); + } + + // Generate unique filename with encryption-safe naming const filename = `${crypto.randomUUID()}-${file.originalname}`; const filepath = `uploads/medical-records/${filename}`; - // In production, you would upload to cloud storage here - // For now, we'll just return the filepath + // In production, upload to encrypted cloud storage here return filepath; } } From bd5f1c636809ca4bdabb7e1b0251ca2ff513d17f Mon Sep 17 00:00:00 2001 From: Meshmulla Date: Sat, 21 Feb 2026 13:07:38 +0100 Subject: [PATCH 2/2] User Blocking & Reporting --- backend/package-lock.json | 40 +-- .../blocking-reporting.module.ts | 18 ++ .../controllers/block.controller.spec.ts | 64 +++++ .../controllers/block.controller.ts | 45 ++++ .../blocking-reporting/controllers/index.ts | 2 + .../controllers/report.controller.spec.ts | 107 ++++++++ .../controllers/report.controller.ts | 71 +++++ .../dto/create-block.dto.spec.ts | 30 +++ .../dto/create-block.dto.ts | 6 + .../dto/create-report.dto.ts | 24 ++ .../modules/blocking-reporting/dto/index.ts | 4 + .../dto/report-filter.dto.ts | 24 ++ .../dto/statistics-query.dto.ts | 18 ++ .../dto/update-report-status.dto.ts | 8 + .../entities/block.entity.ts | 40 +++ .../blocking-reporting/entities/index.ts | 3 + .../entities/report-note.entity.ts | 36 +++ .../entities/report.entity.ts | 55 ++++ .../modules/blocking-reporting/enums/index.ts | 2 + .../enums/report-category.enum.ts | 8 + .../enums/report-status.enum.ts | 6 + .../blocking-reporting/guards/index.ts | 1 + .../src/modules/blocking-reporting/index.ts | 2 + .../blocking-reporting/interceptors/index.ts | 1 + .../services/block.service.spec.ts | 250 ++++++++++++++++++ .../services/block.service.ts | 126 +++++++++ .../blocking-reporting/services/index.ts | 2 + .../services/report.service.spec.ts | 158 +++++++++++ .../services/report.service.ts | 199 ++++++++++++++ 29 files changed, 1321 insertions(+), 29 deletions(-) create mode 100644 backend/src/modules/blocking-reporting/blocking-reporting.module.ts create mode 100644 backend/src/modules/blocking-reporting/controllers/block.controller.spec.ts create mode 100644 backend/src/modules/blocking-reporting/controllers/block.controller.ts create mode 100644 backend/src/modules/blocking-reporting/controllers/index.ts create mode 100644 backend/src/modules/blocking-reporting/controllers/report.controller.spec.ts create mode 100644 backend/src/modules/blocking-reporting/controllers/report.controller.ts create mode 100644 backend/src/modules/blocking-reporting/dto/create-block.dto.spec.ts create mode 100644 backend/src/modules/blocking-reporting/dto/create-block.dto.ts create mode 100644 backend/src/modules/blocking-reporting/dto/create-report.dto.ts create mode 100644 backend/src/modules/blocking-reporting/dto/index.ts create mode 100644 backend/src/modules/blocking-reporting/dto/report-filter.dto.ts create mode 100644 backend/src/modules/blocking-reporting/dto/statistics-query.dto.ts create mode 100644 backend/src/modules/blocking-reporting/dto/update-report-status.dto.ts create mode 100644 backend/src/modules/blocking-reporting/entities/block.entity.ts create mode 100644 backend/src/modules/blocking-reporting/entities/index.ts create mode 100644 backend/src/modules/blocking-reporting/entities/report-note.entity.ts create mode 100644 backend/src/modules/blocking-reporting/entities/report.entity.ts create mode 100644 backend/src/modules/blocking-reporting/enums/index.ts create mode 100644 backend/src/modules/blocking-reporting/enums/report-category.enum.ts create mode 100644 backend/src/modules/blocking-reporting/enums/report-status.enum.ts create mode 100644 backend/src/modules/blocking-reporting/guards/index.ts create mode 100644 backend/src/modules/blocking-reporting/index.ts create mode 100644 backend/src/modules/blocking-reporting/interceptors/index.ts create mode 100644 backend/src/modules/blocking-reporting/services/block.service.spec.ts create mode 100644 backend/src/modules/blocking-reporting/services/block.service.ts create mode 100644 backend/src/modules/blocking-reporting/services/index.ts create mode 100644 backend/src/modules/blocking-reporting/services/report.service.spec.ts create mode 100644 backend/src/modules/blocking-reporting/services/report.service.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 0bd5f891..3424cc2c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1240,7 +1240,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3893,7 +3892,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4064,7 +4062,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -4112,7 +4109,6 @@ "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -4196,7 +4192,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.14.tgz", "integrity": "sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -4218,7 +4213,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.14.tgz", "integrity": "sha512-LLSIWkYz4FcvUhfepillYQboo9qbjq1YtQj8XC3zyex+EaqNXvxhZntx/1uJhAjc655pJts9HfZwWXei8jrRGw==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -5507,7 +5501,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5667,7 +5660,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5933,7 +5925,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -6640,7 +6631,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6699,7 +6689,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7284,7 +7273,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7363,7 +7351,6 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.69.3.tgz", "integrity": "sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.9.2", @@ -7558,7 +7545,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7606,15 +7592,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -8493,7 +8477,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8554,7 +8537,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9879,7 +9861,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", @@ -10160,7 +10141,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -11814,6 +11794,7 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -12008,7 +11989,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -12139,7 +12119,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -12412,7 +12391,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13803,7 +13781,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14164,7 +14141,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14325,7 +14301,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -14521,7 +14496,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14923,6 +14897,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -14941,6 +14916,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -14954,6 +14930,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -14968,6 +14945,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -14977,7 +14955,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -14985,6 +14964,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -14995,6 +14975,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -15008,6 +14989,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/backend/src/modules/blocking-reporting/blocking-reporting.module.ts b/backend/src/modules/blocking-reporting/blocking-reporting.module.ts new file mode 100644 index 00000000..dec56910 --- /dev/null +++ b/backend/src/modules/blocking-reporting/blocking-reporting.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Block } from './entities/block.entity'; +import { Report } from './entities/report.entity'; +import { ReportNote } from './entities/report-note.entity'; +import { BlockService } from './services/block.service'; +import { ReportService } from './services/report.service'; +import { BlockController } from './controllers/block.controller'; +import { ReportController } from './controllers/report.controller'; +import { AuditModule } from '../audit/audit.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Block, Report, ReportNote]), AuditModule], + providers: [BlockService, ReportService], + controllers: [BlockController, ReportController], + exports: [BlockService, ReportService], +}) +export class BlockingReportingModule { } diff --git a/backend/src/modules/blocking-reporting/controllers/block.controller.spec.ts b/backend/src/modules/blocking-reporting/controllers/block.controller.spec.ts new file mode 100644 index 00000000..026a3a67 --- /dev/null +++ b/backend/src/modules/blocking-reporting/controllers/block.controller.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BlockController } from './block.controller'; +import { BlockService } from '../services/block.service'; + +describe('BlockController', () => { + let controller: BlockController; + let blockService: BlockService; + + const mockBlockService = { + createBlock: jest.fn(), + unblockUser: jest.fn(), + getBlockedUsers: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BlockController], + providers: [ + { + provide: BlockService, + useValue: mockBlockService, + }, + ], + }).compile(); + + controller = module.get(BlockController); + blockService = module.get(BlockService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createBlock', () => { + it('should create a block', async () => { + const dto = { blockedUserId: 'user-2' }; + mockBlockService.createBlock.mockResolvedValue({ id: 'block-1' }); + + const result = await controller.createBlock('user-1', dto); + expect(result).toEqual({ id: 'block-1' }); + expect(mockBlockService.createBlock).toHaveBeenCalledWith('user-1', dto); + }); + }); + + describe('unblockUser', () => { + it('should unblock a user', async () => { + mockBlockService.unblockUser.mockResolvedValue(undefined); + + const result = await controller.unblockUser('user-1', 'user-2'); + expect(result).toBeUndefined(); + expect(mockBlockService.unblockUser).toHaveBeenCalledWith('user-1', 'user-2'); + }); + }); + + describe('getBlockedUsers', () => { + it('should return paginated blocked users', async () => { + mockBlockService.getBlockedUsers.mockResolvedValue({ data: [], total: 0 }); + + const result = await controller.getBlockedUsers('user-1', 10, 1); + expect(result).toEqual({ data: [], total: 0 }); + expect(mockBlockService.getBlockedUsers).toHaveBeenCalledWith('user-1', 10, 1); + }); + }); +}); diff --git a/backend/src/modules/blocking-reporting/controllers/block.controller.ts b/backend/src/modules/blocking-reporting/controllers/block.controller.ts new file mode 100644 index 00000000..26e191bd --- /dev/null +++ b/backend/src/modules/blocking-reporting/controllers/block.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, + Post, + Body, + UseGuards, + Delete, + Param, + Get, + Query, +} from '@nestjs/common'; +import { BlockService } from '../services/block.service'; +import { CreateBlockDto } from '../dto/create-block.dto'; +import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../../../auth/decorators/current-user.decorator'; + +@Controller('blocks') +@UseGuards(JwtAuthGuard) +export class BlockController { + constructor(private readonly blockService: BlockService) { } + + @Post() + async createBlock( + @CurrentUser('id') userId: string, + @Body() createBlockDto: CreateBlockDto, + ) { + return this.blockService.createBlock(userId, createBlockDto); + } + + @Delete(':blockedUserId') + async unblockUser( + @CurrentUser('id') userId: string, + @Param('blockedUserId') blockedUserId: string, + ) { + return this.blockService.unblockUser(userId, blockedUserId); + } + + @Get() + async getBlockedUsers( + @CurrentUser('id') userId: string, + @Query('limit') limit: number = 10, + @Query('page') page: number = 1, + ) { + return this.blockService.getBlockedUsers(userId, limit, page); + } +} diff --git a/backend/src/modules/blocking-reporting/controllers/index.ts b/backend/src/modules/blocking-reporting/controllers/index.ts new file mode 100644 index 00000000..074d68bd --- /dev/null +++ b/backend/src/modules/blocking-reporting/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './block.controller'; +export * from './report.controller'; diff --git a/backend/src/modules/blocking-reporting/controllers/report.controller.spec.ts b/backend/src/modules/blocking-reporting/controllers/report.controller.spec.ts new file mode 100644 index 00000000..8621b01e --- /dev/null +++ b/backend/src/modules/blocking-reporting/controllers/report.controller.spec.ts @@ -0,0 +1,107 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { ReportController } from './report.controller'; +import { ReportService } from '../services/report.service'; +import { ReportStatus } from '../enums/report-status.enum'; +import { ReportCategory } from '../enums/report-category.enum'; +import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../../auth/guards/roles.guard'; + +describe('ReportController', () => { + let controller: ReportController; + let reportService: ReportService; + + const mockReportService = { + createReport: jest.fn(), + getReports: jest.fn(), + getReportStatistics: jest.fn(), + getReportById: jest.fn(), + updateReportStatus: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReportController], + providers: [ + { + provide: ReportService, + useValue: mockReportService, + }, + { + provide: Reflector, + useValue: { + getAllAndOverride: jest.fn(), + }, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(ReportController); + reportService = module.get(ReportService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createReport', () => { + it('should create a report', async () => { + const dto = { reportedUserId: 'user-2', category: ReportCategory.SPAM }; + mockReportService.createReport.mockResolvedValue({ id: 'report-1' }); + + const result = await controller.createReport('user-1', dto); + expect(result).toEqual({ id: 'report-1' }); + expect(mockReportService.createReport).toHaveBeenCalledWith('user-1', dto); + }); + }); + + describe('getReports', () => { + it('should get paginated reports', async () => { + mockReportService.getReports.mockResolvedValue({ data: [], total: 0 }); + const filter = { status: ReportStatus.PENDING }; + + const result = await controller.getReports(filter, 10, 1); + expect(result).toEqual({ data: [], total: 0 }); + expect(mockReportService.getReports).toHaveBeenCalledWith(filter, 10, 1); + }); + }); + + describe('getReportStatistics', () => { + it('should return report statistics', async () => { + mockReportService.getReportStatistics.mockResolvedValue({ total: 1 }); + + const result = await controller.getReportStatistics({}); + expect(result).toEqual({ total: 1 }); + expect(mockReportService.getReportStatistics).toHaveBeenCalledWith({}); + }); + }); + + describe('getReportById', () => { + it('should return a report', async () => { + mockReportService.getReportById.mockResolvedValue({ id: 'report-1' }); + + const result = await controller.getReportById('report-1'); + expect(result).toEqual({ id: 'report-1' }); + expect(mockReportService.getReportById).toHaveBeenCalledWith('report-1'); + }); + }); + + describe('updateReportStatus', () => { + it('should update status', async () => { + mockReportService.updateReportStatus.mockResolvedValue({ status: ReportStatus.RESOLVED }); + + const dto = { status: ReportStatus.RESOLVED }; + const note = 'Review completed'; + + const result = await controller.updateReportStatus('admin-1', 'report-1', dto, note); + + expect(result).toEqual({ status: ReportStatus.RESOLVED }); + expect(mockReportService.updateReportStatus).toHaveBeenCalledWith('admin-1', 'report-1', dto, note); + }); + }); +}); diff --git a/backend/src/modules/blocking-reporting/controllers/report.controller.ts b/backend/src/modules/blocking-reporting/controllers/report.controller.ts new file mode 100644 index 00000000..a16429ee --- /dev/null +++ b/backend/src/modules/blocking-reporting/controllers/report.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Post, + Body, + UseGuards, + Get, + Param, + Patch, + Query, +} from '@nestjs/common'; +import { ReportService } from '../services/report.service'; +import { CreateReportDto } from '../dto/create-report.dto'; +import { ReportFilterDto } from '../dto/report-filter.dto'; +import { UpdateReportStatusDto } from '../dto/update-report-status.dto'; +import { StatisticsQueryDto } from '../dto/statistics-query.dto'; +import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../../auth/guards/roles.guard'; +import { Roles } from '../../../auth/decorators/roles.decorator'; +import { RoleName } from '../../../auth/constants/roles.enum'; +import { CurrentUser } from '../../../auth/decorators/current-user.decorator'; + +@Controller('reports') +@UseGuards(JwtAuthGuard) +export class ReportController { + constructor(private readonly reportService: ReportService) { } + + @Post() + async createReport( + @CurrentUser('id') userId: string, + @Body() createReportDto: CreateReportDto, + ) { + return this.reportService.createReport(userId, createReportDto); + } + + @Get() + @UseGuards(RolesGuard) + @Roles(RoleName.Admin) + async getReports( + @Query() filterDto: ReportFilterDto, + @Query('limit') limit: number = 10, + @Query('page') page: number = 1, + ) { + return this.reportService.getReports(filterDto, limit, page); + } + + @Get('statistics') + @UseGuards(RolesGuard) + @Roles(RoleName.Admin) + async getReportStatistics(@Query() queryDto: StatisticsQueryDto) { + return this.reportService.getReportStatistics(queryDto); + } + + @Get(':id') + @UseGuards(RolesGuard) + @Roles(RoleName.Admin) + async getReportById(@Param('id') id: string) { + return this.reportService.getReportById(id); + } + + @Patch(':id/status') + @UseGuards(RolesGuard) + @Roles(RoleName.Admin) + async updateReportStatus( + @CurrentUser('id') adminId: string, + @Param('id') reportId: string, + @Body() updateDto: UpdateReportStatusDto, + @Body('note') note?: string, + ) { + return this.reportService.updateReportStatus(adminId, reportId, updateDto, note); + } +} diff --git a/backend/src/modules/blocking-reporting/dto/create-block.dto.spec.ts b/backend/src/modules/blocking-reporting/dto/create-block.dto.spec.ts new file mode 100644 index 00000000..b7a7dc38 --- /dev/null +++ b/backend/src/modules/blocking-reporting/dto/create-block.dto.spec.ts @@ -0,0 +1,30 @@ +import { validate } from 'class-validator'; +import { CreateBlockDto } from './create-block.dto'; + +describe('CreateBlockDto', () => { + it('should pass validation with valid UUID', async () => { + const dto = new CreateBlockDto(); + dto.blockedUserId = '123e4567-e89b-12d3-a456-426614174000'; + + const errors = await validate(dto); + expect(errors.length).toBe(0); + }); + + it('should fail validation with invalid UUID', async () => { + const dto = new CreateBlockDto(); + dto.blockedUserId = 'invalid-uuid'; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('blockedUserId'); + }); + + it('should fail validation with empty string', async () => { + const dto = new CreateBlockDto(); + dto.blockedUserId = ''; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('blockedUserId'); + }); +}); diff --git a/backend/src/modules/blocking-reporting/dto/create-block.dto.ts b/backend/src/modules/blocking-reporting/dto/create-block.dto.ts new file mode 100644 index 00000000..fbeeb0bb --- /dev/null +++ b/backend/src/modules/blocking-reporting/dto/create-block.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class CreateBlockDto { + @IsUUID() + blockedUserId: string; +} diff --git a/backend/src/modules/blocking-reporting/dto/create-report.dto.ts b/backend/src/modules/blocking-reporting/dto/create-report.dto.ts new file mode 100644 index 00000000..e66851df --- /dev/null +++ b/backend/src/modules/blocking-reporting/dto/create-report.dto.ts @@ -0,0 +1,24 @@ +import { + IsNotEmpty, + IsString, + IsUUID, + IsEnum, + IsOptional, + MaxLength, +} from 'class-validator'; +import { ReportCategory } from '../enums/report-category.enum'; + +export class CreateReportDto { + @IsUUID() + @IsNotEmpty() + reportedUserId: string; + + @IsEnum(ReportCategory) + @IsNotEmpty() + category: ReportCategory; + + @IsString() + @IsOptional() + @MaxLength(1000) + description?: string; +} diff --git a/backend/src/modules/blocking-reporting/dto/index.ts b/backend/src/modules/blocking-reporting/dto/index.ts new file mode 100644 index 00000000..2ae91ef6 --- /dev/null +++ b/backend/src/modules/blocking-reporting/dto/index.ts @@ -0,0 +1,4 @@ +export * from './create-report.dto'; +export * from './update-report-status.dto'; +export * from './report-filter.dto'; +export * from './statistics-query.dto'; diff --git a/backend/src/modules/blocking-reporting/dto/report-filter.dto.ts b/backend/src/modules/blocking-reporting/dto/report-filter.dto.ts new file mode 100644 index 00000000..1b036b29 --- /dev/null +++ b/backend/src/modules/blocking-reporting/dto/report-filter.dto.ts @@ -0,0 +1,24 @@ +import { IsEnum, IsOptional, IsDate } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ReportStatus } from '../enums/report-status.enum'; +import { ReportCategory } from '../enums/report-category.enum'; + +export class ReportFilterDto { + @IsEnum(ReportStatus) + @IsOptional() + status?: ReportStatus; + + @IsEnum(ReportCategory) + @IsOptional() + category?: ReportCategory; + + @IsDate() + @Type(() => Date) + @IsOptional() + startDate?: Date; + + @IsDate() + @Type(() => Date) + @IsOptional() + endDate?: Date; +} diff --git a/backend/src/modules/blocking-reporting/dto/statistics-query.dto.ts b/backend/src/modules/blocking-reporting/dto/statistics-query.dto.ts new file mode 100644 index 00000000..a3f0c467 --- /dev/null +++ b/backend/src/modules/blocking-reporting/dto/statistics-query.dto.ts @@ -0,0 +1,18 @@ +import { IsDate, IsOptional, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class StatisticsQueryDto { + @IsDate() + @Type(() => Date) + @IsOptional() + startDate?: Date; + + @IsDate() + @Type(() => Date) + @IsOptional() + endDate?: Date; + + @IsUUID() + @IsOptional() + userId?: string; +} diff --git a/backend/src/modules/blocking-reporting/dto/update-report-status.dto.ts b/backend/src/modules/blocking-reporting/dto/update-report-status.dto.ts new file mode 100644 index 00000000..d35d3b58 --- /dev/null +++ b/backend/src/modules/blocking-reporting/dto/update-report-status.dto.ts @@ -0,0 +1,8 @@ +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ReportStatus } from '../enums/report-status.enum'; + +export class UpdateReportStatusDto { + @IsEnum(ReportStatus) + @IsNotEmpty() + status: ReportStatus; +} diff --git a/backend/src/modules/blocking-reporting/entities/block.entity.ts b/backend/src/modules/blocking-reporting/entities/block.entity.ts new file mode 100644 index 00000000..731ace59 --- /dev/null +++ b/backend/src/modules/blocking-reporting/entities/block.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('blocks') +@Index(['blocker', 'blockedUser'], { unique: true }) +@Index(['blocker']) +@Index(['blockedUser']) +export class Block { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + blocker: string; + + @ManyToOne(() => User, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'blocker' }) + blockerUser: User; + + @Column() + blockedUser: string; + + @ManyToOne(() => User, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'blockedUser' }) + blockedUserEntity: User; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/blocking-reporting/entities/index.ts b/backend/src/modules/blocking-reporting/entities/index.ts new file mode 100644 index 00000000..ba310982 --- /dev/null +++ b/backend/src/modules/blocking-reporting/entities/index.ts @@ -0,0 +1,3 @@ +export { Block } from './block.entity'; +export { Report } from './report.entity'; +export { ReportNote } from './report-note.entity'; diff --git a/backend/src/modules/blocking-reporting/entities/report-note.entity.ts b/backend/src/modules/blocking-reporting/entities/report-note.entity.ts new file mode 100644 index 00000000..fe99f218 --- /dev/null +++ b/backend/src/modules/blocking-reporting/entities/report-note.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Report } from './report.entity'; + +@Entity('report_notes') +@Index(['report']) +@Index(['admin']) +export class ReportNote { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Report, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'report_id' }) + report: Report; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'admin_id' }) + admin: User; + + @Column({ + type: 'text', + nullable: false, + }) + note: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/blocking-reporting/entities/report.entity.ts b/backend/src/modules/blocking-reporting/entities/report.entity.ts new file mode 100644 index 00000000..14c56439 --- /dev/null +++ b/backend/src/modules/blocking-reporting/entities/report.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { ReportCategory } from '../enums/report-category.enum'; +import { ReportStatus } from '../enums/report-status.enum'; + +@Entity('reports') +@Index(['reporter']) +@Index(['reportedUser']) +@Index(['status']) +@Index(['category']) +export class Report { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'reporter_id' }) + reporter: User; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'reported_user_id' }) + reportedUser: User; + + @Column({ + type: 'enum', + enum: ReportCategory, + nullable: false, + }) + category: ReportCategory; + + @Column({ + type: 'enum', + enum: ReportStatus, + default: ReportStatus.PENDING, + nullable: false, + }) + status: ReportStatus; + + @Column({ + type: 'varchar', + length: 1000, + nullable: true, + }) + description: string | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/blocking-reporting/enums/index.ts b/backend/src/modules/blocking-reporting/enums/index.ts new file mode 100644 index 00000000..26424e7b --- /dev/null +++ b/backend/src/modules/blocking-reporting/enums/index.ts @@ -0,0 +1,2 @@ +export * from './report-category.enum'; +export * from './report-status.enum'; diff --git a/backend/src/modules/blocking-reporting/enums/report-category.enum.ts b/backend/src/modules/blocking-reporting/enums/report-category.enum.ts new file mode 100644 index 00000000..c84bc987 --- /dev/null +++ b/backend/src/modules/blocking-reporting/enums/report-category.enum.ts @@ -0,0 +1,8 @@ +export enum ReportCategory { + HARASSMENT = 'harassment', + SPAM = 'spam', + INAPPROPRIATE_CONTENT = 'inappropriate_content', + IMPERSONATION = 'impersonation', + FRAUD = 'fraud', + OTHER = 'other', +} diff --git a/backend/src/modules/blocking-reporting/enums/report-status.enum.ts b/backend/src/modules/blocking-reporting/enums/report-status.enum.ts new file mode 100644 index 00000000..fd751362 --- /dev/null +++ b/backend/src/modules/blocking-reporting/enums/report-status.enum.ts @@ -0,0 +1,6 @@ +export enum ReportStatus { + PENDING = 'pending', + UNDER_REVIEW = 'under_review', + RESOLVED = 'resolved', + DISMISSED = 'dismissed', +} diff --git a/backend/src/modules/blocking-reporting/guards/index.ts b/backend/src/modules/blocking-reporting/guards/index.ts new file mode 100644 index 00000000..af37211a --- /dev/null +++ b/backend/src/modules/blocking-reporting/guards/index.ts @@ -0,0 +1 @@ +// Guard exports will be added here diff --git a/backend/src/modules/blocking-reporting/index.ts b/backend/src/modules/blocking-reporting/index.ts new file mode 100644 index 00000000..9d45eaac --- /dev/null +++ b/backend/src/modules/blocking-reporting/index.ts @@ -0,0 +1,2 @@ +export * from './blocking-reporting.module'; +export * from './enums'; diff --git a/backend/src/modules/blocking-reporting/interceptors/index.ts b/backend/src/modules/blocking-reporting/interceptors/index.ts new file mode 100644 index 00000000..d307fa6c --- /dev/null +++ b/backend/src/modules/blocking-reporting/interceptors/index.ts @@ -0,0 +1 @@ +// Interceptor exports will be added here diff --git a/backend/src/modules/blocking-reporting/services/block.service.spec.ts b/backend/src/modules/blocking-reporting/services/block.service.spec.ts new file mode 100644 index 00000000..bf66a5c1 --- /dev/null +++ b/backend/src/modules/blocking-reporting/services/block.service.spec.ts @@ -0,0 +1,250 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { BlockService } from './block.service'; +import { Block } from '../entities/block.entity'; +import { AuditService } from '../../audit/audit.service'; +import { AuditAction } from '../../audit/entities/audit-log.entity'; + +describe('BlockService', () => { + let service: BlockService; + let blockRepository: Repository; + let auditService: AuditService; + + const mockBlockRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + findAndCount: jest.fn(), + }; + + const mockAuditService = { + log: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BlockService, + { + provide: getRepositoryToken(Block), + useValue: mockBlockRepository, + }, + { + provide: AuditService, + useValue: mockAuditService, + }, + ], + }).compile(); + + service = module.get(BlockService); + blockRepository = module.get>(getRepositoryToken(Block)); + auditService = module.get(AuditService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createBlock', () => { + const blockerId = 'blocker-uuid'; + const blockedUserId = 'blocked-uuid'; + const createBlockDto = { blockedUserId }; + + it('should reject self-blocking', async () => { + const selfBlockDto = { blockedUserId: blockerId }; + + await expect( + service.createBlock(blockerId, selfBlockDto), + ).rejects.toThrow(BadRequestException); + await expect( + service.createBlock(blockerId, selfBlockDto), + ).rejects.toThrow('Users cannot block themselves'); + }); + + it('should return existing block if already exists (idempotent)', async () => { + const existingBlock = { + id: 'block-uuid', + blocker: blockerId, + blockedUser: blockedUserId, + createdAt: new Date(), + }; + + mockBlockRepository.findOne.mockResolvedValue(existingBlock); + + const result = await service.createBlock(blockerId, createBlockDto); + + expect(result).toEqual(existingBlock); + expect(mockBlockRepository.findOne).toHaveBeenCalledWith({ + where: { + blocker: blockerId, + blockedUser: blockedUserId, + }, + }); + expect(mockBlockRepository.create).not.toHaveBeenCalled(); + expect(mockBlockRepository.save).not.toHaveBeenCalled(); + expect(mockAuditService.log).not.toHaveBeenCalled(); + }); + + it('should create new block record with timestamp', async () => { + const newBlock = { + blocker: blockerId, + blockedUser: blockedUserId, + }; + + const savedBlock = { + id: 'new-block-uuid', + ...newBlock, + createdAt: new Date(), + }; + + mockBlockRepository.findOne.mockResolvedValue(null); + mockBlockRepository.create.mockReturnValue(newBlock); + mockBlockRepository.save.mockResolvedValue(savedBlock); + + const result = await service.createBlock(blockerId, createBlockDto); + + expect(result).toEqual(savedBlock); + expect(mockBlockRepository.create).toHaveBeenCalledWith({ + blocker: blockerId, + blockedUser: blockedUserId, + }); + expect(mockBlockRepository.save).toHaveBeenCalledWith(newBlock); + }); + + it('should call audit service to log block action', async () => { + const newBlock = { + blocker: blockerId, + blockedUser: blockedUserId, + }; + + const savedBlock = { + id: 'new-block-uuid', + ...newBlock, + createdAt: new Date(), + }; + + mockBlockRepository.findOne.mockResolvedValue(null); + mockBlockRepository.create.mockReturnValue(newBlock); + mockBlockRepository.save.mockResolvedValue(savedBlock); + + await service.createBlock(blockerId, createBlockDto); + + expect(mockAuditService.log).toHaveBeenCalledWith( + blockerId, + 'Block', + savedBlock.id, + AuditAction.CREATE, + ); + }); + + it('should validate different users', async () => { + const differentBlockerId = 'different-blocker-uuid'; + const newBlock = { + blocker: differentBlockerId, + blockedUser: blockedUserId, + }; + + const savedBlock = { + id: 'new-block-uuid', + ...newBlock, + createdAt: new Date(), + }; + + mockBlockRepository.findOne.mockResolvedValue(null); + mockBlockRepository.create.mockReturnValue(newBlock); + mockBlockRepository.save.mockResolvedValue(savedBlock); + + const result = await service.createBlock( + differentBlockerId, + createBlockDto, + ); + + expect(result).toEqual(savedBlock); + expect(result.blocker).not.toEqual(result.blockedUser); + }); + }); + + describe('unblockUser', () => { + const blockerId = 'blocker-uuid'; + const blockedUserId = 'blocked-uuid'; + + it('should successfully unblock a user', async () => { + const existingBlock = { id: 'block-uuid', blocker: blockerId, blockedUser: blockedUserId }; + mockBlockRepository.findOne.mockResolvedValue(existingBlock); + + await service.unblockUser(blockerId, blockedUserId); + + expect(mockBlockRepository.findOne).toHaveBeenCalledWith({ + where: { blocker: blockerId, blockedUser: blockedUserId }, + }); + expect(mockBlockRepository.remove).toHaveBeenCalledWith(existingBlock); + expect(mockAuditService.log).toHaveBeenCalledWith( + blockerId, + 'Unblock', + existingBlock.id, + AuditAction.DELETE, + ); + }); + + it('should throw BadRequestException if block record not found', async () => { + mockBlockRepository.findOne.mockResolvedValue(null); + + await expect(service.unblockUser(blockerId, blockedUserId)).rejects.toThrow( + BadRequestException, + ); + expect(mockBlockRepository.remove).not.toHaveBeenCalled(); + expect(mockAuditService.log).not.toHaveBeenCalled(); + }); + }); + + describe('getBlockedUsers', () => { + const blockerId = 'blocker-uuid'; + + it('should return a list of blocked users', async () => { + const mockedData = [ + { + id: 'block-1', + blocker: blockerId, + blockedUser: 'user-1', + blockedUserEntity: { id: 'user-1', password: 'pwd', firstName: 'John' }, + }, + ]; + mockBlockRepository.findAndCount.mockResolvedValue([mockedData, 1]); + + const result = await service.getBlockedUsers(blockerId, 10, 1); + + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + expect(result.data[0].blockedUserEntity).not.toHaveProperty('password'); + expect(result.data[0].blockedUserEntity).toHaveProperty('firstName', 'John'); + expect(mockBlockRepository.findAndCount).toHaveBeenCalledWith({ + where: { blocker: blockerId }, + relations: ['blockedUserEntity'], + skip: 0, + take: 10, + order: { createdAt: 'DESC' }, + }); + }); + }); + + describe('isBlocked', () => { + const userId1 = 'user-1'; + const userId2 = 'user-2'; + + it('should return true if blocked', async () => { + mockBlockRepository.findOne.mockResolvedValue({ id: 'block-id' }); + const result = await service.isBlocked(userId1, userId2); + expect(result).toBe(true); + }); + + it('should return false if not blocked', async () => { + mockBlockRepository.findOne.mockResolvedValue(null); + const result = await service.isBlocked(userId1, userId2); + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/src/modules/blocking-reporting/services/block.service.ts b/backend/src/modules/blocking-reporting/services/block.service.ts new file mode 100644 index 00000000..0b530700 --- /dev/null +++ b/backend/src/modules/blocking-reporting/services/block.service.ts @@ -0,0 +1,126 @@ +import { + Injectable, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Block } from '../entities/block.entity'; +import { CreateBlockDto } from '../dto/create-block.dto'; +import { AuditService } from '../../audit/audit.service'; +import { AuditAction } from '../../audit/entities/audit-log.entity'; + +@Injectable() +export class BlockService { + constructor( + @InjectRepository(Block) + private readonly blockRepository: Repository, + private readonly auditService: AuditService, + ) {} + + async createBlock( + blockerId: string, + createBlockDto: CreateBlockDto, + ): Promise { + const { blockedUserId } = createBlockDto; + + // Validate that blocker and blocked user are different (reject self-blocking) + if (blockerId === blockedUserId) { + throw new BadRequestException('Users cannot block themselves'); + } + + // Check for existing block record (idempotent operation) + const existingBlock = await this.blockRepository.findOne({ + where: { + blocker: blockerId, + blockedUser: blockedUserId, + }, + }); + + if (existingBlock) { + // Return existing block record for idempotent operation + return existingBlock; + } + + // Create block record with timestamp + const block = this.blockRepository.create({ + blocker: blockerId, + blockedUser: blockedUserId, + }); + + const savedBlock = await this.blockRepository.save(block); + + // Call audit service to log block action + await this.auditService.log( + blockerId, + 'Block', + savedBlock.id, + AuditAction.CREATE, + ); + + // Return created block record + return savedBlock; + } + + async unblockUser(blockerId: string, blockedUserId: string): Promise { + const block = await this.blockRepository.findOne({ + where: { blocker: blockerId, blockedUser: blockedUserId }, + }); + + if (!block) { + throw new BadRequestException('Block record not found'); + } + + await this.blockRepository.remove(block); + + await this.auditService.log( + blockerId, + 'Unblock', + block.id, + AuditAction.DELETE, + ); + } + + async getBlockedUsers( + blockerId: string, + limit: number = 10, + page: number = 1, + ): Promise<{ data: Block[]; total: number; page: number; limit: number }> { + const skip = (page - 1) * limit; + + const [data, total] = await this.blockRepository.findAndCount({ + where: { blocker: blockerId }, + relations: ['blockedUserEntity'], + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + // Strip sensitive info from blocked user entity + data.forEach((block) => { + if (block.blockedUserEntity) { + delete block.blockedUserEntity.password; + delete block.blockedUserEntity.passwordResetToken; + delete block.blockedUserEntity.emailVerificationToken; + } + }); + + return { + data, + total, + page, + limit, + }; + } + + async isBlocked(userId1: string, userId2: string): Promise { + const block = await this.blockRepository.findOne({ + where: [ + { blocker: userId1, blockedUser: userId2 }, + { blocker: userId2, blockedUser: userId1 }, + ], + }); + + return !!block; + } +} diff --git a/backend/src/modules/blocking-reporting/services/index.ts b/backend/src/modules/blocking-reporting/services/index.ts new file mode 100644 index 00000000..979d311f --- /dev/null +++ b/backend/src/modules/blocking-reporting/services/index.ts @@ -0,0 +1,2 @@ +export * from './block.service'; +export * from './report.service'; diff --git a/backend/src/modules/blocking-reporting/services/report.service.spec.ts b/backend/src/modules/blocking-reporting/services/report.service.spec.ts new file mode 100644 index 00000000..a229ad7d --- /dev/null +++ b/backend/src/modules/blocking-reporting/services/report.service.spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { ReportService } from './report.service'; +import { Report } from '../entities/report.entity'; +import { ReportNote } from '../entities/report-note.entity'; +import { AuditService } from '../../audit/audit.service'; +import { ReportCategory } from '../enums/report-category.enum'; +import { ReportStatus } from '../enums/report-status.enum'; + +describe('ReportService', () => { + let service: ReportService; + let reportRepository: Repository; + let reportNoteRepository: Repository; + let auditService: AuditService; + + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn(), + select: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn(), + }; + + const mockReportRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + const mockReportNoteRepository = { + create: jest.fn(), + save: jest.fn(), + }; + + const mockAuditService = { + log: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReportService, + { + provide: getRepositoryToken(Report), + useValue: mockReportRepository, + }, + { + provide: getRepositoryToken(ReportNote), + useValue: mockReportNoteRepository, + }, + { + provide: AuditService, + useValue: mockAuditService, + }, + ], + }).compile(); + + service = module.get(ReportService); + reportRepository = module.get>(getRepositoryToken(Report)); + reportNoteRepository = module.get>( + getRepositoryToken(ReportNote), + ); + auditService = module.get(AuditService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createReport', () => { + it('should create a new report', async () => { + const reporterId = 'reporter-1'; + const dto = { + reportedUserId: 'reported-1', + category: ReportCategory.SPAM, + description: 'spamming', + }; + const newReport = { id: 'report-1', ...dto }; + + mockReportRepository.create.mockReturnValue(newReport); + mockReportRepository.save.mockResolvedValue(newReport); + + const result = await service.createReport(reporterId, dto); + + expect(result).toEqual(newReport); + expect(mockReportRepository.create).toHaveBeenCalled(); + expect(mockReportRepository.save).toHaveBeenCalledWith(newReport); + expect(mockAuditService.log).toHaveBeenCalled(); + }); + }); + + describe('getReports', () => { + it('should return paginated reports with filtered criteria', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[{ id: 'r1' }], 1]); + + const filterDto = { status: ReportStatus.PENDING, category: ReportCategory.SPAM }; + const result = await service.getReports(filterDto, 10, 1); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('report.status = :status', { status: ReportStatus.PENDING }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('report.category = :category', { category: ReportCategory.SPAM }); + }); + }); + + describe('getReportById', () => { + it('should return a report', async () => { + mockReportRepository.findOne.mockResolvedValue({ id: 'r1' }); + const result = await service.getReportById('r1'); + expect(result.id).toEqual('r1'); + }); + + it('should throw NotFoundException if report not found', async () => { + mockReportRepository.findOne.mockResolvedValue(null); + await expect(service.getReportById('r1')).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateReportStatus', () => { + it('should update status and add a note', async () => { + const report = { id: 'r1', status: ReportStatus.PENDING }; + const updatedReport = { ...report, status: ReportStatus.RESOLVED }; + + jest.spyOn(service, 'getReportById').mockResolvedValue(report as any); + mockReportRepository.save.mockResolvedValue(updatedReport); + mockReportNoteRepository.create.mockReturnValue({ id: 'note-1' }); + + const result = await service.updateReportStatus('admin-1', 'r1', { status: ReportStatus.RESOLVED }, 'Good'); + + expect(result.status).toBe(ReportStatus.RESOLVED); + expect(mockReportRepository.save).toHaveBeenCalled(); + expect(mockReportNoteRepository.create).toHaveBeenCalled(); + expect(mockReportNoteRepository.save).toHaveBeenCalled(); + expect(mockAuditService.log).toHaveBeenCalled(); + }); + }); + + describe('getReportStatistics', () => { + it('should return report statistics', async () => { + mockQueryBuilder.getRawMany + .mockResolvedValueOnce([{ status: 'pending', count: '5' }]) + .mockResolvedValueOnce([{ category: 'spam', count: '5' }]); + + const result = await service.getReportStatistics({}); + + expect(result.total).toBe(5); + expect(result.byStatus).toEqual([{ status: 'pending', count: '5' }]); + expect(result.byCategory).toEqual([{ category: 'spam', count: '5' }]); + }); + }); +}); diff --git a/backend/src/modules/blocking-reporting/services/report.service.ts b/backend/src/modules/blocking-reporting/services/report.service.ts new file mode 100644 index 00000000..d5ec7aef --- /dev/null +++ b/backend/src/modules/blocking-reporting/services/report.service.ts @@ -0,0 +1,199 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Report } from '../entities/report.entity'; +import { ReportNote } from '../entities/report-note.entity'; +import { CreateReportDto } from '../dto/create-report.dto'; +import { ReportFilterDto } from '../dto/report-filter.dto'; +import { UpdateReportStatusDto } from '../dto/update-report-status.dto'; +import { StatisticsQueryDto } from '../dto/statistics-query.dto'; +import { AuditService } from '../../audit/audit.service'; +import { AuditAction } from '../../audit/entities/audit-log.entity'; + +@Injectable() +export class ReportService { + constructor( + @InjectRepository(Report) + private readonly reportRepository: Repository, + @InjectRepository(ReportNote) + private readonly reportNoteRepository: Repository, + private readonly auditService: AuditService, + ) { } + + async createReport( + reporterId: string, + createReportDto: CreateReportDto, + ): Promise { + const report = this.reportRepository.create({ + reporter: { id: reporterId }, + reportedUser: { id: createReportDto.reportedUserId }, + category: createReportDto.category, + description: createReportDto.description, + }); + + const savedReport = await this.reportRepository.save(report); + + await this.auditService.log( + reporterId, + 'Report', + savedReport.id, + AuditAction.CREATE, + ); + + return savedReport; + } + + async getReports( + filterDto: ReportFilterDto, + limit: number = 10, + page: number = 1, + ): Promise<{ data: Report[]; total: number; page: number; limit: number }> { + const query = this.reportRepository + .createQueryBuilder('report') + .leftJoinAndSelect('report.reporter', 'reporter') + .leftJoinAndSelect('report.reportedUser', 'reportedUser'); + + if (filterDto.status) { + query.andWhere('report.status = :status', { status: filterDto.status }); + } + + if (filterDto.category) { + query.andWhere('report.category = :category', { + category: filterDto.category, + }); + } + + if (filterDto.startDate) { + query.andWhere('report.createdAt >= :startDate', { + startDate: filterDto.startDate, + }); + } + + if (filterDto.endDate) { + query.andWhere('report.createdAt <= :endDate', { + endDate: filterDto.endDate, + }); + } + + query.skip((page - 1) * limit).take(limit); + query.orderBy('report.createdAt', 'DESC'); + + const [data, total] = await query.getManyAndCount(); + + // Clean sensitive user info + data.forEach(report => { + if (report.reporter) { + delete report.reporter.password; + delete report.reporter.passwordResetToken; + delete report.reporter.emailVerificationToken; + } + if (report.reportedUser) { + delete report.reportedUser.password; + delete report.reportedUser.passwordResetToken; + delete report.reportedUser.emailVerificationToken; + } + }); + + return { + data, + total, + page, + limit, + }; + } + + async getReportById(reportId: string): Promise { + const report = await this.reportRepository.findOne({ + where: { id: reportId }, + relations: ['reporter', 'reportedUser'], + }); + + if (!report) { + throw new NotFoundException('Report not found'); + } + + // clean up password + if (report.reporter) { + delete report.reporter.password; + delete report.reporter.passwordResetToken; + delete report.reporter.emailVerificationToken; + } + if (report.reportedUser) { + delete report.reportedUser.password; + delete report.reportedUser.passwordResetToken; + delete report.reportedUser.emailVerificationToken; + } + + return report; + } + + async updateReportStatus( + adminId: string, + reportId: string, + updateDto: UpdateReportStatusDto, + note?: string, + ): Promise { + const report = await this.getReportById(reportId); + + report.status = updateDto.status; + const updatedReport = await this.reportRepository.save(report); + + if (note) { + const reportNote = this.reportNoteRepository.create({ + report: updatedReport, + admin: { id: adminId }, + note, + }); + await this.reportNoteRepository.save(reportNote); + } + + await this.auditService.log( + adminId, + 'ReportStatusUpdate', + updatedReport.id, + AuditAction.UPDATE, + ); + + return updatedReport; + } + + async getReportStatistics(queryDto: StatisticsQueryDto): Promise { + const query = this.reportRepository.createQueryBuilder('report'); + + if (queryDto.startDate) { + query.andWhere('report.createdAt >= :startDate', { + startDate: queryDto.startDate, + }); + } + + if (queryDto.endDate) { + query.andWhere('report.createdAt <= :endDate', { + endDate: queryDto.endDate, + }); + } + + if (queryDto.userId) { + query.andWhere('report.reported_user_id = :userId', { + userId: queryDto.userId, + }); + } + + const byStatus = await query + .select('report.status, COUNT(report.id) as count') + .groupBy('report.status') + .getRawMany(); + + const byCategory = await query + .select('report.category, COUNT(report.id) as count') + .groupBy('report.category') + .getRawMany(); + + const totalReports = byStatus.reduce((acc, curr) => acc + parseInt(curr.count, 10), 0); + + return { + total: totalReports, + byStatus, + byCategory, + }; + } +}