feature: phase0 - Global patients + mobile normalization
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
-- Global patients: mobile is cloud-wide unique identity; org scope removed.
|
||||||
|
-- Test/dev data only — clear patient-linked rows before reshape.
|
||||||
|
|
||||||
|
DELETE FROM "treatment_case_sends";
|
||||||
|
DELETE FROM "treatment_case_attachments";
|
||||||
|
DELETE FROM "treatment_cases";
|
||||||
|
DELETE FROM "treatments";
|
||||||
|
DELETE FROM "appointments";
|
||||||
|
DELETE FROM "patients";
|
||||||
|
|
||||||
|
ALTER TABLE "patients" DROP CONSTRAINT IF EXISTS "patients_organizationId_fkey";
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS "patients_organizationId_createdAt_idx";
|
||||||
|
DROP INDEX IF EXISTS "patients_organizationId_lastName_firstName_idx";
|
||||||
|
|
||||||
|
ALTER TABLE "patients" DROP COLUMN "organizationId";
|
||||||
|
ALTER TABLE "patients" DROP COLUMN "phone";
|
||||||
|
|
||||||
|
ALTER TABLE "patients" ADD COLUMN "mobile" TEXT NOT NULL;
|
||||||
|
ALTER TABLE "patients" ADD COLUMN "createdByOrganizationId" TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "patients_mobile_key" ON "patients"("mobile");
|
||||||
|
CREATE INDEX "patients_lastName_firstName_idx" ON "patients"("lastName", "firstName");
|
||||||
|
|
||||||
|
ALTER TABLE "patients"
|
||||||
|
ADD CONSTRAINT "patients_createdByOrganizationId_fkey"
|
||||||
|
FOREIGN KEY ("createdByOrganizationId") REFERENCES "organizations"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -60,7 +60,7 @@ model Organization {
|
|||||||
sharedWithMe OrganizationLink[] @relation("OrganizationB")
|
sharedWithMe OrganizationLink[] @relation("OrganizationB")
|
||||||
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
|
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
|
||||||
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
|
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
|
||||||
patients Patient[]
|
createdPatients Patient[] @relation("PatientCreatedBy")
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
caseSends TreatmentCaseSend[]
|
caseSends TreatmentCaseSend[]
|
||||||
@@ -72,24 +72,23 @@ model Organization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Patient {
|
model Patient {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
organizationId String
|
firstName String
|
||||||
firstName String
|
lastName String
|
||||||
lastName String
|
mobile String @unique
|
||||||
phone String?
|
email String?
|
||||||
email String?
|
dateOfBirth DateTime?
|
||||||
dateOfBirth DateTime?
|
notes String?
|
||||||
notes String?
|
isActive Boolean @default(true)
|
||||||
isActive Boolean @default(true)
|
createdByOrganizationId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
createdByOrganization Organization? @relation("PatientCreatedBy", fields: [createdByOrganizationId], references: [id], onDelete: SetNull)
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
|
|
||||||
@@index([organizationId, createdAt])
|
@@index([lastName, firstName])
|
||||||
@@index([organizationId, lastName, firstName])
|
|
||||||
@@map("patients")
|
@@map("patients")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
backend/src/common/phone.spec.ts
Normal file
52
backend/src/common/phone.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
54
backend/src/common/phone.ts
Normal file
54
backend/src/common/phone.ts
Normal 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, '');
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ export class AppointmentsService {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
patient: {
|
patient: {
|
||||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ startAt: 'asc' }],
|
orderBy: [{ startAt: 'asc' }],
|
||||||
@@ -152,7 +152,7 @@ export class AppointmentsService {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
patient: {
|
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: {
|
include: {
|
||||||
patient: {
|
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) {
|
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
||||||
const patient = await this.prisma.patient.findFirst({
|
const patient = await this.prisma.patient.findUnique({
|
||||||
where: { id: patientId, organizationId },
|
where: { id: patientId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!patient) {
|
if (!patient) {
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ export class CreatePatientDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(30)
|
@MaxLength(30)
|
||||||
phone?: string;
|
mobile: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -25,30 +24,27 @@ export class PatientsController {
|
|||||||
constructor(private readonly patientsService: PatientsService) {}
|
constructor(private readonly patientsService: PatientsService) {}
|
||||||
|
|
||||||
@Post()
|
@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) {
|
create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
|
||||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||||
return this.patientsService.create(createPatientDto, organizationId);
|
return this.patientsService.create(createPatientDto, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List patients with search and pagination' })
|
@ApiOperation({ summary: 'Search all patients globally' })
|
||||||
findAll(@Query() query: ListPatientsDto, @Req() req) {
|
findAll(@Query() query: ListPatientsDto) {
|
||||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
return this.patientsService.findAll(query);
|
||||||
return this.patientsService.findAll(query, organizationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get one patient by id' })
|
@ApiOperation({ summary: 'Get one patient by id' })
|
||||||
findOne(@Param('id') id: string, @Req() req) {
|
findOne(@Param('id') id: string) {
|
||||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
return this.patientsService.findOne(id);
|
||||||
return this.patientsService.findOne(id, organizationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: 'Update patient' })
|
@ApiOperation({ summary: 'Update global patient record' })
|
||||||
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) {
|
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) {
|
||||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
return this.patientsService.update(id, updatePatientDto);
|
||||||
return this.patientsService.update(id, updatePatientDto, organizationId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
|
||||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||||
@@ -9,34 +10,38 @@ export class PatientsService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createPatientDto: CreatePatientDto, organizationId: string) {
|
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({
|
const patient = await this.prisma.patient.create({
|
||||||
data: {
|
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,
|
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 { page = 1, limit = 10, q } = query;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where = {
|
const where = q?.trim()
|
||||||
organizationId,
|
? this.buildSearchWhere(q.trim())
|
||||||
...(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 [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.patient.findMany({
|
this.prisma.patient.findMany({
|
||||||
@@ -62,9 +67,9 @@ export class PatientsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string, organizationId: string) {
|
async findOne(id: string) {
|
||||||
const patient = await this.prisma.patient.findFirst({
|
const patient = await this.prisma.patient.findUnique({
|
||||||
where: { id, organizationId },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!patient) {
|
if (!patient) {
|
||||||
@@ -74,35 +79,92 @@ export class PatientsService {
|
|||||||
return { success: true, data: patient };
|
return { success: true, data: patient };
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
|
async update(id: string, updatePatientDto: UpdatePatientDto) {
|
||||||
await this.ensurePatient(id, organizationId);
|
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({
|
const patient = await this.prisma.patient.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data,
|
||||||
...updatePatientDto,
|
|
||||||
dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, data: patient };
|
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 }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
if (!user?.organizationId) {
|
if (!user?.organizationId) {
|
||||||
throw new BadRequestException('Organization is not selected');
|
throw new BadRequestException('Organization is not selected');
|
||||||
}
|
}
|
||||||
return user.organizationId;
|
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) {
|
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
||||||
const patient = await this.prisma.patient.findFirst({
|
const patient = await this.prisma.patient.findUnique({
|
||||||
where: { id: patientId, organizationId },
|
where: { id: patientId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!patient) {
|
if (!patient) {
|
||||||
|
|||||||
@@ -296,15 +296,17 @@
|
|||||||
"errorSavePatient": "Failed to save patient.",
|
"errorSavePatient": "Failed to save patient.",
|
||||||
"firstName": "First name",
|
"firstName": "First name",
|
||||||
"lastName": "Last name",
|
"lastName": "Last name",
|
||||||
"phone": "Phone",
|
"mobile": "Mobile",
|
||||||
|
"mobilePlaceholder": "09121234567",
|
||||||
|
"mobileLabel": "Mobile:",
|
||||||
|
"patientAlreadyExists": "A patient with this mobile already exists: {firstName} {lastName}. They were selected for you.",
|
||||||
"savePatient": "Save Patient",
|
"savePatient": "Save Patient",
|
||||||
"dialogTitle": "New patient",
|
"dialogTitle": "New patient",
|
||||||
"searchPlaceholder": "Search patients by name, phone, email",
|
"searchPlaceholder": "Search patients by name, mobile, email",
|
||||||
"loadingPatients": "Loading patients...",
|
"loadingPatients": "Loading patients...",
|
||||||
"noResults": "No patients found for this search.",
|
"noResults": "No patients found for this search.",
|
||||||
"noContact": "No contact",
|
"noContact": "No contact",
|
||||||
"selectPatient": "Select a patient to view details.",
|
"selectPatient": "Select a patient to view details.",
|
||||||
"phoneLabel": "Phone:",
|
|
||||||
"emailLabel": "Email:",
|
"emailLabel": "Email:",
|
||||||
"statusLabel": "Status:",
|
"statusLabel": "Status:",
|
||||||
"statusActive": "Active",
|
"statusActive": "Active",
|
||||||
|
|||||||
@@ -296,15 +296,17 @@
|
|||||||
"errorSavePatient": "ذخیره بیمار ناموفق بود.",
|
"errorSavePatient": "ذخیره بیمار ناموفق بود.",
|
||||||
"firstName": "نام",
|
"firstName": "نام",
|
||||||
"lastName": "نام خانوادگی",
|
"lastName": "نام خانوادگی",
|
||||||
"phone": "تلفن",
|
"mobile": "موبایل",
|
||||||
|
"mobilePlaceholder": "09121234567",
|
||||||
|
"mobileLabel": "موبایل:",
|
||||||
|
"patientAlreadyExists": "بیماری با این شماره موبایل از قبل وجود دارد: {firstName} {lastName}. برای شما انتخاب شد.",
|
||||||
"savePatient": "ذخیره بیمار",
|
"savePatient": "ذخیره بیمار",
|
||||||
"dialogTitle": "بیمار جدید",
|
"dialogTitle": "بیمار جدید",
|
||||||
"searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل",
|
"searchPlaceholder": "جستجوی بیماران بر اساس نام، موبایل، ایمیل",
|
||||||
"loadingPatients": "در حال بارگذاری بیماران...",
|
"loadingPatients": "در حال بارگذاری بیماران...",
|
||||||
"noResults": "هیچ بیماری برای این جستجو یافت نشد.",
|
"noResults": "هیچ بیماری برای این جستجو یافت نشد.",
|
||||||
"noContact": "بدون اطلاعات تماس",
|
"noContact": "بدون اطلاعات تماس",
|
||||||
"selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.",
|
"selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.",
|
||||||
"phoneLabel": "تلفن:",
|
|
||||||
"emailLabel": "ایمیل:",
|
"emailLabel": "ایمیل:",
|
||||||
"statusLabel": "وضعیت:",
|
"statusLabel": "وضعیت:",
|
||||||
"statusActive": "فعال",
|
"statusActive": "فعال",
|
||||||
|
|||||||
@@ -296,15 +296,17 @@
|
|||||||
"errorSavePatient": "Patiënt opslaan mislukt.",
|
"errorSavePatient": "Patiënt opslaan mislukt.",
|
||||||
"firstName": "Voornaam",
|
"firstName": "Voornaam",
|
||||||
"lastName": "Achternaam",
|
"lastName": "Achternaam",
|
||||||
"phone": "Telefoon",
|
"mobile": "Mobiel",
|
||||||
|
"mobilePlaceholder": "0612345678",
|
||||||
|
"mobileLabel": "Mobiel:",
|
||||||
|
"patientAlreadyExists": "Er bestaat al een patiënt met dit mobiele nummer: {firstName} {lastName}. Deze is voor u geselecteerd.",
|
||||||
"savePatient": "Patiënt opslaan",
|
"savePatient": "Patiënt opslaan",
|
||||||
"dialogTitle": "Nieuwe patiënt",
|
"dialogTitle": "Nieuwe patiënt",
|
||||||
"searchPlaceholder": "Zoek patiënten op naam, telefoon, e-mail",
|
"searchPlaceholder": "Zoek patiënten op naam, mobiel, e-mail",
|
||||||
"loadingPatients": "Patiënten laden...",
|
"loadingPatients": "Patiënten laden...",
|
||||||
"noResults": "Geen patiënten gevonden voor deze zoekopdracht.",
|
"noResults": "Geen patiënten gevonden voor deze zoekopdracht.",
|
||||||
"noContact": "Geen contact",
|
"noContact": "Geen contact",
|
||||||
"selectPatient": "Selecteer een patiënt om details te bekijken.",
|
"selectPatient": "Selecteer een patiënt om details te bekijken.",
|
||||||
"phoneLabel": "Telefoon:",
|
|
||||||
"emailLabel": "E-mail:",
|
"emailLabel": "E-mail:",
|
||||||
"statusLabel": "Status:",
|
"statusLabel": "Status:",
|
||||||
"statusActive": "Actief",
|
"statusActive": "Actief",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co
|
|||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
phone: '',
|
mobile: '',
|
||||||
email: '',
|
email: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,12 +152,21 @@ export default function AppointmentsPage() {
|
|||||||
setPatientForm(EMPTY_PATIENT_FORM);
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
await loadPatientsSearch(search);
|
await loadPatientsSearch(search);
|
||||||
setSelectedPatient(response.data);
|
setSelectedPatient(response.data);
|
||||||
toast.showSuccess(
|
if (response.existing) {
|
||||||
t('successPatientSaved', {
|
toast.showInfo(
|
||||||
firstName: response.data.firstName,
|
tPatients('patientAlreadyExists', {
|
||||||
lastName: response.data.lastName,
|
firstName: response.data.firstName,
|
||||||
}),
|
lastName: response.data.lastName,
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.showSuccess(
|
||||||
|
t('successPatientSaved', {
|
||||||
|
firstName: response.data.firstName,
|
||||||
|
lastName: response.data.lastName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
err && typeof err === 'object' && 'message' in err
|
err && typeof err === 'object' && 'message' in err
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
|
|||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
|
mobile: '',
|
||||||
email: '',
|
email: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,12 +82,21 @@ export default function PatientsPage() {
|
|||||||
setPatientForm(EMPTY_PATIENT_FORM);
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
await loadPatients(search);
|
await loadPatients(search);
|
||||||
setSelectedPatient(response.data);
|
setSelectedPatient(response.data);
|
||||||
[patients],
|
if (response.existing) {
|
||||||
|
toast.showInfo(
|
||||||
);
|
t('patientAlreadyExists', {
|
||||||
|
firstName: response.data.firstName,
|
||||||
|
lastName: response.data.lastName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.showSuccess(
|
||||||
|
t('successPatientSaved', {
|
||||||
|
firstName: response.data.firstName,
|
||||||
|
lastName: response.data.lastName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorSavePatient')));
|
toast.showError(formatApiErrorMessage(error, t('errorSavePatient')));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '@/components/appointments/appointmentOverlapLayout';
|
} from '@/components/appointments/appointmentOverlapLayout';
|
||||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
|
||||||
|
import { formatMobileForDisplay } from '@/lib/phone';
|
||||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
|
|
||||||
const HOUR_PX = 80;
|
const HOUR_PX = 80;
|
||||||
@@ -300,7 +301,9 @@ export function AppointmentScheduleGrid({
|
|||||||
clusterSize > 1
|
clusterSize > 1
|
||||||
? t('overlappingChoose', { count: clusterSize })
|
? t('overlappingChoose', { count: clusterSize })
|
||||||
: null,
|
: null,
|
||||||
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
|
!isUnderOneHour && apt.patient.mobile
|
||||||
|
? formatMobileForDisplay(apt.patient.mobile)
|
||||||
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
@@ -338,10 +341,10 @@ export function AppointmentScheduleGrid({
|
|||||||
{patientName}
|
{patientName}
|
||||||
</span>
|
</span>
|
||||||
{!isUnderOneHour &&
|
{!isUnderOneHour &&
|
||||||
apt.patient.phone &&
|
apt.patient.mobile &&
|
||||||
lane.laneCount === 1 && (
|
lane.laneCount === 1 && (
|
||||||
<span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90">
|
<span className="block w-full truncate pointer-events-none text-[10px] leading-tight opacity-90">
|
||||||
{apt.patient.phone}
|
{formatMobileForDisplay(apt.patient.mobile)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isUnderOneHour && clusterSize > 1 && (
|
{!isUnderOneHour && clusterSize > 1 && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Search } from 'lucide-react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
|
import { formatMobileForDisplay } from '@/lib/phone';
|
||||||
import type { Patient } from '@/types/patient';
|
import type { Patient } from '@/types/patient';
|
||||||
|
|
||||||
interface AppointmentsPatientSearchProps {
|
interface AppointmentsPatientSearchProps {
|
||||||
@@ -82,7 +83,7 @@ export function AppointmentsPatientSearch({
|
|||||||
{patient.firstName} {patient.lastName}
|
{patient.firstName} {patient.lastName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{patient.phone || patient.email || tPatients('noContact')}
|
{formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/shared/Button';
|
|||||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { CreatePatientInput } from '@/types/patient';
|
import { CreatePatientInput } from '@/types/patient';
|
||||||
|
import { isValidMobile, normalizeMobile } from '@/lib/phone';
|
||||||
|
|
||||||
interface CreatePatientModalProps {
|
interface CreatePatientModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -49,9 +50,10 @@ function CreatePatientFormFields({
|
|||||||
onChange={(e) => onChange({ lastName: e.target.value })}
|
onChange={(e) => onChange({ lastName: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('phone')}
|
label={t('mobile')}
|
||||||
value={formData.phone || ''}
|
value={formData.mobile || ''}
|
||||||
onChange={(e) => onChange({ phone: e.target.value })}
|
onChange={(e) => onChange({ mobile: e.target.value })}
|
||||||
|
placeholder={t('mobilePlaceholder')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={tCommon('email')}
|
label={tCommon('email')}
|
||||||
@@ -66,7 +68,7 @@ function CreatePatientFormFields({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
disabled={!formData.firstName || !formData.lastName}
|
disabled={!formData.firstName || !formData.lastName || !isValidMobile(normalizeMobile(formData.mobile || '') ?? '')}
|
||||||
>
|
>
|
||||||
{t('savePatient')}
|
{t('savePatient')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
|
import { formatMobileForDisplay } from '@/lib/phone';
|
||||||
import { Patient } from '@/types/patient';
|
import { Patient } from '@/types/patient';
|
||||||
|
|
||||||
interface PatientSearchSelectProps {
|
interface PatientSearchSelectProps {
|
||||||
@@ -56,7 +57,9 @@ export function PatientSearchSelect({
|
|||||||
<p className="text-sm font-medium text-text-primary">
|
<p className="text-sm font-medium text-text-primary">
|
||||||
{patient.firstName} {patient.lastName}
|
{patient.firstName} {patient.lastName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted">{patient.phone || patient.email || t('noContact')}</p>
|
<p className="text-xs text-text-muted">
|
||||||
|
{formatMobileForDisplay(patient.mobile) || patient.email || t('noContact')}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { formatMobileForDisplay } from '@/lib/phone';
|
||||||
import { Patient } from '@/types/patient';
|
import { Patient } from '@/types/patient';
|
||||||
|
|
||||||
interface PatientSummaryCardProps {
|
interface PatientSummaryCardProps {
|
||||||
@@ -24,7 +25,7 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
|
|||||||
{patient.firstName} {patient.lastName}
|
{patient.firstName} {patient.lastName}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{t('phoneLabel')} {patient.phone || t('emptyValue')}
|
{t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{t('emailLabel')} {patient.email || t('emptyValue')}
|
{t('emailLabel')} {patient.email || t('emptyValue')}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import {
|
import {
|
||||||
CreatePatientInput,
|
CreatePatientInput,
|
||||||
|
CreatePatientResponse,
|
||||||
Patient,
|
Patient,
|
||||||
PatientsListResponse,
|
PatientsListResponse,
|
||||||
} from '@/types/patient';
|
} from '@/types/patient';
|
||||||
@@ -11,7 +12,7 @@ export const patientsApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => {
|
create: async (data: CreatePatientInput): Promise<CreatePatientResponse> => {
|
||||||
const response = await apiClient.post('/patients', data);
|
const response = await apiClient.post('/patients', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
44
frontend/src/lib/phone.ts
Normal file
44
frontend/src/lib/phone.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/** Canonical Iran mobile: +989XXXXXXXXX */
|
||||||
|
export const IR_MOBILE_REGEX = /^\+989\d{9}$/;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -25,5 +25,5 @@ export interface AppointmentRecord {
|
|||||||
startAt: string;
|
startAt: string;
|
||||||
endAt: string;
|
endAt: string;
|
||||||
purpose: string;
|
purpose: string;
|
||||||
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'phone'>;
|
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'mobile'>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
export interface Patient {
|
export interface Patient {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
phone?: string | null;
|
mobile: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
dateOfBirth?: string | null;
|
dateOfBirth?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
createdByOrganizationId?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ export interface Patient {
|
|||||||
export interface CreatePatientInput {
|
export interface CreatePatientInput {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
phone?: string;
|
mobile: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
dateOfBirth?: string;
|
dateOfBirth?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -33,3 +33,9 @@ export interface PatientsListResponse {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatePatientResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: Patient;
|
||||||
|
existing?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user