diff --git a/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql
new file mode 100644
index 0000000..9625d89
--- /dev/null
+++ b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql
@@ -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;
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index ffe59bc..117d9e8 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -60,7 +60,7 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
- patients Patient[]
+ createdPatients Patient[] @relation("PatientCreatedBy")
appointments Appointment[]
treatments Treatment[]
caseSends TreatmentCaseSend[]
@@ -72,24 +72,23 @@ model Organization {
}
model Patient {
- id String @id @default(uuid())
- organizationId String
- firstName String
- lastName String
- phone String?
- email String?
- dateOfBirth DateTime?
- notes String?
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(uuid())
+ firstName String
+ lastName String
+ mobile String @unique
+ email String?
+ dateOfBirth DateTime?
+ notes String?
+ isActive Boolean @default(true)
+ createdByOrganizationId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- organization Organization @relation(fields: [organizationId], references: [id])
- treatments Treatment[]
- appointments Appointment[]
+ createdByOrganization Organization? @relation("PatientCreatedBy", fields: [createdByOrganizationId], references: [id], onDelete: SetNull)
+ treatments Treatment[]
+ appointments Appointment[]
- @@index([organizationId, createdAt])
- @@index([organizationId, lastName, firstName])
+ @@index([lastName, firstName])
@@map("patients")
}
diff --git a/backend/src/common/phone.spec.ts b/backend/src/common/phone.spec.ts
new file mode 100644
index 0000000..1838bd0
--- /dev/null
+++ b/backend/src/common/phone.spec.ts
@@ -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');
+ });
+});
diff --git a/backend/src/common/phone.ts b/backend/src/common/phone.ts
new file mode 100644
index 0000000..b2b0ada
--- /dev/null
+++ b/backend/src/common/phone.ts
@@ -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, '');
+}
diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts
index 2293082..769255e 100644
--- a/backend/src/modules/appointments/appointments.service.ts
+++ b/backend/src/modules/appointments/appointments.service.ts
@@ -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) {
diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts
index fa92769..5e72673 100644
--- a/backend/src/modules/patients/dto/create-patient.dto.ts
+++ b/backend/src/modules/patients/dto/create-patient.dto.ts
@@ -9,10 +9,9 @@ export class CreatePatientDto {
@MaxLength(80)
lastName: string;
- @IsOptional()
@IsString()
@MaxLength(30)
- phone?: string;
+ mobile: string;
@IsOptional()
@IsEmail()
diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts
index 0e97c6e..46be5c1 100644
--- a/backend/src/modules/patients/patients.controller.ts
+++ b/backend/src/modules/patients/patients.controller.ts
@@ -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);
}
}
diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts
index ac99936..6a1f6fc 100644
--- a/backend/src/modules/patients/patients.service.ts
+++ b/backend/src/modules/patients/patients.service.ts
@@ -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{t('title')}
-
- {t('title')}
+
+
- {patient.phone || patient.email || tPatients('noContact')} + {formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}
); diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 7e0f44f..6f792ad 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Input } from '@/components/ui/shared/Input'; import { CreatePatientInput } from '@/types/patient'; +import { isValidMobile, normalizeMobile } from '@/lib/phone'; interface CreatePatientModalProps { isOpen: boolean; @@ -49,9 +50,10 @@ function CreatePatientFormFields({ onChange={(e) => onChange({ lastName: e.target.value })} /> onChange({ phone: e.target.value })} + label={t('mobile')} + value={formData.mobile || ''} + onChange={(e) => onChange({ mobile: e.target.value })} + placeholder={t('mobilePlaceholder')} /> {t('savePatient')} diff --git a/frontend/src/components/ui/patient/PatientSearchSelect.tsx b/frontend/src/components/ui/patient/PatientSearchSelect.tsx index 4f4d707..0ca43c0 100644 --- a/frontend/src/components/ui/patient/PatientSearchSelect.tsx +++ b/frontend/src/components/ui/patient/PatientSearchSelect.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { Search } from 'lucide-react'; import { Input } from '@/components/ui/shared/Input'; +import { formatMobileForDisplay } from '@/lib/phone'; import { Patient } from '@/types/patient'; interface PatientSearchSelectProps { @@ -56,7 +57,9 @@ export function PatientSearchSelect({{patient.firstName} {patient.lastName}
-{patient.phone || patient.email || t('noContact')}
++ {formatMobileForDisplay(patient.mobile) || patient.email || t('noContact')} +
); })} diff --git a/frontend/src/components/ui/patient/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx index fb2e1aa..63974d2 100644 --- a/frontend/src/components/ui/patient/PatientSummaryCard.tsx +++ b/frontend/src/components/ui/patient/PatientSummaryCard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; +import { formatMobileForDisplay } from '@/lib/phone'; import { Patient } from '@/types/patient'; interface PatientSummaryCardProps { @@ -24,7 +25,7 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { {patient.firstName} {patient.lastName}- {t('phoneLabel')} {patient.phone || t('emptyValue')} + {t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}
{t('emailLabel')} {patient.email || t('emptyValue')}
diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts
index 8578c9c..27309e6 100644
--- a/frontend/src/lib/api/patients.ts
+++ b/frontend/src/lib/api/patients.ts
@@ -1,6 +1,7 @@
import { apiClient } from './client';
import {
CreatePatientInput,
+ CreatePatientResponse,
Patient,
PatientsListResponse,
} from '@/types/patient';
@@ -11,7 +12,7 @@ export const patientsApi = {
return response.data;
},
- create: async (data: CreatePatientInput): Promise<{ success: boolean; data: Patient }> => {
+ create: async (data: CreatePatientInput): Promise