Profile Overview
The Patient Portal Profile API lets the authenticated patient read and update their own profile (the /me resource). All access is self-only: the JWT subject is the only patient that can be read or updated through these endpoints — there is no :userId parameter and no admin-on-behalf path.
Endpoints
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/v1/users/me | Return the authenticated patient's own profile |
| 2 | PATCH | /api/v1/users/me | Update fields on the authenticated patient's own profile |
Authentication
Both endpoints require a successful /verify-otp exchange first. The resulting access JWT and the tenant API key are presented on every call.
| Header | Required | Description |
|---|---|---|
cv-api-key | Yes | Tenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR. |
Authorization | Yes | Bearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401. |
Content-Type | Yes (PATCH only) | Must be application/json. |
The patientPortalAuth() middleware enforces, in order:
cv-api-keyheader is present.Authorizationheader starts withBearer.- JWT verifies.
- JWT claim
type === 'patient-portal'(blocks staff/Firebase tokens). cv-api-keyresolves to an organization, and the resolvedorganization.idmatches the JWT'sorganizationIdclaim (blocks cross-tenant token replay).- The user referenced by the JWT still exists.
Any thrown error in this chain is collapsed into 401 VALIDATION_ERROR "Invalid or expired token".
Common Response Envelope
Successful responses always wrap the profile in data.profile:
{
"status": 200,
"success": true,
"data": { "profile": { "...": "see Profile Object below" } }
}
Error responses follow:
{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }
Profile Object
The profile object always contains exactly these 17 fields, in this shape. Any underlying value of undefined is normalized to null in the response.
| Field | Type | Nullable | Notes |
|---|---|---|---|
id | string (UUID) | No | The patient's User.id. Same value as patientId from verify-otp. |
email | string | No | Always present. Read-only via this API. |
firstName | string | null | Yes | |
lastName | string | null | Yes | |
phoneNumber | string | null | Yes | E.164 if present. |
dob | ISO-8601 datetime | null | Yes | Stored as Date; serialized as YYYY-MM-DDTHH:mm:ss.sssZ. |
gender | "MALE" | "FEMALE" | "OTHER" | null | Yes | Server enum. |
address | string | null | Yes | |
address2 | string | null | Yes | |
city | string | null | Yes | |
state | string | null | Yes | |
country | string | null | Yes | 2-letter uppercase ISO 3166-1 alpha-2 (server uppercases on write). |
postalCode | string | null | Yes | |
allergies | string | null | Yes | Free-text. |
healthConditions | string | null | Yes | Free-text. |
currentMedications | string | null | Yes | Free-text. |
createdAt | ISO-8601 datetime | No | Read-only. The user's account-creation timestamp. |
Restricted Fields and the Active-Case Rule
The fields firstName, lastName, dob, gender are gated by an active-case check on PATCH /me. The intent is to preserve identity continuity on case records that are mid-flight.
A case is considered active if its status is one of:
ApprovedAssignedInProgressNoDecisionRejected
Statuses outside that list — notably Open — are not active.
The check is per-organization: a patient with active cases in a different org may still update these fields when authenticated against an org with no active cases.
When the rule fires, no fields are updated — including the non-restricted fields in the same body. Surface a clear "complete or close active cases first" message and let the user retry without those four fields.
Server-Side Coercions
These transformations happen on the server and are observable in the response:
country— uppercased:"us"→"US".dob— accepted asYYYY-MM-DD(string), persisted asDate, returned as ISO-8601 datetime.undefinedfields — normalized tonullin the response.- Unknown body keys — silently dropped.
Security Properties
- Self-only access.
GET /meandPATCH /mealways operate on the JWT subject. There is no:userIdparameter and no admin-on-behalf path. - Token type pinned. Only JWTs with
type: 'patient-portal'reach the handler. - Cross-tenant defense. The JWT's
organizationIdis verified against thecv-api-key-resolved org on every call. - Identity continuity.
firstName,lastName,dob,gendercannot be changed while the patient has active cases in the calling organization. - No
emailmutation surface.emailis read-only via these endpoints. - Atomic update. Profile updates use a single
prisma.user.update; partial failures are not possible.
Integrator Guidance
- Always send
Authorization: Bearer <accessToken>andcv-api-keytogether; either alone fails. - Refresh proactively before the 15-minute access-token expiry. Both endpoints surface a generic
401 VALIDATION_ERRORon any auth failure, so do not rely on the message to distinguish "expired" vs "wrong tenant". - Use
nullto clear nullable fields (address,address2,city,postalCode,allergies,healthConditions,currentMedications). Omitting a field leaves it unchanged. - For
country, send any 2-letter ISO-3166-1 alpha-2 code in any case; expect uppercase back. - For
dob, sendYYYY-MM-DD; expect ISO-8601 back. Do not sendMM/DD/YYYY. - For
gender, onlyMALE,FEMALE,OTHERare accepted. Case-sensitive. - The PATCH response is the full updated profile, not a diff. Replace the local profile object on success.