diff --git a/Cargo.lock b/Cargo.lock index 537d15f..5475a12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -356,6 +356,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -894,6 +929,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1437,6 +1478,28 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1792,6 +1855,7 @@ dependencies = [ "jaq-std", "json_dotpath", "mockito", + "regex", "reqwest", "rpassword", "self_update", @@ -1800,6 +1864,7 @@ dependencies = [ "tempfile", "tokio", "uuid", + "validator", "webbrowser", "write_atomic", ] @@ -2449,6 +2514,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 283aab4..7b8b32a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ uuid = { version = "1", features = ["v4"] } rpassword = "7" json_dotpath = "1" self_update = { version = "0.43", default-features = false, features = ["reqwest", "rustls", "archive-tar", "compression-flate2"] } +validator = { version = "0.19", features = ["derive"] } +regex = "1" [dev-dependencies] mockito = "1" diff --git a/README.md b/README.md index a7deda4..f4e10a1 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ rw clinicians assign joe@example.com 60c0e3b8-64b6-491f-a502-7346d14b3192 rw clinicians prepare joe@example.com rw clinicians prepare 60fda0c4-eca0-434a-80d8-fd4e490aa051 +# Update a clinician attribute (by UUID, email, or "me") +rw clinicians update joe@example.com --field name --value "Jane Doe" +rw clinicians update 60fda0c4-eca0-434a-80d8-fd4e490aa051 --field email --value jane@example.com +rw clinicians update me --field npi --value 1234567890 +rw clinicians update me --field credentials --value "RN,MD" +rw clinicians update me --field npi # omit --value to clear (sends null) +rw clinicians update me --field credentials # omit --value to clear (sends []) + # Use a named profile rw clinicians enable joe@example.com --profile mercy ``` diff --git a/openspec/changes/archive/2026-04-05-clinicians-update/.openspec.yaml b/openspec/changes/archive/2026-04-05-clinicians-update/.openspec.yaml new file mode 100644 index 0000000..c551aea --- /dev/null +++ b/openspec/changes/archive/2026-04-05-clinicians-update/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-05 diff --git a/openspec/changes/archive/2026-04-05-clinicians-update/design.md b/openspec/changes/archive/2026-04-05-clinicians-update/design.md new file mode 100644 index 0000000..86d9748 --- /dev/null +++ b/openspec/changes/archive/2026-04-05-clinicians-update/design.md @@ -0,0 +1,68 @@ +## Context + +The CLI already has a `clinicians` command module (`src/commands/clinicians.rs`) with subcommands for `assign`, `enable`, `disable`, and `prepare`. These subcommands follow a consistent pattern: resolve a target clinician (UUID or email), build a JSON:API PATCH body, send it, and print the result. + +Target resolution today handles UUID or email strings. The `"me"` shorthand is not yet supported anywhere but the API likely exposes a `/clinicians/me` endpoint or the current user can be inferred from auth context. + +The four fields to support — `name`, `email`, `npi`, `credentials` — are all `attributes` on the clinician resource. The API accepts PATCH in JSON:API format. `credentials` is an array of strings in the schema, while the others are scalars. + +## Goals / Non-Goals + +**Goals:** +- Add `rw clinicians update --field --value ` +- Support `target` = `"me"`, email address, or UUID +- Validate each field client-side before sending to the API +- Send a JSON:API PATCH to update a single attribute at a time +- Print the updated clinician on success + +**Non-Goals:** +- Updating multiple fields in one command invocation (one `--field`/`--value` pair only) +- Adding to or removing individual items from the `credentials` array (the value replaces the entire array) +- Any changes to how other subcommands resolve targets + +## Decisions + +### 1. Single field per invocation + +**Decision:** Accept exactly one `--field`/`--value` pair per call. + +**Rationale:** Keeps the CLI surface minimal and consistent with the existing `--field`/`--value` pattern in other commands (e.g., `fields update`). Users wanting multiple field updates can chain commands. A multi-field flag design would require validating pairs and is significantly more complex. + +**Alternative considered:** `--name`, `--email`, etc. as individual flags. Rejected because it couples the CLI interface to the attribute names and requires new flags for any future fields. + +### 2. `"me"` resolution via `/clinicians` list filtered by auth identity + +**Decision:** Resolve `"me"` by calling `GET /clinicians?filter[me]=true` or by fetching `/auth/me` (or equivalent) to get the current user's UUID, then fetching the clinician by that UUID. If the API provides a `/clinicians/me` shorthand, use it directly. + +**Rationale:** The existing `require_auth` + `resolve_uuid_by_email` pattern doesn't cover the authenticated-user case. The cleanest solution is to add a `resolve_me()` helper that calls the appropriate endpoint. + +**Alternative considered:** Caching the current user's UUID in auth config. Rejected because it would go stale and adds write-back complexity. + +### 3. `credentials` value is comma-separated → array + +**Decision:** When `--field credentials` is used, `--value` is treated as a comma-separated list of credential strings (e.g., `"RN,MD"`), which is split into an array before sending. + +**Rationale:** The API stores credentials as `string[]`. The CLI value must be a single string argument. Comma-separation is a simple and widely-used convention for array inputs in CLI tools. + +**Alternative considered:** Repeated `--value` flags for arrays. Rejected because the current `--field`/`--value` signature uses a single value pair; extending to multiple values would require a flag redesign. + +### 4. Validation strategy + +**Decision:** Use the [`validator`](https://crates.io/crates/validator) crate for client-side validation. Define a struct per field (or a unified `ClinicianUpdateInput` struct) with `#[validate(...)]` annotations, call `validate()` before sending to the API, and fail fast with a clear error. + +| Field | Validation | +|--------------|-----------------------------------------| +| `name` | `#[validate(length(min = 1))]` after trimming | +| `email` | `#[validate(email)]` | +| `npi` | Empty or omitted `--value` sends `null` (clears NPI); non-empty value must pass `#[validate(length(equal = 10), regex(path = *NPI_RE))]` where `NPI_RE` matches `[0-9]{10}` | +| `credentials`| Empty or omitted `--value` sends `[]` (clears all); non-empty value is split on commas — no `validator` annotation needed | + +**Rationale:** The `validator` crate provides declarative, well-tested validation (including RFC-compliant email checking) and standardised error types, avoiding hand-rolled regex for email. It is the conventional Rust choice for struct-level input validation. + +**Alternative considered:** Hand-rolled regex / manual checks. Rejected in favour of `validator` to reduce bespoke validation code and leverage its email format support. + +## Risks / Trade-offs + +- **`"me"` resolution depends on an undiscovered endpoint** → Mitigation: check the OpenAPI spec for a `/clinicians/me` path or an auth profile endpoint; if absent, fetch the full clinician list and match against the authenticated identity. +- **`credentials` comma-split is lossy if a credential itself contains a comma** → Low risk in practice (credential strings like `"RN"`, `"MD"` never contain commas), but worth noting. +- **Single field per call means multiple roundtrips for bulk updates** → Acceptable trade-off given the scope of this feature. diff --git a/openspec/changes/archive/2026-04-05-clinicians-update/proposal.md b/openspec/changes/archive/2026-04-05-clinicians-update/proposal.md new file mode 100644 index 0000000..ce27055 --- /dev/null +++ b/openspec/changes/archive/2026-04-05-clinicians-update/proposal.md @@ -0,0 +1,31 @@ +## Why + +Clinician data sometimes needs to be corrected or updated after initial creation, but the CLI currently has no way to do this, requiring users to fall back to the web UI or direct API calls. Adding an `update` subcommand to `clinicians` closes this gap for common fields. + +## What Changes + +- New subcommand: `rw clinicians update --field --value ` +- `` accepts `"me"` (authenticated user), an email address, or a UUID +- Updatable fields: `name`, `email`, `npi`, `credentials` +- Field-specific validation before sending to the API: + - `name`: non-empty string + - `email`: valid email format + - `npi`: string or empty/null (nullable) + - `credentials`: one or more credential strings (e.g. `"RN"`) + +## Capabilities + +### New Capabilities + +- `clinicians-update`: Update a clinician's attributes via the CLI, with target resolution and per-field validation + +### Modified Capabilities + + + +## Impact + +- Adds a new `update` subcommand under the existing `clinicians` command module +- Makes a `PATCH` request to the clinicians API endpoint in JSON:API format +- Requires target resolution logic (me / email / UUID) shared with existing clinician lookup patterns +- No new dependencies expected diff --git a/openspec/changes/archive/2026-04-05-clinicians-update/specs/clinicians-update/spec.md b/openspec/changes/archive/2026-04-05-clinicians-update/specs/clinicians-update/spec.md new file mode 100644 index 0000000..c988a8b --- /dev/null +++ b/openspec/changes/archive/2026-04-05-clinicians-update/specs/clinicians-update/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Update a clinician attribute +The `clinicians update` subcommand SHALL update a single attribute of a clinician resource via a PATCH request to the API. The command SHALL accept a `--field` flag naming the attribute and a `--value` flag providing the new value. + +#### Scenario: Successful update by UUID +- **WHEN** the user runs `rw clinicians update --field name --value "Jane Doe"` +- **THEN** the CLI SHALL PATCH the clinician resource with the new name and print the updated clinician + +#### Scenario: Successful update by email +- **WHEN** the user runs `rw clinicians update jane@example.com --field email --value "jane2@example.com"` +- **THEN** the CLI SHALL resolve the email to a UUID, PATCH the resource, and print the updated clinician + +#### Scenario: Successful update as authenticated user +- **WHEN** the user runs `rw clinicians update me --field name --value "Jane Doe"` +- **THEN** the CLI SHALL resolve `"me"` to the currently authenticated clinician's UUID, PATCH the resource, and print the updated clinician + +#### Scenario: Unsupported field rejected +- **WHEN** the user specifies `--field` with a value not in `{name, email, npi, credentials}` +- **THEN** the CLI SHALL exit with a non-zero status and print an error listing the allowed fields + +### Requirement: Target resolution +The `` argument SHALL be resolved to a clinician UUID before making any API call. Three forms are accepted: `"me"` (authenticated user), a valid email address, or a UUID string. + +#### Scenario: UUID target passed through +- **WHEN** `` is a valid UUID string +- **THEN** the CLI SHALL use it directly as the clinician ID without any lookup + +#### Scenario: Email target resolved +- **WHEN** `` is an email address (contains `@`) +- **THEN** the CLI SHALL fetch the clinician list and resolve the matching clinician's UUID + +#### Scenario: "me" target resolved +- **WHEN** `` is the literal string `"me"` +- **THEN** the CLI SHALL resolve to the UUID of the currently authenticated user + +#### Scenario: Unknown target +- **WHEN** `` does not match a UUID, email, or `"me"`, and no clinician is found +- **THEN** the CLI SHALL exit with a non-zero status and print a descriptive error + +### Requirement: Field validation before API call +Each field SHALL be validated client-side before sending the PATCH request. Invalid values SHALL produce a clear error without making a network call. + +#### Scenario: name must be non-empty +- **WHEN** `--field name` is given with an empty or whitespace-only `--value` +- **THEN** the CLI SHALL exit with a non-zero status and report that name must be non-empty + +#### Scenario: email must have valid format +- **WHEN** `--field email` is given and `--value` does not contain `@` and a domain +- **THEN** the CLI SHALL exit with a non-zero status and report that the value is not a valid email address + +#### Scenario: npi must be exactly 10 digits when provided +- **WHEN** `--field npi` is given with a non-empty `--value` that is not exactly 10 decimal digits +- **THEN** the CLI SHALL exit with a non-zero status and report that NPI must be a 10-digit number + +#### Scenario: valid npi accepted +- **WHEN** `--field npi` is given with a string of exactly 10 decimal digits (e.g. `"1234567890"`) +- **THEN** the CLI SHALL send the value to the API + +#### Scenario: npi cleared when value is empty or omitted +- **WHEN** `--field npi` is given with an empty `--value` or with `--value` omitted +- **THEN** the CLI SHALL send `null` as the npi value in the PATCH body + +#### Scenario: credentials cleared when value is empty or omitted +- **WHEN** `--field credentials` is given with an empty `--value` or with `--value` omitted +- **THEN** the CLI SHALL send `[]` as the credentials array in the PATCH body + +#### Scenario: credentials split on comma +- **WHEN** `--field credentials --value "RN,MD"` is provided +- **THEN** the CLI SHALL send `["RN", "MD"]` as the credentials array in the PATCH body + +### Requirement: API PATCH in JSON:API format +The PATCH request SHALL conform to JSON:API format, including only the field being updated under `attributes`. + +#### Scenario: PATCH body structure +- **WHEN** the update command sends a request +- **THEN** the request body SHALL be `{"data": {"type": "clinicians", "id": "", "attributes": {"": }}}` + +#### Scenario: PATCH response contains updated clinician +- **WHEN** the API returns a 2xx response to the PATCH request +- **THEN** the CLI SHALL parse the full clinician resource from the response body (no additional GET required) + +#### Scenario: API error surfaced to user +- **WHEN** the API returns a non-2xx response +- **THEN** the CLI SHALL exit with a non-zero status and print the HTTP status code and response body diff --git a/openspec/changes/archive/2026-04-05-clinicians-update/tasks.md b/openspec/changes/archive/2026-04-05-clinicians-update/tasks.md new file mode 100644 index 0000000..c402780 --- /dev/null +++ b/openspec/changes/archive/2026-04-05-clinicians-update/tasks.md @@ -0,0 +1,56 @@ +## 1. Dependencies + +- [x] 1.1 Add `validator` crate (with `derive` feature) to `Cargo.toml` + +## 2. CLI Definition + +- [x] 2.1 Add `Update` variant to `CliniciansCommands` enum in `src/cli.rs` with `target`, `field`, and optional `value` args (`--value` is optional to support clearing credentials) +- [x] 2.2 Add match arm for `CliniciansCommands::Update` in `src/main.rs` routing to the new handler + +## 3. Target Resolution + +- [x] 3.1 Add `resolve_me()` helper in `src/commands/clinicians.rs` that resolves `"me"` to the authenticated clinician's UUID (via `/clinicians/me` or equivalent endpoint) +- [x] 3.2 Update target resolution in the `update` function to dispatch to `resolve_me()`, `resolve_uuid_by_email()`, or pass through UUID directly + +## 4. Field Validation + +- [x] 4.1 Define a `ClinicianUpdateInput` struct with `#[derive(Validate)]` and per-field annotations: + - `name`: `#[validate(length(min = 1))]` + - `email`: `#[validate(email)]` + - `npi`: `#[validate(length(equal = 10), regex(path = *NPI_RE))]` with a `[0-9]{10}` lazy static +- [x] 4.2 Implement `validate_field(field, value)` that populates the relevant struct field and calls `.validate()`; skip NPI format validation when value is empty/omitted (pass `null` through directly) +- [x] 4.3 Return a clear error for unrecognized field names listing allowed values + +## 5. Data Model + +- [x] 5.1 Add `npi: Option` and `credentials: Vec` to `ClinicianAttributes` in `src/commands/clinicians.rs` + +## 6. API Call + +- [x] 6.1 Implement `patch_clinician_attribute()` helper that builds the JSON:API PATCH body for a single field and sends it to `PATCH /clinicians/` +- [x] 6.2 For `credentials` field, split the value on commas before building the body (send as `string[]`); empty or omitted value sends `[]` +- [x] 6.3 Parse the full clinician resource from the PATCH response body into `ClinicianOutput` (no follow-up GET needed) + +## 7. Output + +- [x] 7.1 Implement `update()` public async function in `src/commands/clinicians.rs` wiring together resolution, validation, PATCH, and output +- [x] 7.2 Print the updated clinician using `out.print()` (plain: `" () updated "`, JSON: full resource) + +## 8. Tests + +- [x] 8.1 Write test: update by UUID (mock PATCH endpoint, assert body and output) +- [x] 8.2 Write test: update by email (mock GET clinicians list + PATCH, assert resolution and output) +- [x] 8.3 Write test: update with target `"me"` (mock me-resolution endpoint + PATCH) +- [x] 8.4 Write test: validation rejects empty name +- [x] 8.5 Write test: validation rejects invalid email +- [x] 8.6 Write test: validation rejects non-empty npi that is not exactly 10 digits (too short, too long, non-numeric) +- [x] 8.7 Write test: omitted or empty `--value` with `--field npi` sends `null` in PATCH body +- [x] 8.8 Write test: omitted or empty `--value` with `--field credentials` sends `[]` in PATCH body +- [x] 8.9 Write test: credentials split on comma produces correct array in PATCH body +- [x] 8.10 Write test: unsupported field name returns error +- [x] 8.11 Write test: API error response surfaced to caller + +## 9. Documentation + +- [x] 9.1 Update `README.md` with `clinicians update` usage and examples +- [x] 9.2 Update `docs/` if a clinicians command reference page exists diff --git a/openspec/specs/clinicians-update/spec.md b/openspec/specs/clinicians-update/spec.md new file mode 100644 index 0000000..cc7f3a2 --- /dev/null +++ b/openspec/specs/clinicians-update/spec.md @@ -0,0 +1,83 @@ +### Requirement: Update a clinician attribute +The `clinicians update` subcommand SHALL update a single attribute of a clinician resource via a PATCH request to the API. The command SHALL accept a `--field` flag naming the attribute and a `--value` flag providing the new value. + +#### Scenario: Successful update by UUID +- **WHEN** the user runs `rw clinicians update --field name --value "Jane Doe"` +- **THEN** the CLI SHALL PATCH the clinician resource with the new name and print the updated clinician + +#### Scenario: Successful update by email +- **WHEN** the user runs `rw clinicians update jane@example.com --field email --value "jane2@example.com"` +- **THEN** the CLI SHALL resolve the email to a UUID, PATCH the resource, and print the updated clinician + +#### Scenario: Successful update as authenticated user +- **WHEN** the user runs `rw clinicians update me --field name --value "Jane Doe"` +- **THEN** the CLI SHALL resolve `"me"` to the currently authenticated clinician's UUID, PATCH the resource, and print the updated clinician + +#### Scenario: Unsupported field rejected +- **WHEN** the user specifies `--field` with a value not in `{name, email, npi, credentials}` +- **THEN** the CLI SHALL exit with a non-zero status and print an error listing the allowed fields + +### Requirement: Target resolution +The `` argument SHALL be resolved to a clinician UUID before making any API call. Three forms are accepted: `"me"` (authenticated user), a valid email address, or a UUID string. + +#### Scenario: UUID target passed through +- **WHEN** `` is a valid UUID string +- **THEN** the CLI SHALL use it directly as the clinician ID without any lookup + +#### Scenario: Email target resolved +- **WHEN** `` is an email address (contains `@`) +- **THEN** the CLI SHALL fetch the clinician list and resolve the matching clinician's UUID + +#### Scenario: "me" target resolved +- **WHEN** `` is the literal string `"me"` +- **THEN** the CLI SHALL resolve to the UUID of the currently authenticated user + +#### Scenario: Unknown target +- **WHEN** `` does not match a UUID, email, or `"me"`, and no clinician is found +- **THEN** the CLI SHALL exit with a non-zero status and print a descriptive error + +### Requirement: Field validation before API call +Each field SHALL be validated client-side before sending the PATCH request. Invalid values SHALL produce a clear error without making a network call. + +#### Scenario: name must be non-empty +- **WHEN** `--field name` is given with an empty or whitespace-only `--value` +- **THEN** the CLI SHALL exit with a non-zero status and report that name must be non-empty + +#### Scenario: email must have valid format +- **WHEN** `--field email` is given and `--value` does not contain `@` and a domain +- **THEN** the CLI SHALL exit with a non-zero status and report that the value is not a valid email address + +#### Scenario: npi must be exactly 10 digits when provided +- **WHEN** `--field npi` is given with a non-empty `--value` that is not exactly 10 decimal digits +- **THEN** the CLI SHALL exit with a non-zero status and report that NPI must be a 10-digit number + +#### Scenario: valid npi accepted +- **WHEN** `--field npi` is given with a string of exactly 10 decimal digits (e.g. `"1234567890"`) +- **THEN** the CLI SHALL send the value to the API + +#### Scenario: npi cleared when value is empty or omitted +- **WHEN** `--field npi` is given with an empty `--value` or with `--value` omitted +- **THEN** the CLI SHALL send `null` as the npi value in the PATCH body + +#### Scenario: credentials cleared when value is empty or omitted +- **WHEN** `--field credentials` is given with an empty `--value` or with `--value` omitted +- **THEN** the CLI SHALL send `[]` as the credentials array in the PATCH body + +#### Scenario: credentials split on comma +- **WHEN** `--field credentials --value "RN,MD"` is provided +- **THEN** the CLI SHALL send `["RN", "MD"]` as the credentials array in the PATCH body + +### Requirement: API PATCH in JSON:API format +The PATCH request SHALL conform to JSON:API format, including only the field being updated under `attributes`. + +#### Scenario: PATCH body structure +- **WHEN** the update command sends a request +- **THEN** the request body SHALL be `{"data": {"type": "clinicians", "id": "", "attributes": {"": }}}` + +#### Scenario: PATCH response contains updated clinician +- **WHEN** the API returns a 2xx response to the PATCH request +- **THEN** the CLI SHALL parse the full clinician resource from the response body (no additional GET required) + +#### Scenario: API error surfaced to user +- **WHEN** the API returns a non-2xx response +- **THEN** the CLI SHALL exit with a non-zero status and print the HTTP status code and response body diff --git a/src/cli.rs b/src/cli.rs index 4ddd1ba..f08ed00 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -242,6 +242,8 @@ pub enum CliniciansCommands { Disable(CliniciansTargetArgs), /// Prepare a clinician with the appropriate role, team, and workspace memberships. Prepare(CliniciansTargetArgs), + /// Update a clinician attribute by UUID, email, or "me". + Update(CliniciansUpdateArgs), } /// Arguments for `clinicians assign`. @@ -260,6 +262,19 @@ pub struct CliniciansTargetArgs { pub target: String, } +/// Arguments for `clinicians update`. +#[derive(Args, Debug)] +pub struct CliniciansUpdateArgs { + /// Clinician UUID, email address, or "me". + pub target: String, + /// Field to update (name, email, npi, credentials). + #[arg(long)] + pub field: String, + /// New value for the field (omit to clear npi or credentials). + #[arg(long)] + pub value: Option, +} + /// Arguments for the `auth` subcommand. #[derive(Args, Debug)] pub struct AuthArgs { diff --git a/src/commands/clinicians.rs b/src/commands/clinicians.rs index a698cb5..64d51a7 100644 --- a/src/commands/clinicians.rs +++ b/src/commands/clinicians.rs @@ -1,11 +1,97 @@ use anyhow::{bail, Context, Result}; +use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; use uuid::Uuid; +use validator::Validate; use crate::config::AppContext; use crate::output::{CommandOutput, Output}; +// --- Validation --- + +static NPI_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\d{10}$").unwrap()); + +#[derive(Validate)] +struct ClinicianUpdateInput { + #[validate(length(min = 1))] + name: Option, + #[validate(email)] + email: Option, + #[validate(length(equal = 10), regex(path = *NPI_RE))] + npi: Option, +} + +const ALLOWED_FIELDS: &[&str] = &["name", "email", "npi", "credentials"]; + +fn validate_field(field: &str, value: Option<&str>) -> Result<()> { + match field { + "name" => { + let v = value.unwrap_or("").trim().to_string(); + let input = ClinicianUpdateInput { + name: Some(v), + email: None, + npi: None, + }; + input + .validate() + .map_err(|e| anyhow::anyhow!("invalid name: {}", e))?; + } + "email" => { + let v = value.unwrap_or("").to_string(); + let input = ClinicianUpdateInput { + name: None, + email: Some(v), + npi: None, + }; + input + .validate() + .map_err(|e| anyhow::anyhow!("invalid email: {}", e))?; + } + "npi" => { + if let Some(v) = value { + if !v.is_empty() { + let input = ClinicianUpdateInput { + name: None, + email: None, + npi: Some(v.to_string()), + }; + input + .validate() + .map_err(|e| anyhow::anyhow!("invalid NPI: {}", e))?; + } + } + } + "credentials" => {} + _ => { + bail!( + "unsupported field '{}'; allowed fields: {}", + field, + ALLOWED_FIELDS.join(", ") + ); + } + } + Ok(()) +} + +fn build_attribute_value(field: &str, value: Option<&str>) -> serde_json::Value { + match field { + "credentials" => match value { + None | Some("") => serde_json::json!([]), + Some(v) => { + let parts: Vec<&str> = v.split(',').collect(); + serde_json::json!(parts) + } + }, + "npi" => match value { + None | Some("") => serde_json::Value::Null, + Some(v) => serde_json::json!(v), + }, + _ => serde_json::json!(value.unwrap_or("")), + } +} + // --- JSON:API deserialization types --- #[derive(Debug, Deserialize)] @@ -13,6 +99,10 @@ struct ClinicianAttributes { name: String, email: String, enabled: bool, + #[serde(default)] + npi: Option, + #[serde(default)] + credentials: Vec, } #[derive(Debug, Deserialize)] @@ -151,6 +241,24 @@ impl CommandOutput for ClinicianOutput { } } +#[derive(Debug, Serialize)] +pub struct ClinicianUpdateOutput { + pub id: String, + pub name: String, + pub email: String, + pub enabled: bool, + pub npi: Option, + pub credentials: Vec, + #[serde(skip)] + pub updated_field: String, +} + +impl CommandOutput for ClinicianUpdateOutput { + fn plain(&self) -> String { + format!("{} ({}) updated {}", self.name, self.id, self.updated_field) + } +} + // --- Public command functions --- pub async fn assign(ctx: &AppContext, target: &str, role_target: &str, out: &Output) -> Result<()> { @@ -270,6 +378,33 @@ pub async fn disable(ctx: &AppContext, target: &str, out: &Output) -> Result<()> set_enabled(ctx, target, false, out).await } +pub async fn update( + ctx: &AppContext, + target: &str, + field: &str, + value: Option<&str>, + out: &Output, +) -> Result<()> { + validate_field(field, value)?; + + let auth_header = require_auth(ctx).await?; + let client = Client::new(); + + let uuid = if target == "me" { + resolve_me(&client, &ctx.base_url, &auth_header).await? + } else if Uuid::parse_str(target).is_ok() { + target.to_string() + } else { + resolve_uuid_by_email(&client, &ctx.base_url, &auth_header, target).await? + }; + + let result = + patch_clinician_attribute(&client, &ctx.base_url, &auth_header, &uuid, field, value) + .await?; + out.print(&result); + Ok(()) +} + // --- Private helpers --- async fn set_enabled(ctx: &AppContext, target: &str, enabled: bool, out: &Output) -> Result<()> { @@ -298,6 +433,64 @@ fn apply_auth(req: reqwest::RequestBuilder, auth_header: &str) -> reqwest::Reque req.header(reqwest::header::AUTHORIZATION, auth_header) } +async fn resolve_me(client: &Client, base_url: &str, auth_header: &str) -> Result { + let url = format!("{}/clinicians/me", base_url.trim_end_matches('/')); + let req = apply_auth(client.get(&url), auth_header); + let resp = req.send().await.context("GET /clinicians/me failed")?; + let status = resp.status(); + let body = resp.text().await.context("failed to read response body")?; + if !status.is_success() { + bail!("API returned {}: {}", status, body); + } + let response: ClinicianSingleResponse = + serde_json::from_str(&body).context("failed to parse clinician response")?; + Ok(response.data.id) +} + +async fn patch_clinician_attribute( + client: &Client, + base_url: &str, + auth_header: &str, + uuid: &str, + field: &str, + value: Option<&str>, +) -> Result { + let url = format!("{}/clinicians/{}", base_url.trim_end_matches('/'), uuid); + + let attr_value = build_attribute_value(field, value); + let body = serde_json::json!({ + "data": { + "type": "clinicians", + "id": uuid, + "attributes": { + field: attr_value + } + } + }); + + let req = apply_auth(client.patch(&url), auth_header).json(&body); + let resp = req.send().await.context("PATCH /clinicians failed")?; + let status = resp.status(); + let body_text = resp.text().await.context("failed to read response body")?; + + if !status.is_success() { + bail!("API returned {}: {}", status, body_text); + } + + let response: ClinicianSingleResponse = + serde_json::from_str(&body_text).context("failed to parse clinician response")?; + + Ok(ClinicianUpdateOutput { + id: response.data.id, + name: response.data.attributes.name, + email: response.data.attributes.email, + enabled: response.data.attributes.enabled, + npi: response.data.attributes.npi, + credentials: response.data.attributes.credentials, + updated_field: field.to_string(), + }) +} + async fn resolve_uuid_by_email( client: &Client, base_url: &str, @@ -1539,4 +1732,364 @@ mod tests { let result = prepare(&_auth.app_context(&server.url()), uuid, &out).await; assert!(result.is_ok()); } + + fn update_clinician_response( + id: &str, + name: &str, + email: &str, + enabled: bool, + npi: Option<&str>, + credentials: &[&str], + ) -> String { + serde_json::json!({ + "data": { + "type": "clinicians", + "id": id, + "attributes": { + "name": name, + "email": email, + "enabled": enabled, + "npi": npi, + "credentials": credentials + } + } + }) + .to_string() + } + + // 8.1 — update by UUID + #[tokio::test] + async fn test_update_by_uuid() { + let _auth = TestAuthGuard::new(); + let mut server = Server::new_async().await; + let uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + + let mock = server + .mock("PATCH", format!("/clinicians/{}", uuid).as_str()) + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "data": { + "type": "clinicians", + "id": uuid, + "attributes": { "name": "Jane Doe" } + } + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(update_clinician_response( + uuid, + "Jane Doe", + "jane@example.com", + true, + None, + &[], + )) + .create_async() + .await; + + let out = Output { json: false }; + update( + &_auth.app_context(&server.url()), + uuid, + "name", + Some("Jane Doe"), + &out, + ) + .await + .unwrap(); + + mock.assert_async().await; + } + + // 8.2 — update by email + #[tokio::test] + async fn test_update_by_email() { + let _auth = TestAuthGuard::new(); + let mut server = Server::new_async().await; + let uuid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + let email = "jane@example.com"; + + let get_mock = server + .mock("GET", "/clinicians") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(clinician_list_response(&[(uuid, "Jane", email, true)])) + .create_async() + .await; + + let patch_mock = server + .mock("PATCH", format!("/clinicians/{}", uuid).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(update_clinician_response( + uuid, + "Jane", + "jane2@example.com", + true, + None, + &[], + )) + .create_async() + .await; + + let out = Output { json: false }; + update( + &_auth.app_context(&server.url()), + email, + "email", + Some("jane2@example.com"), + &out, + ) + .await + .unwrap(); + + get_mock.assert_async().await; + patch_mock.assert_async().await; + } + + // 8.3 — update with target "me" + #[tokio::test] + async fn test_update_by_me() { + let _auth = TestAuthGuard::new(); + let mut server = Server::new_async().await; + let uuid = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + + let me_mock = server + .mock("GET", "/clinicians/me") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(update_clinician_response( + uuid, + "Me User", + "me@example.com", + true, + None, + &[], + )) + .create_async() + .await; + + let patch_mock = server + .mock("PATCH", format!("/clinicians/{}", uuid).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(update_clinician_response( + uuid, + "New Name", + "me@example.com", + true, + None, + &[], + )) + .create_async() + .await; + + let out = Output { json: false }; + update( + &_auth.app_context(&server.url()), + "me", + "name", + Some("New Name"), + &out, + ) + .await + .unwrap(); + + me_mock.assert_async().await; + patch_mock.assert_async().await; + } + + // 8.4 — validation rejects empty name + #[test] + fn test_validate_field_rejects_empty_name() { + let result = validate_field("name", Some("")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid name")); + } + + // 8.5 — validation rejects invalid email + #[test] + fn test_validate_field_rejects_invalid_email() { + let result = validate_field("email", Some("not-an-email")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid email")); + } + + // 8.6 — validation rejects non-10-digit NPI + #[test] + fn test_validate_field_rejects_invalid_npi() { + // too short + let result = validate_field("npi", Some("12345")); + assert!(result.is_err(), "expected error for too-short NPI"); + // too long + let result = validate_field("npi", Some("12345678901")); + assert!(result.is_err(), "expected error for too-long NPI"); + // non-numeric + let result = validate_field("npi", Some("123456789a")); + assert!(result.is_err(), "expected error for non-numeric NPI"); + } + + // 8.7 — omitted/empty value with --field npi sends null + #[tokio::test] + async fn test_update_npi_null_when_empty() { + let _auth = TestAuthGuard::new(); + let mut server = Server::new_async().await; + let uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd"; + + let mock = server + .mock("PATCH", format!("/clinicians/{}", uuid).as_str()) + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "data": { + "attributes": { "npi": serde_json::Value::Null } + } + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(update_clinician_response( + uuid, + "Doc", + "doc@example.com", + true, + None, + &[], + )) + .create_async() + .await; + + let out = Output { json: false }; + // omitted value (None) + update(&_auth.app_context(&server.url()), uuid, "npi", None, &out) + .await + .unwrap(); + + mock.assert_async().await; + } + + // 8.8 — omitted/empty value with --field credentials sends [] + #[tokio::test] + async fn test_update_credentials_empty_sends_array() { + let _auth = TestAuthGuard::new(); + let mut server = Server::new_async().await; + let uuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + + let mock = server + .mock("PATCH", format!("/clinicians/{}", uuid).as_str()) + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "data": { + "attributes": { "credentials": [] } + } + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(update_clinician_response( + uuid, + "Doc", + "doc@example.com", + true, + None, + &[], + )) + .create_async() + .await; + + let out = Output { json: false }; + update( + &_auth.app_context(&server.url()), + uuid, + "credentials", + None, + &out, + ) + .await + .unwrap(); + + mock.assert_async().await; + } + + // 8.9 — credentials split on comma + #[tokio::test] + async fn test_update_credentials_split_on_comma() { + let _auth = TestAuthGuard::new(); + let mut server = Server::new_async().await; + let uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + + let mock = server + .mock("PATCH", format!("/clinicians/{}", uuid).as_str()) + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "data": { + "attributes": { "credentials": ["RN", "MD"] } + } + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(update_clinician_response( + uuid, + "Doc", + "doc@example.com", + true, + None, + &["RN", "MD"], + )) + .create_async() + .await; + + let out = Output { json: false }; + update( + &_auth.app_context(&server.url()), + uuid, + "credentials", + Some("RN,MD"), + &out, + ) + .await + .unwrap(); + + mock.assert_async().await; + } + + // 8.10 — unsupported field name returns error + #[test] + fn test_validate_field_rejects_unknown_field() { + let result = validate_field("unknown_field", Some("value")); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("unsupported field"), + "expected 'unsupported field' in: {}", + err + ); + assert!( + err.contains("name") && err.contains("email"), + "expected allowed fields listed in: {}", + err + ); + } + + // 8.11 — API error response surfaced to caller + #[tokio::test] + async fn test_update_api_error_surfaced() { + let _auth = TestAuthGuard::new(); + let mut server = Server::new_async().await; + let uuid = "11111111-2222-3333-4444-555555555555"; + + let _mock = server + .mock("PATCH", format!("/clinicians/{}", uuid).as_str()) + .with_status(422) + .with_body(r#"{"errors":[{"detail":"invalid"}]}"#) + .create_async() + .await; + + let out = Output { json: false }; + let result = update( + &_auth.app_context(&server.url()), + uuid, + "name", + Some("Jane"), + &out, + ) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("422"), "expected 422 status in: {}", err); + } } diff --git a/src/main.rs b/src/main.rs index 1907bfb..721233e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,6 +111,16 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { CliniciansCommands::Prepare(args) => { commands::clinicians::prepare(&ctx, &args.target, out).await?; } + CliniciansCommands::Update(args) => { + commands::clinicians::update( + &ctx, + &args.target, + &args.field, + args.value.as_deref(), + out, + ) + .await?; + } } } Commands::Api(api_args) => {