Skip to content

Commit 6437bdb

Browse files
author
llins
authored
Merge pull request #229 from Mac-5/feature/medical-records-management
Feature/medical records management
2 parents 75a0116 + 29c225e commit 6437bdb

6 files changed

Lines changed: 167 additions & 83 deletions

File tree

backend/src/auth/constants/permissions.enum.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ export enum Permission {
55
CREATE_PETS = 'CREATE_PETS',
66
SHARE_RECORDS = 'SHARE_RECORDS',
77

8+
// Medical Records
9+
READ_MEDICAL_RECORDS = 'READ_MEDICAL_RECORDS',
10+
CREATE_MEDICAL_RECORDS = 'CREATE_MEDICAL_RECORDS',
11+
UPDATE_MEDICAL_RECORDS = 'UPDATE_MEDICAL_RECORDS',
12+
DELETE_MEDICAL_RECORDS = 'DELETE_MEDICAL_RECORDS',
13+
814
// Veterinarian
915
READ_ALL_PETS = 'READ_ALL_PETS',
10-
UPDATE_MEDICAL_RECORDS = 'UPDATE_MEDICAL_RECORDS',
1116
CREATE_TREATMENTS = 'CREATE_TREATMENTS',
1217
PRESCRIBE = 'PRESCRIBE',
1318

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { IsString, IsNotEmpty } from 'class-validator';
2+
3+
/** Appends an immutable observation to an existing record's notes */
4+
export class AppendRecordDto {
5+
@IsString() @IsNotEmpty()
6+
observation: string;
7+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { IsOptional, IsEnum, IsString, IsDateString, IsUUID, IsBoolean } from 'class-validator';
2+
import { Transform } from 'class-transformer';
3+
import { RecordType, AccessLevel } from '../entities/medical-record.entity';
4+
5+
export class SearchMedicalRecordsDto {
6+
@IsOptional() @IsUUID()
7+
petId?: string;
8+
9+
@IsOptional() @IsEnum(RecordType)
10+
recordType?: RecordType;
11+
12+
@IsOptional() @IsEnum(AccessLevel)
13+
accessLevel?: AccessLevel;
14+
15+
@IsOptional() @IsDateString()
16+
startDate?: string;
17+
18+
@IsOptional() @IsDateString()
19+
endDate?: string;
20+
21+
/** Full-text search across diagnosis, treatment, notes */
22+
@IsOptional() @IsString()
23+
q?: string;
24+
25+
@IsOptional() @IsUUID()
26+
vetId?: string;
27+
28+
@IsOptional() @Transform(({ value }) => value === 'true')
29+
@IsBoolean()
30+
verified?: boolean;
31+
}

backend/src/modules/medical-records/medical-records.controller.ts

Lines changed: 80 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import {
1010
UseInterceptors,
1111
UploadedFiles,
1212
BadRequestException,
13+
UseGuards,
1314
} from '@nestjs/common';
1415
import { StreamableFile } from '@nestjs/common/file-stream';
1516
import { FilesInterceptor } from '@nestjs/platform-express';
1617
import { MedicalRecordsService } from './medical-records.service';
1718
import { MedicalRecordsExportService } from './medical-records-export.service';
1819
import { CreateMedicalRecordDto } from './dto/create-medical-record.dto';
1920
import { UpdateMedicalRecordDto } from './dto/update-medical-record.dto';
21+
import { AppendRecordDto } from './dto/append-record.dto';
22+
import { SearchMedicalRecordsDto } from './dto/search-medical-records.dto';
2023
import { VerifyRecordDto, RevokeVerificationDto } from './dto/verify-record.dto';
2124
import {
2225
ExportMedicalRecordsDto,
@@ -25,72 +28,75 @@ import {
2528
} from './dto/export-medical-records.dto';
2629
import { RecordType } from './entities/medical-record.entity';
2730
import { PetSpecies } from '../pets/entities/pet.entity';
28-
31+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
32+
import { RolesGuard } from '../../auth/guards/roles.guard';
33+
import { Roles } from '../../auth/decorators/roles.decorator';
34+
import { Permissions } from '../../auth/decorators/permissions.decorator';
35+
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
36+
import { RoleName } from '../../auth/constants/roles.enum';
37+
import { Permission } from '../../auth/constants/permissions.enum';
38+
39+
@UseGuards(JwtAuthGuard, RolesGuard)
2940
@Controller('medical-records')
3041
export class MedicalRecordsController {
3142
constructor(
3243
private readonly medicalRecordsService: MedicalRecordsService,
3344
private readonly exportService: MedicalRecordsExportService,
34-
) { }
45+
) {}
3546

3647
@Post()
48+
@Permissions(Permission.CREATE_MEDICAL_RECORDS)
3749
@UseInterceptors(FilesInterceptor('files', 10))
3850
async create(
3951
@Body() createMedicalRecordDto: CreateMedicalRecordDto,
52+
@CurrentUser('id') userId: string,
4053
@UploadedFiles() files?: Express.Multer.File[],
4154
) {
42-
// Handle file uploads
43-
if (files && files.length > 0) {
44-
const attachments = await Promise.all(
45-
files.map((file) => this.medicalRecordsService.saveAttachment(file)),
55+
if (files?.length) {
56+
createMedicalRecordDto.attachments = await Promise.all(
57+
files.map((f) => this.medicalRecordsService.saveAttachment(f)),
4658
);
47-
createMedicalRecordDto.attachments = attachments;
4859
}
60+
return this.medicalRecordsService.create(createMedicalRecordDto, userId);
61+
}
4962

50-
return this.medicalRecordsService.create(createMedicalRecordDto);
63+
/** Rich search: ?q=text&petId=&recordType=&accessLevel=&vetId=&verified=&startDate=&endDate= */
64+
@Get('search')
65+
@Permissions(Permission.READ_MEDICAL_RECORDS)
66+
search(@Query() dto: SearchMedicalRecordsDto) {
67+
return this.medicalRecordsService.search(dto);
5168
}
5269

5370
@Get()
71+
@Permissions(Permission.READ_MEDICAL_RECORDS)
5472
findAll(
5573
@Query('petId') petId?: string,
5674
@Query('recordType') recordType?: RecordType,
5775
@Query('startDate') startDate?: string,
5876
@Query('endDate') endDate?: string,
5977
) {
60-
return this.medicalRecordsService.findAll(
61-
petId,
62-
recordType,
63-
startDate,
64-
endDate,
65-
);
78+
return this.medicalRecordsService.findAll(petId, recordType, startDate, endDate);
6679
}
6780

6881
@Get('templates/:petType')
82+
@Permissions(Permission.READ_MEDICAL_RECORDS)
6983
getTemplates(@Param('petType') petType: PetSpecies) {
7084
return this.medicalRecordsService.getTemplatesByPetType(petType);
7185
}
7286

7387
@Post('templates')
88+
@Roles(RoleName.Veterinarian, RoleName.Admin)
7489
createTemplate(
7590
@Body('petType') petType: PetSpecies,
7691
@Body('recordType') recordType: RecordType,
7792
@Body('templateFields') templateFields: Record<string, any>,
7893
@Body('description') description?: string,
7994
) {
80-
return this.medicalRecordsService.createTemplate(
81-
petType,
82-
recordType,
83-
templateFields,
84-
description,
85-
);
86-
}
87-
88-
/**
89-
* Export medical records as PDF, CSV, or FHIR.
90-
* GET: use query params (format, petId, recordType, startDate, endDate).
91-
* POST: use body for full options including recordIds for batch export.
92-
*/
95+
return this.medicalRecordsService.createTemplate(petType, recordType, templateFields, description);
96+
}
97+
9398
@Get('export')
99+
@Permissions(Permission.READ_MEDICAL_RECORDS)
94100
async exportGet(
95101
@Query('format') format: ExportFormat,
96102
@Query('recordIds') recordIdsStr?: string,
@@ -100,21 +106,10 @@ export class MedicalRecordsController {
100106
@Query('endDate') endDate?: string,
101107
@Query('includeAttachments') includeAttachments?: string,
102108
) {
103-
const recordIds = recordIdsStr
104-
? recordIdsStr
105-
.split(',')
106-
.map((s) => s.trim())
107-
.filter(Boolean)
108-
: undefined;
109+
const recordIds = recordIdsStr?.split(',').map((s) => s.trim()).filter(Boolean);
109110
const dto: ExportMedicalRecordsDto = {
110-
format,
111-
recordIds,
112-
petId,
113-
recordType,
114-
startDate,
115-
endDate,
116-
includeAttachments:
117-
includeAttachments === 'true' || includeAttachments === undefined,
111+
format, recordIds, petId, recordType, startDate, endDate,
112+
includeAttachments: includeAttachments !== 'false',
118113
};
119114
const result = await this.exportService.export(dto);
120115
return new StreamableFile(result.buffer, {
@@ -124,6 +119,7 @@ export class MedicalRecordsController {
124119
}
125120

126121
@Post('export')
122+
@Permissions(Permission.READ_MEDICAL_RECORDS)
127123
async exportPost(@Body() dto: ExportMedicalRecordsDto) {
128124
const result = await this.exportService.export(dto);
129125
return new StreamableFile(result.buffer, {
@@ -132,79 +128,98 @@ export class MedicalRecordsController {
132128
});
133129
}
134130

135-
/**
136-
* Generate export and send it by email.
137-
* Requires MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS to be set.
138-
*/
139131
@Post('export/email')
132+
@Permissions(Permission.READ_MEDICAL_RECORDS)
140133
async exportEmail(
141134
@Body() dto: EmailExportMedicalRecordsDto,
142135
@Query('userEmail') userEmail?: string,
143136
) {
144137
const recipient = dto.to || userEmail;
145-
if (!recipient) {
146-
throw new BadRequestException(
147-
'Provide "to" in body or userEmail query param.',
148-
);
149-
}
138+
if (!recipient) throw new BadRequestException('Provide "to" in body or userEmail query param.');
150139
return this.exportService.sendExportByEmail(dto, recipient);
151140
}
152141

153-
// --- Vet Verification / Signature ---
142+
// --- Vet Verification ---
154143

155144
@Post(':id/verify')
145+
@Roles(RoleName.Veterinarian, RoleName.Admin)
156146
verifyRecord(
157147
@Param('id') id: string,
158-
@Body() verifyRecordDto: VerifyRecordDto,
148+
@Body() dto: VerifyRecordDto,
149+
@CurrentUser('id') userId: string,
159150
) {
160-
return this.medicalRecordsService.verifyRecord(id, verifyRecordDto);
151+
return this.medicalRecordsService.verifyRecord(id, dto, userId);
161152
}
162153

163154
@Post(':id/revoke-verification')
155+
@Roles(RoleName.Veterinarian, RoleName.Admin)
164156
revokeVerification(
165157
@Param('id') id: string,
166-
@Body() revokeDto: RevokeVerificationDto,
158+
@Body() dto: RevokeVerificationDto,
159+
@CurrentUser('id') userId: string,
160+
) {
161+
return this.medicalRecordsService.revokeVerification(id, dto, userId);
162+
}
163+
164+
// --- Append-only observation ---
165+
166+
@Post(':id/append')
167+
@Permissions(Permission.CREATE_MEDICAL_RECORDS)
168+
appendObservation(
169+
@Param('id') id: string,
170+
@Body() dto: AppendRecordDto,
171+
@CurrentUser('id') userId: string,
167172
) {
168-
return this.medicalRecordsService.revokeVerification(id, revokeDto);
173+
return this.medicalRecordsService.append(id, dto, userId);
169174
}
170175

171-
// --- Record Versioning ---
176+
// --- Versioning / history ---
177+
178+
@Get(':id/history')
179+
@Permissions(Permission.READ_MEDICAL_RECORDS)
180+
getHistory(@Param('id') id: string) {
181+
return this.medicalRecordsService.getRecordVersions(id);
182+
}
172183

173184
@Get(':id/versions')
185+
@Permissions(Permission.READ_MEDICAL_RECORDS)
174186
getVersions(@Param('id') id: string) {
175187
return this.medicalRecordsService.getRecordVersions(id);
176188
}
177189

178190
@Get(':id/versions/:versionId')
179-
getVersion(
180-
@Param('id') id: string,
181-
@Param('versionId') versionId: string,
182-
) {
191+
@Permissions(Permission.READ_MEDICAL_RECORDS)
192+
getVersion(@Param('id') id: string, @Param('versionId') versionId: string) {
183193
return this.medicalRecordsService.getRecordVersion(id, versionId);
184194
}
185195

186-
// --- Core record endpoints ---
196+
// --- Core CRUD ---
187197

188198
@Get(':id')
199+
@Permissions(Permission.READ_MEDICAL_RECORDS)
189200
findOne(@Param('id') id: string) {
190201
return this.medicalRecordsService.findOne(id);
191202
}
192203

193204
@Get(':id/qr')
205+
@Permissions(Permission.READ_MEDICAL_RECORDS)
194206
getQRCode(@Param('id') id: string) {
195207
return this.medicalRecordsService.getQRCode(id);
196208
}
197209

198210
@Patch(':id')
211+
@Permissions(Permission.UPDATE_MEDICAL_RECORDS)
199212
update(
200213
@Param('id') id: string,
201-
@Body() updateMedicalRecordDto: UpdateMedicalRecordDto,
214+
@Body() dto: UpdateMedicalRecordDto,
215+
@CurrentUser('id') userId: string,
202216
) {
203-
return this.medicalRecordsService.update(id, updateMedicalRecordDto);
217+
return this.medicalRecordsService.update(id, dto, userId);
204218
}
205219

206220
@Delete(':id')
207-
remove(@Param('id') id: string) {
208-
return this.medicalRecordsService.remove(id);
221+
@Permissions(Permission.DELETE_MEDICAL_RECORDS)
222+
remove(@Param('id') id: string, @CurrentUser('id') userId: string) {
223+
return this.medicalRecordsService.remove(id, userId);
209224
}
210225
}

backend/src/modules/medical-records/medical-records.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Module, OnModuleInit } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33
import { ConfigModule } from '@nestjs/config';
4-
import { JwtModule } from '@nestjs/jwt';
54
import { MedicalRecordsService } from './medical-records.service';
65
import { MedicalRecordsController } from './medical-records.controller';
76
import { MedicalRecordsExportService } from './medical-records-export.service';
@@ -12,6 +11,7 @@ import { RecordTemplate } from './entities/record-template.entity';
1211
import { RecordVersion } from './entities/record-version.entity';
1312
import { AuditModule } from '../audit/audit.module';
1413
import { SecurityModule } from '../../security/security.module';
14+
import { AuthModule } from '../../auth/auth.module';
1515
import { EncryptionService } from '../../security/services/encryption.service';
1616
import { KeyRotationService } from '../../security/services/key-rotation.service';
1717
import { setEncryptionService } from '../../common/transformers/encrypted.transformer';
@@ -23,6 +23,7 @@ import { BlockchainSyncModule } from '../blockchain/blockchain-sync.module';
2323
ConfigModule,
2424
AuditModule,
2525
SecurityModule,
26+
AuthModule,
2627
BlockchainSyncModule,
2728
],
2829
controllers: [MedicalRecordsController, RecordShareController],

0 commit comments

Comments
 (0)