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:
hamed
2026-08-18 16:31:52 +03:30
co-authored by Claude Opus 5
parent 8a43297e24
commit 5d2594ff87
12 changed files with 244 additions and 32 deletions
+2 -1
View File
@@ -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),
));
}
+16 -8
View File
@@ -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(
+6 -2
View File
@@ -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(),
+11 -2
View File
@@ -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 ? [