improvement: appointments history component added to patients feature.
This commit is contained in:
@@ -1,20 +1,24 @@
|
||||
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { ErrorCode } from '../../../common/errors';
|
||||
|
||||
export class CreatePatientDto {
|
||||
@IsString()
|
||||
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||
@MaxLength(80)
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||
@MaxLength(80)
|
||||
lastName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||
@MaxLength(30)
|
||||
mobile: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -37,6 +37,16 @@ export class PatientsController {
|
||||
return this.patientsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id/appointments')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'List this patient\'s appointments for the current clinic (requires TAB_PATIENTS_READ; not gated by appointments permission)',
|
||||
})
|
||||
listAppointments(@Param('id') id: string, @Req() req: { user: { id: string; organizationId?: string } }) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.listAppointments(id, organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one patient by id' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||
@@ -10,6 +12,8 @@ export class PatientsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(createPatientDto: CreatePatientDto, organizationId: string) {
|
||||
const firstName = this.requireNonEmptyName(createPatientDto.firstName, 'firstName');
|
||||
const lastName = this.requireNonEmptyName(createPatientDto.lastName, 'lastName');
|
||||
const mobile = this.resolveMobile(createPatientDto.mobile);
|
||||
|
||||
const existing = await this.prisma.patient.findUnique({
|
||||
@@ -22,8 +26,8 @@ export class PatientsService {
|
||||
|
||||
const patient = await this.prisma.patient.create({
|
||||
data: {
|
||||
firstName: createPatientDto.firstName.trim(),
|
||||
lastName: createPatientDto.lastName.trim(),
|
||||
firstName,
|
||||
lastName,
|
||||
mobile,
|
||||
email: createPatientDto.email?.trim() || null,
|
||||
notes: createPatientDto.notes?.trim() || null,
|
||||
@@ -92,10 +96,10 @@ export class PatientsService {
|
||||
} = {};
|
||||
|
||||
if (updatePatientDto.firstName !== undefined) {
|
||||
data.firstName = updatePatientDto.firstName.trim();
|
||||
data.firstName = this.requireNonEmptyName(updatePatientDto.firstName, 'firstName');
|
||||
}
|
||||
if (updatePatientDto.lastName !== undefined) {
|
||||
data.lastName = updatePatientDto.lastName.trim();
|
||||
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
|
||||
}
|
||||
if (updatePatientDto.mobile !== undefined) {
|
||||
data.mobile = this.resolveMobile(updatePatientDto.mobile);
|
||||
@@ -120,6 +124,42 @@ export class PatientsService {
|
||||
return { success: true, data: patient };
|
||||
}
|
||||
|
||||
async listAppointments(
|
||||
patientId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanViewPatients(actorUserId, organizationId);
|
||||
await this.ensurePatient(patientId);
|
||||
|
||||
const items = await this.prisma.appointment.findMany({
|
||||
where: { organizationId, patientId },
|
||||
orderBy: [{ startAt: 'desc' }],
|
||||
});
|
||||
|
||||
const providerIds = [...new Set(items.map((item) => item.providerUserId))];
|
||||
const providers =
|
||||
providerIds.length === 0
|
||||
? []
|
||||
: await this.prisma.user.findMany({
|
||||
where: { id: { in: providerIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const providerNameById = new Map(providers.map((p) => [p.id, p.name]));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: items.map((item) => ({
|
||||
id: item.id,
|
||||
startAt: item.startAt,
|
||||
endAt: item.endAt,
|
||||
purpose: item.purpose,
|
||||
providerUserId: item.providerUserId,
|
||||
providerName: providerNameById.get(item.providerUserId) ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
@@ -148,15 +188,51 @@ export class PatientsService {
|
||||
}
|
||||
|
||||
private resolveMobile(raw: string): string {
|
||||
if (!raw?.trim()) {
|
||||
throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [
|
||||
{ field: 'mobile', code: ErrorCode.VALIDATION_FIELD_REQUIRED },
|
||||
]);
|
||||
}
|
||||
|
||||
const mobile = normalizeMobile(raw);
|
||||
if (!mobile || !isValidMobile(mobile)) {
|
||||
throw new BadRequestException(
|
||||
'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).',
|
||||
);
|
||||
throw new AppException(ErrorCode.VALIDATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST, [
|
||||
{ field: 'mobile', code: ErrorCode.VALIDATION_MOBILE_INVALID },
|
||||
]);
|
||||
}
|
||||
return mobile;
|
||||
}
|
||||
|
||||
private requireNonEmptyName(value: string, field: 'firstName' | 'lastName'): string {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
if (!trimmed) {
|
||||
throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [
|
||||
{ field, code: ErrorCode.VALIDATION_FIELD_REQUIRED },
|
||||
]);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private async assertCanViewPatients(userId: string, organizationId: string) {
|
||||
const membership = await this.prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: { userId, organizationId },
|
||||
},
|
||||
include: {
|
||||
organization: { include: { type: true, plan: true } },
|
||||
permissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
if (!hasEffectivePermission(membership, 'TAB_PATIENTS_READ')) {
|
||||
throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePatient(id: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id },
|
||||
|
||||
Reference in New Issue
Block a user