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 |
---