Linking a Patient and a Practitioner to the Same U...
# support
t
Hello, We are trying to implement a use case where a single user (same email address) can act both as a Patient and a Practitioner in Apneal. Our understanding of the model: • Create a Patient, a User, and a Practitioner. • Link the Patient and the User via a ProjectMembership. • Link the Practitioner and the User via another ProjectMembership. Issue encountered: When testing invitations from the Medplum project admin interface: • If we invite a Practitioner first and then a Patient using the same email address, Medplum creates two separate Users. • This leads to a login issue: the user is automatically logged in as the Practitioner, with no access to the Patient role. Our attempted approach: We tried the following: 1. Use the medplum.invite() command to invite a Practitioner. 2. Manually create a Patient, then create a ProjectMembership linking this Patient to the User already created during the invitation. Current limitation: • Creating the ProjectMembership manually requires super admin credentials. • Once created, the ProjectMembership is only accessible via the super admin account. • As a project admin, we cannot create or manage this ProjectMembership. • This leaves us stuck in a loop with no clear way forward. Question: Could you please advise us on the best practice for handling this use case (a single User linked to both a Patient and a Practitioner)? Thank you for your support. Best regards, The Apneal Team
@reshma @rahul1
We've understood that the invite created two users (one project scoped for the patient, one server scoped for the practitioner). We still would like to have the possibility to create a projectMembership for the practitioner and the patient using only one user !
a
I think I remember reading something a while back on this discord where someone was trying to assign Practitioner and Patient to same User email / login. And I remember the response being something like -- that this was not then possible -- and that the recommended best practice is to have a separate email/login for practioner account and separate login for patient account. But that's just my recollection from about 6 months ago. Don't know if things have changed since then, but that was the status at the time.
t
Thanks @albert_wong ! Would love to have confirmation about that from the Medplum team ! It looks like we manage to do it, but only by having the projectmembership creation manually (through API) using a client that is a super admin !
f
Hi @thomabig , there is a way to do this using the same User by calling[ medplum.invite()](https://www.medplum.com/docs/sdk/core.inviterequest) with the
forceNewMembership
param set to True. I would proceed with caution here because generally this can become complicated with the way that you authenticate, and it is a faily new feature. By default, the least recently used ProjectMembership will be used on login. Unfortunately, there was not an easy way to pass a ProjectMembership to specify which ProjectMembership to login with, but you can do this with the MedplumClient after authenticating. See how Medplum app skeleton does that [here](https://github.com/medplum/medplum/blob/b132fea7f9c732d541e05e58e1f1db4396aacffd/packages/react/src/AppShell/HeaderDropdown.tsx#L44-L49) with setActiveLogin(). I think this is your best bet. Alternatively, you could also just use the [on-behalf-of](https://www.medplum.com/docs/auth/methods/on-behalf-of) header with your Patient or Practitioner ProjectMembership depending on who you are logged in with. Generally we recommend creating the ProjectMeberships with the medplum.invite() because it is accessible to admins.
t
Thanks @finnbergquist ! We will test this and let you know. Do you know what minimum medplum version needs to run to use this ? Thanks
f
Release Version 4.3.7
t
Hi @finnbergquist, thanks again for your help! We’ve updated our Medplum server to 4.3.11-558184a29, which should include the forceNewMembership feature. Unfortunately, it still doesn’t seem to work on our side. Here’s what we tried: First invitation (Practitioner):
Copy code
curl https://a**ai/admin/projects/019**51/invite \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceType": "Practitioner",
    "firstName": "**",
    "lastName": "**",
    "email": "dr@ex.gov",
    "scope": "project"
  }'
Second invitation (same email, with forceNewMembership):
Copy code
curl https://a**ai/admin/projects/019**51/invite \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "resourceType": "Practitioner",
    "firstName": "George",
    "lastName": "Washington",
    "email": "dr@ex.gov",
    "forceNewMembership": true,
    "scope": "project"
  }'
Error returned: { "resourceType":"OperationOutcome", .... "text":"**Key (project, email)=(Project/0197bff0-7b52-772f-84fb-66f1d6a9f251, dr.gw@example.gov) already exists**." We looked a bit deeper into the invite implementation. The problem seems to happen at the conditionalCreate step: After the first invite, we expected conditionalCreate to find the existing User. Instead, matches comes back as an empty array, so Medplum tries to create a new User → and that fails with the duplicate conflict. We tried several variations (inviting the same Patient twice, the same Practitioner twice, or a Patient + Practitioner with the same email). The result is always the same conflict. 👉 Do you have any idea why matches would return empty even though the User already exists with that email? 👉 Is there a recommended way to make forceNewMembership work in this scenario? Thanks a lot for your support!
f
Hmm, can you check if the existing User is project or server scoped? If the User resource has a
User.meta.project
field, then it is project scoped. Otherwise, it is server scoped.
If the existing User was server scoped, and then you tried to invite again with
forceNewMembership
, I don't think that the server would not find the existing one.
t
Yes sure @User I've just tried with the following patient :
Copy code
curl https://api.ai/admin/projects/01**66f1d6a9f251/invite \
  -d '{
  "resourceType": "Patient",
  "firstName": "George",
  "lastName": "Washington",
  "email": "patient@example.gov",
  "scope": "project",
  "forceNewMembership": true
}'
The user has a meta.project
Copy code
{
  "resourceType": "User",
  "meta": {
    "project": "0197bff0-7b52-772f-84fb-66f1d6a9f251",
    "versionId": "69c0b2a3-6e45-4d30-8ebf-a44a400a0e25",
    "lastUpdated": "2025-09-03T21:27:38.277Z",
    "author": {
      "reference": "system"
    },
    "compartment": [
      {
        "reference": "Project/0197bff0-7b52-772f-84fb-66f1d6a9f251"
      }
    ]
  },
  "firstName": "George",
  "lastName": "Washington",
  "email": "patient@example.gov",
  "project": {
    "reference": "Project/0197bff0-7b52-772f-84fb-66f1d6a9f251",
    "display": "Apneal"
  },
  "id": "4c513862-878f-4fa9-9fd8-6a5d77880352"
}
If I look for the patient using the following curl
Copy code
curl https://api.uat.apneal.ai/fhir/R4/Patient\?email\=patient@example.gov \
-H "Authorization: Bearer A"
{"resourceType":"Bundle","type":"searchset","entry":[{"fullUrl":"https://api.uat.apneal.ai/fhir/R4/Patient/ff32b6e1-bb29-483c-8ee4-a7060562a1ad","search":{"mode":"match"},"resource":{"resourceType":"Patient","meta":{"versionId":"48d77df7-520a-4842-bff2-fde62294ad1a","lastUpdated":"2025-09-03T21:27:38.307Z"},"name":[{"given":["George"],"family":"Washington"}],"telecom":[{"system":"email","use":"work","value":"patient@example.gov"}],"id":"ff32b6e1-bb29-483c-8ee4-a7060562a1ad"}}],"link":[{"relation":"self","url":"https://api.uat.apneal.ai/fhir/R4/Patient?_count=20&email=patient%40example.gov"},{"relation":"first","url":"https://api.uat.apneal.ai/fhir/R4/Patient?_count=20&email=patient%40example.gov"}]}%
I do retrieve the patient, but my understanding is that I cannot search for the user from the API.
In other hands I actually think that there is a bug here in the inviteUser function
Copy code
if (email) {
    const searchRequest: SearchRequest<User> = {
      resourceType: 'User',
      filters: [
        {
          code: 'email',
          operator: Operator.EXACT,
          value: email,
        },
        request.resourceType === 'Patient'
          ? { code: 'project', operator: Operator.EQUALS, value: project.id }
          : { code: 'project', operator: Operator.MISSING, value: 'true' },
      ],
    };
Indeed if the type is Practitioner it will only search for server scoped User even if the invitation specifies "scoped"
f
> I do retrieve the patient, but my understanding is that I cannot search for the user from the API. For project scoped Users, that is not the case. If you are a project admin (ProjectMembership.admin = true), you should be able to search for that User resource.
For all of these API requests you are making, how are you authenticating?
t
I'm a Project Admin
Not a superadmin
so maybe this means I need to do the invite using superadmin clientapp ?
f
No you shouldn't need to do that. But, if you are using client credentials to auth, you should make sure that the ClientApplication that is used has a ProjectMembership with admin = true.
No access policy is associated to the projectmembership that could restrain the access to some resources
so i'm not too sure where to keep looking :/
f
Ok, so after you invite the first User as a Patient, can you confirm that that User resource exists?
You can search for it with
.../User?email=<your-email>
And then, could you send the second payload when you call invite again, now with Practitioner resourceType
t
First invitation
Copy code
curl https://api.al.ai/admin/projects/0197bb-66f1d6a9f251/invite \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NNiA" \
  -H "Content-Type: application/json" \
  -d '{
  "resourceType": "Patient",
  "firstName": "Test",
  "lastName": "ForDiscor",
  "email": "testForDiscord@example.com"
}'
{"resourceType":"ProjectMembership","project":{"reference":"Project/0197bff0-7b52-772f-84fb-66f1d6a9f251","display":"Apneal"},"user":{"reference":"User/a082e252-0295-4999-b76d-d774dcc39cc2","display":"testfordiscord@example.com"},"profile":{"reference":"Patient/fbdb8e4c-12d4-4b55-96fd-c58182d40058","display":"Test ForDiscor"},"id":"721f80b1-00e3-4de7-8846-4b3619c7331f","meta":{"versionId":"a735a820-b539-4da6-8597-6adc4cfd0f7e","lastUpdated":"2025-09-03T22:11:33.859Z","author":{"reference":"system"},"project":"0197bff0-7b52-772f-84fb-66f1d6a9f251","compartment":[{"reference":"Project/0197bff0-7b52-772f-84fb-66f1d6a9f251"}]}}
User Resource created :
Copy code
{
  "resourceType": "User",
  "meta": {
    "project": "0197bff0-7b52-772f-84fb-66f1d6a9f251",
    "versionId": "6c0f8e69-e963-4a57-a904-2b8799065c20",
    "lastUpdated": "2025-09-03T22:11:33.805Z",
    "author": {
      "reference": "system"
    },
    "compartment": [
      {
        "reference": "Project/0197bff0-7b52-772f-84fb-66f1d6a9f251"
      }
    ]
  },
  "firstName": "Test",
  "lastName": "ForDiscor",
  "email": "testfordiscord@example.com",
  "project": {
    "reference": "Project/0197bff0-7b52-772f-84fb-66f1d6a9f251",
    "display": "Apneal"
  },
  "id": "a082e252-0295-4999-b76d-d774dcc39cc2"
}
Second payload inviting a practitioner with the same email address :
Copy code
curl https://api.uat.apneal.ai/admin/projects/0197bff0-7b52-772f-84fb-66f1d6a9f251/invite \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjAxOTc5Y2U1LTJhNTUtNzM4YS05NzY4LWNjNTU3YjY1OWUxNSIsInR5cCI6IkpXVCJ9.eyJsb2dpbl9pZCI6IjkxYjgzYzQ0LTI0NTgtNDYyNy1hNzVhLWYwMDRiODBkODMzYSIsInN1YiI6IjAxOTdjNjVkLTYxMjktNzUzNC1iMjgzLThlOTlhMDZkMDU0ZCIsInVzZXJuYW1lIjoiMDE5N2M2NWQtNjEyOS03NTM0LWIyODMtOGU5OWEwNmQwNTRkIiwic2NvcGUiOiJvcGVuaWQiLCJwcm9maWxlIjoiUHJhY3RpdGlvbmVyLzAxOTdjNjVkLTYxNDMtNzIwOC1iMzM3LTQyYzJkNTBmOTVmZSIsImlhdCI6MTc1NjkzNDM5MSwiaXNzIjoiaHR0cHM6Ly9hcGkudWF0LmFwbmVhbC5haS8iLCJleHAiOjE3NTY5Mzc5OTF9.TmUwAzJ4oJ0J-D9lIoDcjXJFONPcTUUiLvmxM3LcvBF8iJsqElpMFXrvhRR48TwVCsxMKHsV33w0q_5bnlB-qtWaC10HK_eUuKSwMThxhsnQnTQrbrv3kcQxR4v5th6ogzYJEg1PADOhP9QD4xRn8JrgwD4FZTDC2IJ65EI6M78dNu3b-XhEq-sevmHBQ23ltLdiPxpW-DjMm3u-W6UOEDHpZVd0WUH2hNEMfcK-IlOGsGqGNB1KCZinfuzMa4NrNVRdymoiatN6_oB0RARF4bAHDyCX8ua1PDErpX6JGS9HETFf-3wOCLhhATlB5v_s8BlBTiHZXjSxxDou-EWNiA" \
  -H "Content-Type: application/json" \
  -d '{
  "resourceType": "Practitioner",
  "firstName": "Test",
  "lastName": "ForDiscor Practi",
  "email": "testForDiscord@example.com",
  "forceNewMembership": true
}'
{"resourceType":"ProjectMembership","project":{"reference":"Project/0197bff0-7b52-772f-84fb-66f1d6a9f251","display":"Apneal"},"user":{"reference":"User/818fa4bd-c6d3-4845-8a52-0d9870336a72","display":"testfordiscord@example.com"},"profile":{"reference":"Practitioner/d9d22b97-83e8-4a3b-98fc-bcf735b97d01","display":"Test ForDiscor Practi"},"id":"0babccbb-14db-48ab-8eb0-780f9bc9aa4f","meta":{"versionId":"0631f550-e879-47fd-a505-f45e64451acf","lastUpdated":"2025-09-03T22:15:53.231Z","author":{"reference":"system"},"project":"0197bff0-7b52-772f-84fb-66f1d6a9f251","compartment":[{"reference":"Project/0197bff0-7b52-772f-84fb-66f1d6a9f251"}]}}%
The invitation did work but wait a minute.
SO I actually have two Users with the same email
let me retry
Yes same error :
Copy code
curl https://api.ai/admin/projects/0197bff0--84fb-66f1d6a9f251/invite \
  -H "Authorization: Bearer eyJ-EWNiA" \
  -H "Content-Type: application/json" \
  -d '{
  "resourceType": "Patient",
  "firstName": "Test",
  "lastName": "ForDiscor Patient",
  "email": "testForDiscord2@example.com"
}'
{"resourceType":"ProjectMembership","project":{"reference":"Project/0197bff0-7b52-772f-84fb-66f1d6a9f251","display":"Apneal"},"user":{"reference":"User/ff29005f-ba6a-4f35-ad03-1f7397e2796a","display":"testfordiscord2@example.com"},"profile":{"reference":"Patient/7cb0da67-b168-4601-a1f3-0946928b0c74","display":"Test ForDiscor Patient"},"id":"7567a178-a770-4163-b594-d316bc291acc","meta":{"versionId":"5d8a00f6-9f8b-4759-afeb-0a4f516205c4","lastUpdated":"2025-09-03T22:18:19.473Z","author":{"reference":"system"},"project":"0197bff0-7b52-772f-84fb-66f1d6a9f251","compartment":[{"reference":"Project/0197bff0-7b52-772f-84fb-66f1d6a9f251"}]}}%
Copy code
➜  ~ curl https://api.uat.apneal.ai/admin/projects/0197bff0-7b52-772f-84fb-66f1d6a9f251/invite \
  -H "Authorization: Bearer eyJWNiA" \
  -H "Content-Type: application/json" \
  -d '{
  "resourceType": "Practitioner",
  "firstName": "Test",
  "lastName": "ForDiscor Practi",
  "email": "testForDiscord2@example.com",
  "forceNewMembership": true,
  "scope": "project"
}'
{"resourceType":"OperationOutcome","id":"conflict","issue":[{"severity":"error","code":"conflict","details":{"text":"Key (project, email)=(Project/0197bff0-7b52-772f-84fb-66f1d6a9f251, testfordiscord2@example.com) already exists."}}],"extension":[{"url":"https://medplum.com/fhir/StructureDefinition/tracing","extension":[{"url":"requestId","valueId":"46ebe6b7-245f-4d6a-8c66-73811e22db56"},{"url":"traceId","valueId":"583c5323-e092-4a15-bcda-682603495200"}]}]}%
Also this is the searchRequest that returns 0 Users in the conditionalCreate {"resourceType":"User","filters":[{"code":"email","operator":"exact","value":"testfordiscord2@example.com"},{"code":"project","operator":"missing","value":"true"}]}
f
Ok, I am gonna try to reproduce
t
so here my understanding is : When you invite first the patient and then the project scoped Practitioner => You have a bug because you force to search for a server scoped Practitioner What I don't understand is why when inviting the other way around it doesn't work : First Projectscoped practitioner and then patient ! Here's the searchRequest {"resourceType":"User","filters":[{"code":"email","operator":"exact","value":"testfordiscord3@example.com"},{"code":"project","operator":"eq","value":"0197bff0-7b52-772f-84fb-66f1d6a9f251"}]} And this is where I don't understand why it doesn't work and retrieves 0 users !
Thanks a lot @finnbergquist ! I'm going to bed hopefully you manage to reproduce the bug ! if needed we can have a call
f
> What I don't understand is why when inviting the other way around it doesn't work : > > First Projectscoped practitioner and then patient ! Yea, good point. I think you uncovered a bug here.
Copy code
request.resourceType === 'Patient'
          ? { code: 'project', operator: Operator.EQUALS, value: project.id }
          : { code: 'project', operator: Operator.MISSING, value: 'true' },
should be
Copy code
request.resourceType === 'Patient'
          ? { code: 'project', operator: Operator.EQUALS, value: `Project/${project.id } //should compare to project ref not just id
          : { code: 'project', operator: Operator.MISSING, value: 'true' },
thats why your search request uncovers 0 users
Again, this is a new, fairly untested feature here. It seems like there may also be limitations to using the
forceNewMembership
on both Patients and project scoped Practitioners
t
Hi @finnbergquist , I totally understand that. At the moment if I invite two time the same patient or two time the same practitioner this doesn't work neither 🙂 Let me know if I can help you ! I will test modifying the searchRequest to verify that it works if we add 'Project/${project.id}' instead of project.id. I also think that the if should be : `request.resourceType === 'Patient' || request.scope === 'project' ? { code: 'project', operator: Operator.EQUALS, value:
Copy code
Project/${project.id } //should compare to project ref not just id
          : { code: 'project', operator: Operator.MISSING, value: 'true' },
so that scoped practitioners are also checked based on that !
I will test by modifying to verify that it may works with these configuration
Hi @finnbergquist
I've created a pull request with the modifications.
I had the possibility to test the invitation process with forceNewProjectMembership and it works correctly 🙂
Let me know if I can be of any help or if you want to discuss the uses cases
f
Could you describe the problem and repro case in your PR please
Merged now 😀
t
Thank you very much
84 Views