feature: phase0 - Global patients + mobile normalization

This commit is contained in:
2026-06-28 14:49:37 +03:30
parent 653d67e15b
commit 64c7e5a257
23 changed files with 535 additions and 260 deletions

View File

@@ -0,0 +1,52 @@
import {
formatMobileForDisplay,
isValidMobile,
mobileSearchDigits,
normalizeMobile,
} from './phone';
describe('normalizeMobile', () => {
it('normalizes 09-prefixed numbers', () => {
expect(normalizeMobile('09121234567')).toBe('+989121234567');
});
it('normalizes without leading zero', () => {
expect(normalizeMobile('9121234567')).toBe('+989121234567');
});
it('normalizes +98 prefix', () => {
expect(normalizeMobile('+989121234567')).toBe('+989121234567');
});
it('normalizes 0098 prefix', () => {
expect(normalizeMobile('00989121234567')).toBe('+989121234567');
});
it('normalizes spaced input', () => {
expect(normalizeMobile('0912 123 4567')).toBe('+989121234567');
});
it('rejects invalid numbers', () => {
expect(normalizeMobile('123')).toBeNull();
expect(normalizeMobile('')).toBeNull();
});
});
describe('isValidMobile', () => {
it('validates normalized mobile', () => {
expect(isValidMobile('+989121234567')).toBe(true);
expect(isValidMobile('09121234567')).toBe(false);
});
});
describe('formatMobileForDisplay', () => {
it('formats E.164 to local spaced form', () => {
expect(formatMobileForDisplay('+989121234567')).toBe('0912 123 4567');
});
});
describe('mobileSearchDigits', () => {
it('strips non-digits', () => {
expect(mobileSearchDigits('+98 912-123-4567')).toBe('989121234567');
});
});

View File

@@ -0,0 +1,54 @@
/** Canonical Iran mobile: +989XXXXXXXXX (12 chars). */
export const IR_MOBILE_REGEX = /^\+989\d{9}$/;
/**
* Normalize user-entered mobile to E.164 for Iran (+98…).
* Accepts 09…, 9…, +98…, 0098… with optional spaces/dashes.
*/
export function normalizeMobile(input: string): string | null {
const trimmed = input?.trim();
if (!trimmed) {
return null;
}
let digits = trimmed.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) {
digits = digits.slice(1);
}
digits = digits.replace(/\D/g, '');
if (digits.startsWith('0098')) {
digits = digits.slice(4);
} else if (digits.startsWith('98') && digits.length >= 12) {
digits = digits.slice(2);
}
if (digits.startsWith('0') && digits.length === 11) {
digits = digits.slice(1);
}
if (digits.length === 10 && digits.startsWith('9')) {
return `+98${digits}`;
}
return null;
}
export function isValidMobile(normalized: string): boolean {
return IR_MOBILE_REGEX.test(normalized);
}
/** Display-friendly local format: 09XX XXX XXXX */
export function formatMobileForDisplay(normalized: string): string {
if (!isValidMobile(normalized)) {
return normalized;
}
const local = `0${normalized.slice(3)}`;
return `${local.slice(0, 4)} ${local.slice(4, 7)} ${local.slice(7)}`;
}
/** Strip to digits only for partial search matching. */
export function mobileSearchDigits(input: string): string {
return input.replace(/\D/g, '');
}

View File

@@ -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) {

View File

@@ -9,10 +9,9 @@ export class CreatePatientDto {
@MaxLength(80)
lastName: string;
@IsOptional()
@IsString()
@MaxLength(30)
phone?: string;
mobile: string;
@IsOptional()
@IsEmail()

View File

@@ -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);
}
}

View File

@@ -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');
}
}
}

View File

@@ -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) {