feat(representation): let registering reps edit their doctors and clinics

A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.

Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.

ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.

Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.

Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.

Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.

Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-08 15:50:17 +03:30
co-authored by Claude Opus 5
parent d74a351e5a
commit fb1cb20c11
16 changed files with 2106 additions and 68 deletions
+68 -13
View File
@@ -109,13 +109,23 @@ unreachable data — the request is rejected instead.
Get clinic detail.
**Permission:** `PUBLIC`
**Permission:** `PUBLIC` — a token is optional and only affects `can_edit`.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Clinic UUID |
### `can_edit`
The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this clinic. It is `true`
for the owner, `ROLE_ADMIN`, a member doctor holding `clinic_info.update`, and the registering
representative; `false` for everyone else and always `false` without a token. It is computed by the
same checks the `PATCH` gate uses, so clients must read it rather than re-deriving the rule.
It says nothing about *which fields* are writable — a representative sees `can_edit: true` yet is still
limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`.
### Response `200`
```json
{
@@ -185,13 +195,39 @@ Get clinic detail.
Update a clinic.
**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, or a member doctor holding `clinic_info.update` (see **Clinic Doctor Permissions**)
**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, a member doctor holding `clinic_info.update`
(see **Clinic Doctor Permissions**), or the **registering representative** (a `ROLE_REPRESENTATION`
user whose `Representation.id` equals the clinic's `representation_id`).
The representative's grant is permanent for as long as `representation_id` points at them, but it is
restricted to content fields — see *Representative field whitelist* below. Owner, admin and member-doctor
access is unchanged and unrestricted. The representative path deliberately bypasses
`ClinicDoctorPermissionChecker`: that class answers "is this doctor a member of this clinic", and a
representative is not a member at all.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Clinic UUID |
### Representative field whitelist
A representative may send only these keys. Any other key aborts the whole request with `403`
and **nothing is saved** — the payload is not silently filtered.
`name` · `info` · `address` · `telephone` · `working_days` · `24_7` · `latitude` · `longitude` ·
`practice_domain_uuid` · `state` · `city` · `social_media` · `image_clinic` · `clinic_logo` ·
`specialties` · `doctor_services` · `insurance`
Notably excluded: `doctors`. Which doctors belong to a clinic is a membership decision for the owner,
not for the representative who registered it. `specialties` / `doctor_services` / `insurance` **are**
allowed — they are the clinic's public-facing catalogue, the mirror of the same keys on the doctor
whitelist, and are not membership.
Every successful representative edit writes one `app_log` row with `channel = 'representation_edit'`,
recording the representative id, the target uuid, and the **names** of the changed fields (never
their values). Edits by the owner, a member doctor, or an admin write no such row.
### Request Body
Same fields as POST — all optional — plus:
@@ -211,10 +247,29 @@ Updated clinic object (same structure as GET). Carries `practice_domain` — the
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
| `ERR_FORBIDDEN_001` | 403 | Secretary without `clinic_info.update` — thrown by the pre-check, before the clinic is even loaded |
| `ERR_AUTH_006` | 403 | Not the owner, not an admin, not a permitted member doctor, and not the registering representative |
| `ERR_AUTH_006` | 403 | Representative sent a field outside the whitelist — the offending key is in `errors[0].field` |
| `ERR_VALIDATION_002` | 404 | Clinic not found |
| `ERR_VALIDATION_001` | 422 | `image_clinic` بیش از ۵ عکس دارد |
| `ERR_VALIDATION_002` | 422 | `practice_domain_uuid` به هیچ حوزه‌ای اشاره نمی‌کند |
Real response for a whitelist violation (captured from a functional test run, not hand-written):
```json
{
"success": false,
"data": null,
"errors": [
{
"code": "ERR_AUTH_006",
"message": "نماینده اجازهٔ تغییر این فیلد را ندارد",
"field": "doctors"
}
]
}
```
---
## GET `/api/v1/clinics`
@@ -622,10 +677,12 @@ Returns all addresses registered for a clinic (type=clinic entries).
### `POST /api/v1/clinic/{clinicUuid}/address`
**Permission:** Clinic owner or `ROLE_ADMIN`
**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative
Creates a new address for the clinic. The address will appear in `available-locations` for doctors belonging to this clinic.
> A clinic may hold only one address — posting a second one returns `409` (`ERR_CONFLICT_001`). Use PATCH to change it.
#### Request
```json
{
@@ -664,9 +721,10 @@ Creates a new address for the clinic. The address will appear in `available-loca
### `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
**Permission:** Clinic owner or `ROLE_ADMIN`
**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative
Updates an existing clinic address. Same body fields as POST (all optional).
Updates an existing clinic address. Same body fields as POST (all optional). The address is content,
not membership, so no field whitelist applies to representatives here.
#### Response `200`
```json
@@ -677,18 +735,15 @@ Updates an existing clinic address. Same body fields as POST (all optional).
### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
**Permission:** Clinic owner or `ROLE_ADMIN`
**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative
Deletes a clinic address.
> A clinic must retain at least one address — attempting to delete the last address returns `409`.
Deletes a clinic address. There is no "must keep at least one" guard — the last address can be deleted.
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_CONFLICT_001` | 409 | Cannot delete the last address |
| `ERR_VALIDATION_002` | 404 | Address or clinic not found |
| `ERR_AUTH_006` | 403 | Not the clinic owner |
| `ERR_AUTH_006` | 403 | Not the owner, not an admin, and not the registering representative |
---
+92 -15
View File
@@ -84,13 +84,23 @@ Create a doctor profile for the authenticated user.
Get doctor detail with clinics.
**Permission:** `PUBLIC`
**Permission:** `PUBLIC` — a token is optional and only affects `can_edit`.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Doctor UUID |
### `can_edit`
The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this doctor. It is `true`
for the doctor themselves, `ROLE_ADMIN`, and the registering representative; `false` for everyone else
and always `false` without a token. It is computed by the same policy the `PATCH` gate uses, so clients
must read it rather than re-deriving the rule; a client that recomputes will drift.
It says nothing about *which fields* are writable — a representative sees `can_edit: true` yet is still
limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`.
### Response `200`
```json
{
@@ -314,13 +324,35 @@ php bin/console app:audit-polluted-records --force # خارج‌کردن از
Update doctor profile.
**Permission:** `AUTH`must be the owner (or `ROLE_ADMIN`)
**Permission:** `AUTH`the doctor themselves, `ROLE_ADMIN`, or the **registering representative**
(a `ROLE_REPRESENTATION` user whose `Representation.id` equals the doctor's `representation_id`).
The representative's grant is permanent for as long as `representation_id` points at them, but it is
restricted to content fields — see *Representative field whitelist* below. The doctor's and the
admin's own access is unchanged and unrestricted.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Doctor UUID |
### Representative field whitelist
A representative may send only these keys. Any other key aborts the whole request with `403`
and **nothing is saved** — the payload is not silently filtered.
`title` · `gender` · `degree` · `info` · `detail` · `mobile_number` · `activity_time` ·
`images` · `image_data` · `social_media` · `specialties` · `doctor_services` · `expertise` ·
`states` · `cities`
Notably excluded: `medical_system_code` (professional credential) and `active`. A representative
activates or deactivates their own doctor through `POST /api/v1/representation/doctors/{uuid}/status`
instead.
Every successful representative edit writes one `app_log` row with `channel = 'representation_edit'`,
recording the representative id, the target uuid, and the **names** of the changed fields (never
their values). Edits by the doctor or an admin write no such row.
### Request Body (`application/json`)
Same fields as POST (all optional), plus:
@@ -341,14 +373,41 @@ Same fields as POST (all optional), plus:
```
### Response `200`
Updated doctor object (same structure as GET single).
Updated doctor object (same structure as GET single), wrapped as `{ "success": true, "data": { ... } }`.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_AUTH_006` | 403 | Not the doctor, not an admin, and not the registering representative |
| `ERR_AUTH_006` | 403 | Representative sent a field outside the whitelist — the offending key is in `errors[0].field` |
| `ERR_VALIDATION_002` | 404 | Doctor not found |
Real responses (captured from a functional test run, not hand-written):
```json
{
"success": false,
"data": null,
"errors": [
{
"code": "ERR_AUTH_006",
"message": "نماینده اجازهٔ تغییر این فیلد را ندارد",
"field": "medical_system_code"
}
]
}
```
```json
{
"success": false,
"data": null,
"errors": [
{ "code": "ERR_AUTH_006", "message": "دسترسی ممنوع" }
]
}
```
---
@@ -487,11 +546,24 @@ Get all practice addresses for a doctor, including addresses of clinics the doct
Add a new practice address.
**Permission:** `AUTH`must own the doctor profile
**Permission:** `AUTH`the doctor themselves, `ROLE_ADMIN`, or the registering representative.
**Target resolution.** When `doctor_uuid` is present it is the target, and the caller must be that
doctor, an admin, or that doctor's registering representative. When it is absent the target is the
caller's own doctor profile. A representative who also happens to have a doctor profile therefore no
longer silently writes the address onto their own profile — the explicit uuid always wins.
| Caller | `doctor_uuid` present | `doctor_uuid` absent |
|---|---|---|
| The doctor | must be their own profile, else `403` | their own profile |
| `ROLE_ADMIN` | any doctor | `422``doctor_uuid` required |
| Registering representative | their own doctors only, else `403` | `422``doctor_uuid` required |
| Anyone else | `403` | `403` |
### Request Body
```json
{
"doctor_uuid": "9d5f94ba-322d-4735-88e1-b15e8447a0fa",
"name": "مطب تهران",
"address": "تهران، خیابان ولیعصر",
"telephone": "02112345678",
@@ -504,6 +576,7 @@ Add a new practice address.
| Field | Type | Required |
|-------|------|----------|
| `doctor_uuid` | string (UUID) | ✅ for admins and representatives; optional for the doctor themselves |
| `name` | string | ❌ |
| `address` | string | ✅ (frontend validation) |
| `telephone` | string | ✅ (frontend validation) |
@@ -540,7 +613,10 @@ Add a new practice address.
Update a practice address.
**Permission:** `AUTH`must own the doctor profile
**Permission:** `AUTH`the doctor themselves, `ROLE_ADMIN`, or the registering representative.
Only `type = personal` addresses are reachable here. A clinic address returns `403` regardless of
caller, including the representative — clinic addresses are edited through the clinic routes.
### Path Parameters
| Param | Type | Description |
@@ -548,17 +624,17 @@ Update a practice address.
| `id` | integer | Address ID |
### Request Body
Same fields as POST — all optional.
Same fields as POST — all optional. `doctor_uuid` is ignored; the target comes from the address itself.
### Response `200`
Updated address object.
Updated address object, double-wrapped as `{ "success": true, "data": { "data": { … } } }`.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Address not found |
| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative |
| `ERR_VALIDATION_002` | 404 | Address not found |
---
@@ -566,19 +642,20 @@ Updated address object.
Delete a practice address.
**Permission:** `AUTH`must own the doctor profile
**Permission:** `AUTH`the doctor themselves, `ROLE_ADMIN`, or the registering representative.
Clinic addresses return `403` here, same as PATCH.
### Response `200`
```json
{ "success": true, "data": { "message": "آدرس حذف شد" } }
{ "success": true, "data": { "message": "آدرس با موفقیت حذف شد" } }
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Address not found |
| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative |
| `ERR_VALIDATION_002` | 404 | Address not found |
---