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:
+68
-13
@@ -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
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user