diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index 5e72673..21a2ae7 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -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() diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index cd6fe86..b40b2f0 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -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) { diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index 6a1f6fc..a573acd 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -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 }, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fcd43f4..f0d67e6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -393,7 +393,20 @@ "statusLabel": "Status:", "statusActive": "Active", "statusInactive": "Inactive", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "Fields marked with * are required. Email is optional.", + "firstNameRequired": "First name is required.", + "lastNameRequired": "Last name is required.", + "emailOptional": "Email (optional)", + "emailOptionalSummary": "Email (optional):", + "appointmentHistoryTitle": "Appointment history", + "appointmentHistorySubtitle": "Past and upcoming appointments for this patient at your clinic.", + "appointmentHistoryLoading": "Loading appointment history…", + "appointmentHistoryEmpty": "No appointments recorded for this patient yet.", + "appointmentHistoryError": "Could not load appointment history.", + "appointmentHistoryProvider": "Provider: {name}", + "appointmentHistoryUnknownProvider": "Unknown provider" }, "cases": { "title": "Cases", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index edf6bcd..5c56946 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -393,7 +393,20 @@ "statusLabel": "وضعیت:", "statusActive": "فعال", "statusInactive": "غیرفعال", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "فیلدهای دارای * الزامی هستند. ایمیل اختیاری است.", + "firstNameRequired": "نام الزامی است.", + "lastNameRequired": "نام خانوادگی الزامی است.", + "emailOptional": "ایمیل (اختیاری)", + "emailOptionalSummary": "ایمیل (اختیاری):", + "appointmentHistoryTitle": "سوابق نوبت", + "appointmentHistorySubtitle": "نوبتهای گذشته و آینده این بیمار در کلینیک شما.", + "appointmentHistoryLoading": "در حال بارگذاری سوابق نوبت…", + "appointmentHistoryEmpty": "هنوز نوبتی برای این بیمار ثبت نشده است.", + "appointmentHistoryError": "بارگذاری سوابق نوبت انجام نشد.", + "appointmentHistoryProvider": "ارائهدهنده: {name}", + "appointmentHistoryUnknownProvider": "ارائهدهنده نامشخص" }, "cases": { "title": "پروندهها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 878c88d..8abac2d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -393,7 +393,20 @@ "statusLabel": "Status:", "statusActive": "Actief", "statusInactive": "Inactief", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "Velden met * zijn verplicht. E-mail is optioneel.", + "firstNameRequired": "Voornaam is verplicht.", + "lastNameRequired": "Achternaam is verplicht.", + "emailOptional": "E-mail (optioneel)", + "emailOptionalSummary": "E-mail (optioneel):", + "appointmentHistoryTitle": "Afspraakgeschiedenis", + "appointmentHistorySubtitle": "Eerdere en komende afspraken voor deze patiënt in uw kliniek.", + "appointmentHistoryLoading": "Afspraakgeschiedenis laden…", + "appointmentHistoryEmpty": "Er zijn nog geen afspraken voor deze patiënt.", + "appointmentHistoryError": "Kon afspraakgeschiedenis niet laden.", + "appointmentHistoryProvider": "Behandelaar: {name}", + "appointmentHistoryUnknownProvider": "Onbekende behandelaar" }, "cases": { "title": "Dossiers", diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index e84ae56..9266431 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -13,6 +13,7 @@ import { CreatePatientInput, Patient } from '@/types/patient'; import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -154,6 +155,9 @@ export default function PatientsPage() {
{t('requiredFieldsHint')}
+{t('appointmentHistorySubtitle')}
+{t('appointmentHistoryLoading')}
+ ) : error ? ( +{error}
+ ) : items.length === 0 ? ( +{t('appointmentHistoryEmpty')}
+ ) : ( ++ {formatAppointmentDate(item.startAt)} +
++ {formatTimeForInput(new Date(item.startAt))} + {' – '} + {formatTimeForInput(new Date(item.endAt))} +
++ {t('appointmentHistoryProvider', { + name: item.providerName || t('appointmentHistoryUnknownProvider'), + })} +
+- {t('emailLabel')} {patient.email || t('emptyValue')} + {t('emailOptionalSummary')}{' '} + {patient.email || t('emptyValue')}
{t('statusLabel')}{' '}
diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts
index 27309e6..a4ea0f6 100644
--- a/frontend/src/lib/api/patients.ts
+++ b/frontend/src/lib/api/patients.ts
@@ -3,6 +3,7 @@ import {
CreatePatientInput,
CreatePatientResponse,
Patient,
+ PatientAppointmentHistoryResponse,
PatientsListResponse,
} from '@/types/patient';
@@ -21,4 +22,9 @@ export const patientsApi = {
const response = await apiClient.get(`/patients/${id}`);
return response.data;
},
+
+ listAppointments: async (patientId: string): Promise