feat(privacy): keep venue phone numbers out of every public response
A doctor's office number sat next to the address on the public profile and in the anonymous API payload, so harvesting the phone number of every practice in the country was one unauthenticated request away. Street address and map coordinates stay public — a patient needs those to find the place — but the phone is now opt-in per caller: DoctorAddress::toArray() and the clinic serializers only emit it when told to, and the public doctor/clinic endpoints tell them to only when the caller may edit that profile (the same can_edit they already compute). Owner-facing address CRUD keeps returning it unchanged. The patient still gets the number where it is actually useful — their own appointment. That payload also stops guessing: it used to serialise the doctor's *first* address, so a booking made at the clinic or at a second office showed the wrong street entirely. It now resolves the address recorded on the appointment itself, which works the same for a personal office and a clinic branch, and falls back to the clinic's own number when the address has none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -725,7 +725,7 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$payload = $appointment->toArray();
|
||||
$payload = $this->appointmentWithVenue($appointment);
|
||||
|
||||
/**
|
||||
* نوبتِ سرویسیِ کلینیک `service_section_id` را روی خودِ نوبت نمینویسد؛ فقط
|
||||
@@ -893,13 +893,49 @@ class AppointmentController extends BaseController
|
||||
// `toArray()` هم داخل محدوده است: proxyهای پزشک و کلینیک آنجا باز میشوند و
|
||||
// با فیلترِ برگشته، Doctrine `EntityNotFoundException` میدهد.
|
||||
$rows = $this->tenantScope->withoutFilter(fn () => array_map(
|
||||
fn(Appointment $a) => $a->toArray(),
|
||||
fn(Appointment $a) => $this->appointmentWithVenue($a),
|
||||
$this->appointmentRepo->findByUser($user, $status),
|
||||
));
|
||||
|
||||
return $this->success(['data' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبت بههمراه نشانیِ واقعیِ همان نوبت — با شمارهٔ تماس.
|
||||
*
|
||||
* `Appointment::toArray()` نشانی را از «اولین آدرسِ پزشک» برمیداشت، پس بیماری که
|
||||
* نوبتش در کلینیک یا در شعبهٔ دوم بود، نشانیِ مطبِ شخصیِ پزشک را میدید. آدرسِ درست
|
||||
* روی خودِ نوبت ثبت شده (`address_id`) و همانجا برای مطب و کلینیک یکسان کار میکند.
|
||||
*
|
||||
* شماره هم اینجا برمیگردد و نه در پاسخهای عمومی: مخاطبِ این متد یا صاحب نوبت است
|
||||
* یا کسی که `canView()` تأییدش کرده. اگر خودِ آدرس شماره نداشته باشد، شمارهٔ کلینیکِ
|
||||
* همان نوبت جایش مینشیند تا بیمار بیراهارتباطی نماند.
|
||||
*/
|
||||
private function appointmentWithVenue(Appointment $appointment): array
|
||||
{
|
||||
$payload = $appointment->toArray();
|
||||
|
||||
$addressId = $appointment->getAddressId();
|
||||
$address = $addressId !== null ? $this->addressRepo->find($addressId) : null;
|
||||
|
||||
if ($address !== null) {
|
||||
$payload['address'] = $address->toArray(null, true);
|
||||
} elseif ($payload['address'] !== null) {
|
||||
// نوبتهای قدیمی `address_id` ندارند؛ همان آدرسِ حدسیِ toArray() میماند،
|
||||
// ولی دستکم بدون شمارهٔ اشتباه نمایش داده نمیشود.
|
||||
$payload['address']['telephone'] = null;
|
||||
}
|
||||
|
||||
if (($payload['address']['telephone'] ?? null) === null && $payload['address'] !== null) {
|
||||
$clinic = $appointment->getClinic();
|
||||
if ($clinic !== null) {
|
||||
$payload['address']['telephone'] = $clinic->getTelephone();
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function canView(Appointment $a, User $user): bool
|
||||
{
|
||||
return $this->accessChecker->canView($a, $user);
|
||||
|
||||
@@ -131,7 +131,7 @@ class ClinicController extends BaseController
|
||||
$this->userRepo->save($user);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $clinic->toDetailArray()], 201);
|
||||
return $this->success(['data' => $clinic->toDetailArray(withContact: true)], 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -177,7 +177,7 @@ class ClinicController extends BaseController
|
||||
);
|
||||
|
||||
return $this->success(['data' => array_merge(
|
||||
$clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone),
|
||||
$clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone, $canEdit),
|
||||
['can_edit' => $canEdit],
|
||||
)]);
|
||||
}
|
||||
@@ -277,7 +277,7 @@ class ClinicController extends BaseController
|
||||
|
||||
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
|
||||
|
||||
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
|
||||
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone, true)]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -795,7 +795,7 @@ class ClinicController extends BaseController
|
||||
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $address->toArray()], 201);
|
||||
return $this->success(['data' => $address->toArray(null, true)], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['PATCH'])]
|
||||
@@ -824,7 +824,7 @@ class ClinicController extends BaseController
|
||||
$this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $address->toArray()]);
|
||||
return $this->success(['data' => $address->toArray(null, true)]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['DELETE'])]
|
||||
|
||||
@@ -209,9 +209,11 @@ class Clinic
|
||||
array $cityData = [],
|
||||
?array $map = null,
|
||||
?string $street = null,
|
||||
?string $telephone = null
|
||||
?string $telephone = null,
|
||||
bool $withContact = false
|
||||
): array {
|
||||
$phone = $this->firstFilled($telephone, $this->telephone);
|
||||
// شمارهٔ کلینیک هم مثل شمارهٔ مطب عمومی نیست. {@see DoctorAddress::toArray()}
|
||||
$phone = $withContact ? $this->firstFilled($telephone, $this->telephone) : null;
|
||||
$address = $this->firstFilled($street, $this->address);
|
||||
|
||||
return [
|
||||
@@ -267,9 +269,9 @@ class Clinic
|
||||
return null;
|
||||
}
|
||||
|
||||
public function toListArray(?string $city = null, ?string $state = null, ?string $telephone = null): array
|
||||
public function toListArray(?string $city = null, ?string $state = null, ?string $telephone = null, bool $withContact = false): array
|
||||
{
|
||||
$phone = $this->firstFilled($telephone, $this->telephone);
|
||||
$phone = $withContact ? $this->firstFilled($telephone, $this->telephone) : null;
|
||||
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
|
||||
@@ -41,7 +41,8 @@ class AddressController extends BaseController
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'addresses', 'view');
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (DoctorAddress $a): array => $a->toArray(),
|
||||
// فهرست محیطِ خودِ کاربر است، پس شمارهٔ تماس هم برمیگردد.
|
||||
static fn (DoctorAddress $a): array => $a->toArray(null, true),
|
||||
$this->addresses->listForContext($user),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ class DoctorController extends BaseController
|
||||
$this->userRepo->save($user);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))], 201);
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor), true)], 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -159,12 +159,14 @@ class DoctorController extends BaseController
|
||||
}
|
||||
|
||||
$clinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
// شمارهٔ کلینیک بعد از محاسبهٔ can_edit اضافه میشود؛ اینجا هنوز معلوم نیست
|
||||
// درخواستدهنده صاحب پروفایل است یا یک بازدیدکنندهٔ عمومی.
|
||||
$clinicData = array_map(fn(Clinic $c) => [
|
||||
'id' => (string) $c->getId(),
|
||||
'uuid' => $c->getUuid(),
|
||||
'name' => $c->getName(),
|
||||
'address' => $c->getAddress(),
|
||||
'telephone' => $c->getTelephone(),
|
||||
'telephone' => null,
|
||||
'city_id' => $c->getCityId(),
|
||||
'province_id' => $c->getProvinceId(),
|
||||
'map' => [
|
||||
@@ -187,8 +189,14 @@ class DoctorController extends BaseController
|
||||
|| $this->editPolicy->ownsDoctor($user, $doctor)
|
||||
);
|
||||
|
||||
if ($canEdit) {
|
||||
foreach ($clinics as $index => $clinic) {
|
||||
$clinicData[$index]['telephone'] = $clinic->getTelephone();
|
||||
}
|
||||
}
|
||||
|
||||
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules, $canEdit), [
|
||||
'clinics' => $clinicData,
|
||||
'representation' => $representation,
|
||||
'can_edit' => $canEdit,
|
||||
@@ -214,7 +222,7 @@ class DoctorController extends BaseController
|
||||
}
|
||||
|
||||
$schedules = $this->scheduleRepo->findAllByDoctor($doctor);
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), ['clinics' => [[
|
||||
return $this->success(['data' => array_merge($doctor->toDetailArray($schedules, true), ['clinics' => [[
|
||||
'id' => (string) $clinic->getId(),
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
@@ -393,7 +401,7 @@ class DoctorController extends BaseController
|
||||
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor), true)]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
@@ -613,7 +621,7 @@ class DoctorController extends BaseController
|
||||
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $address->toArray()], 201);
|
||||
return $this->success(['data' => $address->toArray(null, true)], 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -651,7 +659,7 @@ class DoctorController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $address->toArray()]);
|
||||
return $this->success(['data' => $address->toArray(null, true)]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
@@ -715,7 +723,7 @@ class DoctorController extends BaseController
|
||||
$this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $address->toArray()]);
|
||||
return $this->success(['data' => $address->toArray(null, true)]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
|
||||
@@ -601,7 +601,11 @@ class Doctor
|
||||
}
|
||||
|
||||
/** @param WeeklySchedule[] $schedules همهٔ برنامههای پزشک (شخصی + کلینیکها) */
|
||||
public function toDetailArray(array $schedules = []): array
|
||||
/**
|
||||
* `$withContact` فقط برای مخاطبی روشن میشود که حق دیدن شمارهٔ مطب را دارد —
|
||||
* صاحب پروفایل یا ادمین. پاسخ عمومی بدون شماره است. {@see DoctorAddress::toArray()}
|
||||
*/
|
||||
public function toDetailArray(array $schedules = [], bool $withContact = false): array
|
||||
{
|
||||
$sf = $this->computeScheduleFields($schedules);
|
||||
return [
|
||||
@@ -638,7 +642,7 @@ class Doctor
|
||||
'owner_status' => $this->ownerStatus,
|
||||
'free_turn' => $sf['free_turn'],
|
||||
'hours_of_work' => $sf['hours_of_work'],
|
||||
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()),
|
||||
'address' => array_map(fn(DoctorAddress $a) => $a->toArray(null, $withContact), $this->addresses->toArray()),
|
||||
'average_rate' => ['total_rates' => null],
|
||||
'state' => array_map(fn(Province $p) => [
|
||||
'uuid' => $p->getUuid(),
|
||||
|
||||
@@ -151,7 +151,16 @@ class DoctorAddress
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
public function toArray(?string $clinicName = null): array
|
||||
/**
|
||||
* شمارهٔ تماسِ محل، دادهٔ عمومی نیست.
|
||||
*
|
||||
* صفحهٔ پزشک شمارهٔ مطب را کنار آدرس نشان میداد و همان شماره در پاسخ عمومیِ API
|
||||
* هم میآمد — یعنی برداشت انبوهِ شمارهٔ همهٔ مطبها فقط یک درخواست فاصله داشت.
|
||||
* حالا پیشفرض حذف است و فقط جایی که مخاطب حق دیدنش را دارد — صاحب همان محل، یا
|
||||
* بیماری که نوبتش آنجاست — با `$withContact` برمیگردد. موقعیت مکانی و آدرس
|
||||
* عمومی میمانند؛ آنها همان چیزیاند که بیمار برای پیدا کردن مطب لازم دارد.
|
||||
*/
|
||||
public function toArray(?string $clinicName = null, bool $withContact = false): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
@@ -165,7 +174,7 @@ class DoctorAddress
|
||||
'longitude' => $this->longitude !== null ? (string) $this->longitude : null,
|
||||
],
|
||||
'address' => $this->address,
|
||||
'telephone' => $this->telephone,
|
||||
'telephone' => $withContact ? $this->telephone : null,
|
||||
'active' => $this->active,
|
||||
'timezone' => $this->timezone,
|
||||
'city' => $this->city !== null ? [
|
||||
|
||||
Reference in New Issue
Block a user