feature: phase0 - Global patients + mobile normalization
This commit is contained in:
@@ -96,7 +96,7 @@ export class AppointmentsService {
|
||||
},
|
||||
include: {
|
||||
patient: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ startAt: 'asc' }],
|
||||
@@ -152,7 +152,7 @@ export class AppointmentsService {
|
||||
},
|
||||
include: {
|
||||
patient: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -215,7 +215,7 @@ export class AppointmentsService {
|
||||
},
|
||||
include: {
|
||||
patient: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -300,9 +300,9 @@ export class AppointmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePatientInOrg(patientId: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id: patientId, organizationId },
|
||||
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
|
||||
@@ -9,10 +9,9 @@ export class CreatePatientDto {
|
||||
@MaxLength(80)
|
||||
lastName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
phone?: string;
|
||||
mobile: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
@@ -25,30 +24,27 @@ export class PatientsController {
|
||||
constructor(private readonly patientsService: PatientsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a patient for current organization' })
|
||||
@ApiOperation({ summary: 'Create or return existing global patient by mobile' })
|
||||
create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.create(createPatientDto, organizationId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List patients with search and pagination' })
|
||||
findAll(@Query() query: ListPatientsDto, @Req() req) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.findAll(query, organizationId);
|
||||
@ApiOperation({ summary: 'Search all patients globally' })
|
||||
findAll(@Query() query: ListPatientsDto) {
|
||||
return this.patientsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one patient by id' })
|
||||
findOne(@Param('id') id: string, @Req() req) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.findOne(id, organizationId);
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.patientsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update patient' })
|
||||
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.update(id, updatePatientDto, organizationId);
|
||||
@ApiOperation({ summary: 'Update global patient record' })
|
||||
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) {
|
||||
return this.patientsService.update(id, updatePatientDto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
|
||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||
@@ -9,34 +10,38 @@ export class PatientsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(createPatientDto: CreatePatientDto, organizationId: string) {
|
||||
const mobile = this.resolveMobile(createPatientDto.mobile);
|
||||
|
||||
const existing = await this.prisma.patient.findUnique({
|
||||
where: { mobile },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { success: true, data: existing, existing: true as const };
|
||||
}
|
||||
|
||||
const patient = await this.prisma.patient.create({
|
||||
data: {
|
||||
...createPatientDto,
|
||||
firstName: createPatientDto.firstName.trim(),
|
||||
lastName: createPatientDto.lastName.trim(),
|
||||
mobile,
|
||||
email: createPatientDto.email?.trim() || null,
|
||||
notes: createPatientDto.notes?.trim() || null,
|
||||
dateOfBirth: createPatientDto.dateOfBirth ? new Date(createPatientDto.dateOfBirth) : null,
|
||||
organizationId,
|
||||
createdByOrganizationId: organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: patient };
|
||||
return { success: true, data: patient, existing: false as const };
|
||||
}
|
||||
|
||||
async findAll(query: ListPatientsDto, organizationId: string) {
|
||||
async findAll(query: ListPatientsDto) {
|
||||
const { page = 1, limit = 10, q } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
organizationId,
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' as const } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' as const } },
|
||||
{ email: { contains: q, mode: 'insensitive' as const } },
|
||||
{ phone: { contains: q, mode: 'insensitive' as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const where = q?.trim()
|
||||
? this.buildSearchWhere(q.trim())
|
||||
: {};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.patient.findMany({
|
||||
@@ -62,9 +67,9 @@ export class PatientsService {
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id, organizationId },
|
||||
async findOne(id: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
@@ -74,35 +79,92 @@ export class PatientsService {
|
||||
return { success: true, data: patient };
|
||||
}
|
||||
|
||||
async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
|
||||
await this.ensurePatient(id, organizationId);
|
||||
async update(id: string, updatePatientDto: UpdatePatientDto) {
|
||||
await this.ensurePatient(id);
|
||||
|
||||
const data: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
mobile?: string;
|
||||
email?: string | null;
|
||||
notes?: string | null;
|
||||
dateOfBirth?: Date | null;
|
||||
} = {};
|
||||
|
||||
if (updatePatientDto.firstName !== undefined) {
|
||||
data.firstName = updatePatientDto.firstName.trim();
|
||||
}
|
||||
if (updatePatientDto.lastName !== undefined) {
|
||||
data.lastName = updatePatientDto.lastName.trim();
|
||||
}
|
||||
if (updatePatientDto.mobile !== undefined) {
|
||||
data.mobile = this.resolveMobile(updatePatientDto.mobile);
|
||||
}
|
||||
if (updatePatientDto.email !== undefined) {
|
||||
data.email = updatePatientDto.email?.trim() || null;
|
||||
}
|
||||
if (updatePatientDto.notes !== undefined) {
|
||||
data.notes = updatePatientDto.notes?.trim() || null;
|
||||
}
|
||||
if (updatePatientDto.dateOfBirth !== undefined) {
|
||||
data.dateOfBirth = updatePatientDto.dateOfBirth
|
||||
? new Date(updatePatientDto.dateOfBirth)
|
||||
: null;
|
||||
}
|
||||
|
||||
const patient = await this.prisma.patient.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...updatePatientDto,
|
||||
dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined,
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
return { success: true, data: patient };
|
||||
}
|
||||
|
||||
private async ensurePatient(id: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
}
|
||||
}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
private buildSearchWhere(q: string) {
|
||||
const orConditions: Array<Record<string, unknown>> = [
|
||||
{ firstName: { contains: q, mode: 'insensitive' as const } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' as const } },
|
||||
{ email: { contains: q, mode: 'insensitive' as const } },
|
||||
];
|
||||
|
||||
const normalized = normalizeMobile(q);
|
||||
if (normalized) {
|
||||
orConditions.push({ mobile: normalized });
|
||||
} else {
|
||||
const digits = mobileSearchDigits(q);
|
||||
if (digits.length >= 3) {
|
||||
orConditions.push({ mobile: { contains: digits } });
|
||||
}
|
||||
}
|
||||
|
||||
return { OR: orConditions };
|
||||
}
|
||||
|
||||
private resolveMobile(raw: string): string {
|
||||
const mobile = normalizeMobile(raw);
|
||||
if (!mobile || !isValidMobile(mobile)) {
|
||||
throw new BadRequestException(
|
||||
'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).',
|
||||
);
|
||||
}
|
||||
return mobile;
|
||||
}
|
||||
|
||||
private async ensurePatient(id: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,9 +549,9 @@ export class TreatmentsService {
|
||||
]);
|
||||
}
|
||||
|
||||
private async ensurePatientInOrg(patientId: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id: patientId, organizationId },
|
||||
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
|
||||
Reference in New Issue
Block a user