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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user