Project: doctolib-doctolib · Branch: .invalid · Mode: auto · Model: claude-sonnet-4-6
Stats: 2 user messages, 33 tool calls, tools used: ToolSearch, mcp__plugin_github_github__pull_request_read, Bash, Read, Agent.
Conversation
Turn 1 — User
You are a Senior Software Architect at Doctolib and must review the PR https://github.com/doctolib/doctolib/pull/241829, according with Doctolib Sowftware Development guidelines.
Review the PR using @pr-review-toolkit:code-reviewer, @pr-review-toolkit:pr-test-analyzer, @pr-review-toolkit:code-simplifier, @pr-review-toolkit:type-design-analyzer and @doctolib-frontend:frontend-architecture-reviewer agents. Do not post anything in the original PR, but post the output in the current terminal suggesting the comments to be made in the file/codeline using "Conventional Comments" pattern.
If necessary, checkout the branch from the PR in a new worktree
Worktree directory: - worktrees: /Users/USER/workspace/doctolib/worktrees/ - doctolib: @/Users/USER/workspace/doctolib/worktrees/doctolib/ - treatment-plan: @/Users/USER/workspace/doctolib/worktrees/treatment-plan/ - doctolib-pro: @/Users/USER/workspace/doctolib/worktrees/doctolib-pro/
Turn 1 — Assistant
I'll start by fetching the PR details, then set up a worktree and run all the review agents in parallel.
Tools used:
ToolSearch(inputs: query, max_results)
Tool results:
→ result (ok)
Tools used:
mcp__plugin_github_github__pull_request_read(inputs: method, owner, repo, pullNumber)
Tools used:
mcp__plugin_github_github__pull_request_read(inputs: method, owner, repo, pullNumber)
Tool results:
→ result (ERROR): failed to get pull request: GET https://api.github.com/repos/doctolib/doctolib/pulls/241829: 404 Not Found []
Tool results:
→ result (ERROR): failed to get pull request files: GET https://api.github.com/repos/doctolib/doctolib/pulls/241829/files?page=1&per_page=30: 404 Not Found []
The GitHub MCP returns 404 — likely due to private repo access. Let me use the gh CLI instead.
Tools used:
Bash:gh pr view 241829 --repo doctolib/doctolib --json title,body,headRefName,baseRefName,author,state,files 2>&1 | head -100
Tool results:
→ result (ok): {"author":{"id":"U_kgDODbdn8w","is_bot":false,"login":"esat-ozbek","name":"Esat Özbek"},"baseRefName":"master","body":"## Summary\r\n\r\n- Bug: doctors admin of multiple orgs (only one with EHR) had the wrong organizationId submitted when creating a medical device, causing a policy rejection\r\n- Root cause: CreateDeviceButton picked the first admin org client-side without verifying EHR eligibility — a known tech-debt shortcut (// TBD comment)\r\n- Fix: resolve the qualifying org (admin ac
…(truncated, 1814 more chars)
Tools used:
Bash:gh pr diff 241829 --repo doctolib/doctolib 2>&1
Tool results:
→ result (ok): diff --git a/app/components/react/configuration/gdt_export_creation.rb b/app/components/react/configuration/gdt_export_creation.rb index 249d980c7cf98..1e05ac28c84e1 100644 --- a/app/components/react/configuration/gdt_export_creation.rb +++ b/app/components/react/configuration/gdt_export_creation.rb @@ -1,3 +1,7 @@ class React::Configuration::GdtExportCreation < React::Component self.selector = '.gdt-export-creation' + + def to_props(organization_id: nil) + to_camelized_props({ organizat …(truncated, 6479 more chars)
Good, I have the full diff. Now let me check if a worktree already exists for this branch, then set one up and run all review agents in parallel.
Tools used:
Bash:ls /Users/USER/workspace/doctolib/worktrees/doctolib/ 2>&1 | grep -i "fhir-2184"
Tool results:
→ result (ok): (Bash completed with no output)
Tools used:
Bash:git worktree add /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184 origin/FHIR-2184 2>&1
Tool results:
→ result (ok): Preparing worktree (detached HEAD 42019749ed42) Updating files: 2% (1921/93944) Updating files: 3% (2819/93944) Updating files: 4% (3758/93944) Updating files: 5% (4698/93944) Updating files: 5% (4979/93944) Updating files: 6% (5637/93944) Updating files: 7% (6577/93944) Updating files: 8% (7516/93944) Updating files: 8% (7676/93944) Updating files: 9% (8455/93944) Updating files: 10% (9395/93944) Updating files: 11% (10334/93944) Updating files: 11% (10708/93944) Updating …(truncated, 4395 more chars)
Worktree is ready. Now let me read the changed files to get full context before launching all review agents in parallel.
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/controllers/configuration/gdt_exports_controller.rb
Tool results:
→ result (ok): 1 class Configuration::GdtExportsController < Configuration::ApplicationController
2 after_action :verify_authorized
3 before_action -> { authorize(nil, :write, policy_class: V2::Configuration::GdtExportPolicy) }
4
5 def show
6 end
7
8 def new
9 @organization_id =
10 current_account_admin_organizations.find { |org| Ehr::Api::Organization.ehr?(organization_id: org.id) }&.id
11 end
12 end
13
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/components/react/configuration/gdt_export_creation.rb
Tool results:
→ result (ok): 1 class React::Configuration::GdtExportCreation < React::Component
2 self.selector = '.gdt-export-creation'
3
4 def to_props(organization_id: nil)
5 to_camelized_props({ organization_id: organization_id })
6 end
7 end
8
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/views/configuration/gdt_exports/new.html.slim
Tool results:
→ result (ok): 1 - content_for(:back_button_url) 2 = main_app.configuration_equipment_configurations_path 3 = react_component(React::Configuration::GdtExportCreation, organization_id: @organization_id) 4
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/packages/@doctolib/equipment_configuration/src/gdt_export/GDTExportCreation.tsx
Tool results:
→ result (ok): 1 import { Button } from '@doctolib/design-system-b2b/Button' 2 import { Card } from '@doctolib/design-system-b2b/Card' 3 import { SpinnerLoader } from '@doctolib/design-system-b2b/SpinnerLoader' 4 import { Stepper } from '@doctolib/design-system-b2b/Stepper' 5 import { EXPORT_CONFIGURATION_STEPS, deviceBrands, deviceTypes } from '@doctolib/equipment_configuration/src/constants' 6 import DeviceConfigurationForm from '@doctolib/equipment_configuration/src/gdt_export/DeviceConfigurationForm' 7 imp …(truncated, 4934 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/packages/@doctolib/equipment_configuration/src/gdt_export/GDTExportCreation.test.tsx
Tool results:
→ result (ok): 1 import '@testing-library/jest-dom' 2 import { render, screen, waitFor } from '@testing-library/react' 3 import i18n from 'monolith_DEPRECATED/services/i18n' 4 import GDTExportCreation from '@doctolib/equipment_configuration/src/gdt_export/GDTExportCreation' 5 import { DeviceInformationPage } from '@doctolib/equipment_configuration/src/test_helpers/DeviceInformationPage' 6 import userEvent from '@testing-library/user-event' 7 import { DeviceConfigurationPage } from '@doctolib/equipment_configur …(truncated, 19431 more chars)
Now let me look at some surrounding context for deeper analysis.
Tools used:
Bash:find /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184 -name "current_account_admin_organizations*" -o -name "*gdt_export*" 2>/dev/null | grep -v ".git" | head -20
Tools used:
Bash:grep -r "current_account_admin_organizations" /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app --include="*.rb" -l 2>/dev/null | head -10
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/doctor_authorizations.rb /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/configuration/hospital_unit_policy.rb /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/configuration/practitioner_substitute_policy.rb /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/configuration/secure_access/organization_policy.rb /Users/USER/workspace/doctolib/worktrees/docto …(truncated, 645 more chars)
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/test/controllers/configuration/gdt_exports_controller_test.rb /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/packages/@doctolib/equipment_configuration/src/gdt_export /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/views/configuration/gdt_exports /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/controllers/configuration/gdt_exports_controller.rb /Users/USER/workspace/doctolib/worktrees/doctoli …(truncated, 180 more chars)
Tools used:
Bash:grep -n "current_account_admin_organizations" /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/doctor_authorizations.rb 2>/dev/null | head -10
Tool results:
→ result (ok): 147: def current_account_admin_organizations 157: profile.present? ? [profile] : Profile.owned_by(current_account_admin_organizations)
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/v2/configuration/gdt_export_policy.rb
Tool results:
→ result (ok): 1 class V2::Configuration::GdtExportPolicy < V2::Configuration::ApplicationPolicy 2 allow(:write) do 3 to(:doctor) do 4 # TODO: Actual implementation not done yet. 5 # This is a basic implementation with feature switch. 6 EquipmentConfiguration::Api.feature_enabled_for?(current_account) 7 end 8 end 9 end 10
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/test/controllers/configuration/gdt_exports_controller_test.rb
Tool results:
→ result (ok): 1 require 'test_helper'
2
3 class Configuration::GdtExportsControllerTest < Doctolib::Testing::ActionDispatch::IntegrationTest
4 self.subdomain = :pro
5
6 let!(:account) { create(:hcp_account, locale: :de) }
7 let!(:organization) { create(:organization) }
8
9 before do
10 create(:ehr_subscription, account: account, configuration_variant: 'de-gp')
11 organization.enable_feature!(Organization::FeatureSwitch.SUPPORT_MEDICAL_DEVICE.id)
12 login_session(account)
13 end
14
15
…(truncated, 987 more chars)
Tools used:
Bash:grep -n "current_account_admin_organizations\|def current_account" /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/doctor_authorizations.rb 2>/dev/null | head -20
Tool results:
→ result (ok): 143: def current_account_ehr_authorizations 147: def current_account_admin_organizations 157: profile.present? ? [profile] : Profile.owned_by(current_account_admin_organizations)
Tools used:
Bash:grep -rn "def ehr?" /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/ehr --include="*.rb" 2>/dev/null | head -5
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/ehr/app/models/concerns/account/ehr.rb:37: def ehr? /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/ehr/app/public/ehr/api/organization.rb:14: def ehr?(organization_id:)
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/ehr/app/public/ehr/api/organization.rb
Tool results:
→ result (ok): 1 # typed: true
2
3 class Ehr::Api::Organization
4 class << self
5 extend T::Sig
6
7 # Checks if the given organization has at least one EHR patient base subscription.
8 # This indicates that there may be medical data associated with one of its patient bases.
9 #
10 # @param organization_id [Integer] ID of the organization.
11 # @return [Boolean] true if the organization has at least one patient base subscription, false otherwise
12 # @raise [TypeError] if organiz
…(truncated, 4677 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/doctor_authorizations.rb(140–160)
Tool results:
→ result (ok): 140 doctor_current_user.owned_agendas_enabled_for_telehealth_appointment_on_read_only_agenda.pluck(:id) 141 end 142 143 def current_account_ehr_authorizations 144 assumed_authorizations_or_current_account.ehr_authorizations 145 end 146 147 def current_account_admin_organizations 148 organizations = 149 OrganizationAdmin::Api::Get.organizations_for(account_id: assumed_authorizations_or_current_account.id) 150 organizations.eager_load(external_sync_configuration: …(truncated, 320 more chars)
I now have all the context. Launching all five review agents in parallel.
★ Insight ─────────────────────────────────────
The PR moves org-eligibility logic from the client (React useContext(AccountContext)) to the server (GdtExportsController#new). This is a classic "push validation server-side" pattern — client-side context was a performance shortcut that accidentally became a correctness bug when multiple orgs are involved.
─────────────────────────────────────────────────
Tools used:
Agent(inputs: description, subagent_type, prompt)
Tools used:
Agent(inputs: description, subagent_type, prompt)
Tools used:
Agent(inputs: description, subagent_type, prompt)
Tools used:
Agent(inputs: description, subagent_type, prompt)
Tools used:
Agent(inputs: description, subagent_type, prompt)
Tool results:
→ result (ok): I'll perform the code review based on the provided diff and context. Since I have all the necessary information to review the specific concerns raised, I'll provide the review using Conventional Comments format.
Code Review: PR FHIR-2184
What I'm reviewing
app/controllers/configuration/gdt_exports_controller.rb— new@organization_idresolution in#newapp/components/react/configuration/gdt_export_creation.rb— props serialization- `app/views/configuration/gdt_exports/new.htm …(truncated, 7907 more chars)
Tool results:
→ result (ok): I now have the complete picture. Here is the full analysis.
Type: CreateDeviceButtonProps / { organizationId?: number } (GDTExportCreation.tsx)
Invariants Identified
organizationId, when present, must be a positive non-zero integer (database primary key for an organization).organizationIdis absent only when the server found no EHR-eligible organization for the current context — a transient resolution failure, not a permanent acceptable state.- When
organizationId…(truncated, 9866 more chars)
Tool results:
→ result (ok): The package convention strongly favors named XxxProps declarations (and prefers interface over type for them). The inconsistency in the diff is real — CreateDeviceButton uses a named type but GDTExportCreation uses an inline anonymous one.
Now I have enough to write the review.
Conventional Comments Review — FHIR-2184
File: /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/controllers/configuration/gdt_exports_controller.rb
issue (performance): N+1 q …(truncated, 5614 more chars)
Tool results:
→ result (ok): Now I have a thorough picture of the change. Let me provide the structured review.
Architecture Review: PR FHIR-2184
File reviewed: /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/packages/@doctolib/equipment_configuration/src/gdt_export/GDTExportCreation.tsx
Critical Issues (must fix)
praise(migration direction): Removal of AccountContext from monolith_DEPRECATED is correct
The AccountContext import from `monolith_DEPRECATED/contexts/doctor/Acco
…(truncated, 7974 more chars)
Tool results:
→ result (ok): Now I have a complete picture of the PR. Here is the full analysis.
Summary
The PR correctly moves organization resolution server-side, which is the right architectural approach. The frontend test for the happy path (a valid organizationId prop is received and forwarded) is adequate. However, the controller test was not updated at all to cover the new @organization_id assignment logic — the single branch that contains the actual bug fix is completely untested in the integration tes
…(truncated, 6413 more chars)
Here is the full consolidated review for PR #241829 — fix(FHIR-2184): resolve EHR-eligible org server-side for medical device creation.
PR Review: FHIR-2184
Reviewer: Senior Software Architect | Branch: FHIR-2184 | Base: master
Overall verdict: The architectural direction is correct — moving org-resolution server-side and removing the AccountContext (from monolith_DEPRECATED) dependency is the right fix. However, two blocking issues must be resolved before merge, plus several important concerns.
app/controllers/configuration/gdt_exports_controller.rb
issue (blocking): N+1 DB queries in #new — one Ehr::Api::Organization.ehr? call per admin org
Lines 9–10.
Ehr::Api::Organization.ehr?issues two DB queries per call (one toPatientBasesfor org→patient_base mapping, one toEhr::PatientBaseSubscription.exists?). The.find { |org| ... }iterates overcurrent_account_admin_organizationsin Ruby, callingehr?once per org — meaning a doctor with N admin orgs triggers 2×N queries on every#newpage render. This violates the CLAUDE.md rule "prevent N+1 queries with.includes()".Suggested fix: add a batch API to
Ehr::Api::Organization(e.g.ehr_organization_ids(organization_ids:)) that resolves all subscriptions in one query, then use it:ruby def new org_ids = current_account_admin_organizations.map(&:id) ehr_ids = Ehr::Api::Organization.ehr_organization_ids(organization_ids: org_ids) @organization_id = current_account_admin_organizations.find { |org| ehr_ids.include?(org.id) }&.id endThe new batch method belongs inengines/ehr/app/public/ehr/api/organization.rb, which already holds the single-ID variant, and cross-engine API compliance is maintained.
issue: Alphabetical org ordering determines which EHR org is selected — no determinism guarantee and no multi-EHR-org case
Line 9.
current_account_admin_organizationsis ordered.order(:name), sofindreturns the alphabetically first EHR-eligible org. If the doctor administers two EHR orgs, the "wrong" one could be silently selected with no UI affordance to correct it. Consider documenting the ordering assumption explicitly, or (better) acceptingparams[:organization_id]validated againstcurrent_account_admin_organizationsso the caller can explicitly choose the target org. At minimum, add a comment on the ordering dependency.
issue: Missing regression test for the core bug scenario (multi-org, EHR on non-first org)
test/controllers/configuration/gdt_exports_controller_test.rb. The existing#newtest (renders the new page) only sets up one org and assertsassert_response(:success)— it never verifies the value of@organization_id. Per CLAUDE.md: "Bug fix → add a regression test that would have caught the bug before the fix." Required test cases:
- Two admin orgs; first (by name) is non-EHR, second is EHR →
@organization_idequals the EHR org's id- Two admin orgs, neither has EHR →
@organization_idisnil- Single admin EHR org →
@organization_idis set (existing case, needs assertion)All new test methods should include a
# FHIR-2184comment above them per the traceability rules.
suggestion: Extract org resolution into a private method for testability and reuse
The resolution logic in
#newis complex enough to warrant extraction, especially once the N+1 is fixed: ```ruby privatedef qualifying_ehr_organization_id org_ids = current_account_admin_organizations.map(&:id) ehr_ids = Ehr::Api::Organization.ehr_organization_ids(organization_ids: org_ids) current_account_admin_organizations.find { |org| ehr_ids.include?(org.id) }&.id end
`` This also makes the method reachable by#create` if defense-in-depth authorization is added there.
praise: Correct cross-engine API usage
Calling
Ehr::Api::Organization.ehr?fromengines/ehr/app/public/correctly respects the engine boundary rule from CLAUDE.md. No internal-model leaks.
app/components/react/configuration/gdt_export_creation.rb
nitpick: to_props override can be simplified to one line
Lines 4–6. The full method body reduces to a single expression:
ruby def to_props(organization_id: nil) = to_camelized_props(organization_id: organization_id)Or, since the only transform is the camelization of one key (organization_id→organizationId), you can skipto_camelized_propsentirely and return a literal:def to_props(organization_id: nil) = { organizationId: organization_id }— fewer moving parts.
issue: No unit test for to_props prop forwarding
The
to_propsmethod is new. Other component wrappers in the codebase have#to_propsunit tests. A typo (organisation_id) or a future rename would cause the React component to silently receiveundefinedinstead of the org ID. A minimal test:ruby assert_equal({ organizationId: 42 }, React::Configuration::GdtExportCreation.new.to_props(organization_id: 42)) assert_equal({ organizationId: nil }, React::Configuration::GdtExportCreation.new.to_props)
packages/@doctolib/equipment_configuration/src/gdt_export/GDTExportCreation.tsx
issue (blocking): organizationId ?? 0 is a silent failure — submits an invalid organization ID to the backend
Line 47. When the server finds no EHR-eligible org,
@organization_idisnil, which serializes tonulland arrives asundefinedin the React component. The?? 0fallback sendsorganization_id: 0touseMedicalDevicesCreate— a value the backend will reject via policy, producing a confusing error toast with no actionable explanation. This directly undermines the purpose of the server-side resolution.Two acceptable remedies: - Disable the Create button when
organizationIdis absent:tsx <Button onClick={handleSubmit(onSubmit)} disabled={organizationId == null}> {i18n.t('common.actions.create')} </Button>- Guard before submission and surface the error early:tsx const onSubmit = (deviceData: MedicalDeviceWithConfigurationForm) => { if (organizationId === undefined) { onError(); return } createDevice({ payload: { ...deviceData, organization_id: organizationId, workstation_id: workstationId as UUID } }) }Either approach eliminates
?? 0and tightens the type tonumberat the submit boundary.
issue: workstationId as UUID is an unsafe cast on a nullable value
Lines 24 and 47.
useCurrentWorkstation()returnsid: UUID | null. The castworkstationId as UUIDerases that nullability — TypeScript will not warn ifworkstationIdisnullat submission time, producingworkstation_id: nullasUUIDin the payload. A guard is needed:tsx if (!workstationId) { onError(); return }Place it alongside theorganizationIdguard above. (The same pattern exists inGDTExportUpdate.tsxand is a pre-existing concern, but this PR introduces the pattern again.)
issue: Inconsistent prop type declaration style between CreateDeviceButton and GDTExportCreation
Lines 19 and 54.
CreateDeviceButtonuses a named type alias (type CreateDeviceButtonProps = ...), whileGDTExportCreationuses an anonymous inline type. Other components inpackages/@doctolib/equipment_configurationconsistently use namedinterface XxxPropsdeclarations. Align both: ```tsx interface GDTExportCreationProps { organizationId?: number } const GDTExportCreation = ({ organizationId }: GDTExportCreationProps) => { ... }// and flip CreateDeviceButtonProps to interface: interface CreateDeviceButtonProps { organizationId?: number } ```
nitpick: organizationId?: number — the optionality masks the invariant
The
?:optional marker signals "the caller may omit this with a sensible defaul …(truncated, 4219 more chars)
Turn 2 — User
details about the n+1 queries on gdt_exports_controller.rb
Turn 2 — Assistant
Let me trace the full call chain precisely.
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/app/policies/doctor_authorizations.rb(147–157)
Tool results:
→ result (ok): 147 def current_account_admin_organizations 148 organizations = 149 OrganizationAdmin::Api::Get.organizations_for(account_id: assumed_authorizations_or_current_account.id) 150 organizations.eager_load(external_sync_configuration: :external_sync_connector).order(:name) 151 end 152 153 def seeable_profiles 154 @_seeable_profiles ||= 155 begin 156 profile = assumed_authorizations_or_current_account.profile
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/ehr/app/public/ehr/api/organization.rb(1–21)
Tool results:
→ result (ok): 1 # typed: true
2
3 class Ehr::Api::Organization
4 class << self
5 extend T::Sig
6
7 # Checks if the given organization has at least one EHR patient base subscription.
8 # This indicates that there may be medical data associated with one of its patient bases.
9 #
10 # @param organization_id [Integer] ID of the organization.
11 # @return [Boolean] true if the organization has at least one patient base subscription, false otherwise
12 # @raise [TypeError] if organiz
…(truncated, 413 more chars)
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/ehr/app/public/ehr/api/organization.rb(100–112)
Tool results:
→ result (ok): 100
101 private
102
103 def organization_patient_base_ids(organization_id)
104 ::PatientBases::Api::PatientBaseRepository
105 .find_by_query({ organization_id: organization_id })
106 .value_or([])
107 .map(&:id)
108 end
109 end
110 end
111
Tools used:
Bash:grep -rn "def organizations_for" /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/organization_admin --include="*.rb" | head -5
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/organization_admin/app/public/organization_admin/api/get.rb:62: def organizations_for(account_id:)
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/organization_admin/app/public/organization_admin/api/get.rb(55–75)
Tool results:
→ result (ok): 55 #
56 # @param account_id [Integer] The ID of account. Required.
57 # Examples:
58 # OrganizationAdmin::Api::Get.organizations_for(
59 # account_id: 1234,
60 # )
61 # returns Organization::ActiveRecord_Relation
62 def organizations_for(account_id:)
63 organization_ids = organization_ids_for(account_ids: account_id)
64 Organization.not_trashed.where(id: organization_ids)
65 end
66
67 # F
…(truncated, 635 more chars)
Tools used:
Bash:grep -rn "def find_by_query" /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/patient_bases --include="*.rb" | head -5
Tool results:
→ result (ok): /Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/patient_bases/app/public/patient_bases/api/patient_base_repository.rb:28: def find_by_query(params, negative_clauses: {}, with_trashed: false, limit: nil, order_criteria: nil)
Tools used:
Read:/Users/USER/workspace/doctolib/worktrees/doctolib/FHIR-2184/engines/patient_bases/app/public/patient_bases/api/patient_base_repository.rb(20–45)
Tool results:
→ result (ok): 20 end
21
22 # @param params -> named params
23 # @param negative_clauses -> named params
24 # @param with_trashed -> boolean
25 # @param limit -> int
26 # @param order_criteria -> hash with :column and :direction keys (e.g. { column: 'id', direction: 'asc' })
27 # @return Success[Array[PatientBaseDTO]] or Failure[:query_param_not_allowed, { errors: [] }]
28 def find_by_query(params, negative_clauses: {}, with_trashed: false, limit: nil, order_criteria: nil)
29
…(truncated, 842 more chars)
Now I have the full picture. Here's the precise breakdown:
N+1 Analysis: gdt_exports_controller.rb:9
The offending line
@organization_id =
current_account_admin_organizations.find { |org| Ehr::Api::Organization.ehr?(organization_id: org.id) }&.id
What .find actually does here
current_account_admin_organizations returns an ActiveRecord::Relation. When Ruby's Enumerable#find iterates over it, Rails executes the collection query once (good), then for each org it calls Ehr::Api::Organization.ehr? in Ruby — which itself issues 2 more SQL queries per call.
The full query chain per iteration
Step 1 — the orgs are loaded (1 query, happens once):
-- current_account_admin_organizations (eager_load fires this once before the loop)
SELECT organizations.*, external_sync_configurations.*, ...
FROM organizations
INNER JOIN organization_admins ON ...
LEFT OUTER JOIN external_sync_configurations ON ...
WHERE organizations.deleted_at IS NULL
AND organization_admins.account_id = ?
ORDER BY organizations.name
Step 2 — for each org, ehr? calls organization_patient_base_ids (query A):
# engines/ehr/app/public/ehr/api/organization.rb:103-108
def organization_patient_base_ids(organization_id)
::PatientBases::Api::PatientBaseRepository
.find_by_query({ organization_id: organization_id }) # ← fires a query
.value_or([])
.map(&:id)
end
-- patient_bases/app/public/.../patient_base_repository.rb:41
SELECT * FROM patient_bases WHERE organization_id = ? -- one row per org
Step 3 — for each org, ehr? checks subscription existence (query B):
# engines/ehr/app/public/ehr/api/organization.rb:16
Ehr::PatientBaseSubscription.where(patient_base_id: patient_base_ids).exists?
SELECT 1 FROM ehr_patient_base_subscriptions WHERE patient_base_id IN (?) LIMIT 1
Total query count
| Case | Queries |
|---|---|
| First org (alphabetically) is EHR-eligible | 1 + 2 = 3 total |
| EHR org is the K-th alphabetically | 1 + 2×K |
| No EHR org exists (worst case) | 1 + 2×N |
| Typical bug scenario (N=2, EHR is 2nd) | 1 + 2×2 = 5 total |
Ruby's Enumerable#find short-circuits on the first truthy result, so it's not always 2×N — but alphabetical ordering means a doctor whose EHR org has a name that sorts late pays the full cost.
Why a .find early-exit doesn't save you
In the regression scenario the PR fixes — a doctor admins org A (non-EHR, "Clinique Alpha") and org B (EHR, "Hôpital Zeta") — alphabetical ordering puts org A first. The loop checks A (queries A1+B1: returns false), then checks B (queries A2+B2: returns true). That's 5 total queries just to set one instance variable on a page render.
The fix
The root cause is PatientBases::Api::PatientBaseRepository.find_by_query only accepts a single organization_id. A batch approach resolves everything in 3 queries flat regardless of N.
Add to engines/ehr/app/public/ehr/api/organization.rb:
sig { params(organization_ids: T::Array[Integer]).returns(T::Array[Integer]) }
def ehr_organization_ids(organization_ids:)
# Query A (batched): fetch all patient base IDs across all orgs at once
patient_base_ids =
::PatientBases::Api::PatientBaseRepository
.find_by_query({ organization_id: organization_ids }) # if array is supported
.value_or([])
.map(&:id)
return [] if patient_base_ids.empty?
# Query B (batched): which patient bases have a subscription?
subscribed_base_ids =
Ehr::PatientBaseSubscription
.where(patient_base_id: patient_base_ids)
.joins(:patient_base)
.pluck('patient_bases.organization_id')
.uniq
subscribed_base_ids
end
Then the controller becomes:
def new
admin_org_ids = current_account_admin_organizations.map(&:id) # materializes the relation
ehr_org_ids = Ehr::Api::Organization.ehr_organization_ids(organization_ids: admin_org_ids).to_set
@organization_id = current_account_admin_organizations.find { |org| ehr_org_ids.include?(org.id) }&.id
end
Query count: always 3 (orgs load, patient_bases batch, subscriptions batch), regardless of how many orgs the doctor administers.
★ Insight ─────────────────────────────────────
The PatientBaseRepository.find_by_query API only accepts a scalar organization_id today. Before adding ehr_organization_ids to the public API, verify whether it supports an array param (Rails where(organization_id: [...]) works on arrays but the validation in find_by_query at line 29–36 may reject it). If not, the batch query for patient bases would need to be added as a new API method inside the ehr engine's private layer and exposed via a new Ehr::Api::Organization class method.
─────────────────────────────────────────────────