Skip to content

Conversation

thomasyopes
Copy link
Contributor

@thomasyopes thomasyopes commented May 1, 2025

Issues:

Dependencies

Description

Testing

Check each PR.

Release Plan

  • ⚠️ Points to master
  • Upstream dependencies are met/released
  • Merge this

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Introduced full integration with the Healthie EHR platform, including patient and appointment data synchronization, webhook event processing, and secure JWT-based dashboard access.
    • Added API endpoints for Healthie patient management, appointment processing, and webhook handling.
    • Enabled background processing of Healthie appointments and patient linking with robust error handling and concurrency controls.
    • Implemented infrastructure support for Healthie, including new SQS queues, Lambda functions, and environment configuration.
  • Bug Fixes

    • Improved error handling for appointment retrieval in various EHR integrations to gracefully handle specific error types without unnecessary logging.
  • Documentation

    • Updated API documentation to accurately reflect query parameters for patient-related endpoints across multiple EHR integrations.
  • Chores

    • Extended infrastructure and configuration to support Healthie integration and secrets management.
    • Standardized retry logic defaults and SQS batch size constants for improved reliability and scalability.

Thomas Yopes added 30 commits April 29, 2025 11:38
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
… 82-healthie

Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
… 82-healthie

Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
… 82-healthie

Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
… 82-healthie

Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Thomas Yopes and others added 3 commits May 1, 2025 09:22
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
Ref: ENG-82

Ref: #1040
Signed-off-by: Thomas Yopes <thomasyopes@Thomass-MBP.attlocal.net>
feat(healthie): change last page determination
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
packages/core/src/external/ehr/healthie/index.ts (1)

347-352: GraphQL query webhooks() non-standard but accepted – duplicate prior feedback
The empty parentheses are unconventional GraphQL but the Healthie API reportedly allows it. No action needed unless the vendor API changes.

🧹 Nitpick comments (3)
packages/core/src/external/ehr/webhook.ts (2)

6-6: Typo in comment – “dosc” ➜ “docs”
Minor spelling fix keeps comments tidy and searchable.

-// Interpreted from Elation dosc https://docs.elationhealth.com/reference/webhooks
+// Interpreted from Elation docs https://docs.elationhealth.com/reference/webhooks

65-66: Node-only Blob requires experimental fetch on ≤ v18 – use Buffer for portability
new Blob() is only guaranteed from Node 20+. Using Buffer.byteLength() avoids a hard runtime dependency on the newer fetch implementation.

-const contentLength = new Blob([JSON.stringify(body)]).size;
+const contentLength = Buffer.byteLength(JSON.stringify(body));
packages/core/src/external/ehr/healthie/index.ts (1)

54-55: Redundant axiosInstance creation
The instance created here is immediately replaced inside initialize(), so this line is dead code and may confuse future readers.

-    this.axiosInstance = axios.create({});
+    // axiosInstance is created in initialize()
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Lite

📥 Commits

Reviewing files that changed from the base of the PR and between ee751be and 26b95a7.

📒 Files selected for processing (4)
  • packages/api/src/routes/ehr/elation/auth/middleware.ts (2 hunks)
  • packages/api/src/routes/ehr/healthie/auth/middleware.ts (1 hunks)
  • packages/core/src/external/ehr/healthie/index.ts (1 hunks)
  • packages/core/src/external/ehr/webhook.ts (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • packages/api/src/routes/ehr/elation/auth/middleware.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/api/src/routes/ehr/healthie/auth/middleware.ts
🧰 Additional context used
📓 Path-based instructions (1)
`**/*.ts`: - Use the Onion Pattern to organize a package's code in layers - Try to use immutable code and avoid sharing state across different functions, objects, and systems - Try...

**/*.ts: - Use the Onion Pattern to organize a package's code in layers

  • Try to use immutable code and avoid sharing state across different functions, objects, and systems
  • Try to build code that's idempotent whenever possible
  • Prefer functional programming style functions: small, deterministic, 1 input, 1 output
  • Minimize coupling / dependencies
  • Avoid modifying objects received as parameter
  • Only add comments to code to explain why something was done, not how it works
  • Naming
    • classes, enums: PascalCase
    • constants, variables, functions: camelCase
    • file names: kebab-case
    • table and column names: snake_case
    • Use meaningful names, so whoever is reading the code understands what it means
    • Don’t use negative names, like notEnabled, prefer isDisabled
    • For numeric values, if the type doesn’t convey the unit, add the unit to the name
  • Typescript
    • Use types
    • Prefer const instead of let
    • Avoid any and casting from any to other types
    • Type predicates: only applicable to narrow down the type, not to force a complete type conversion
    • Prefer deconstructing parameters for functions instead of multiple parameters that might be of
      the same type
    • Don’t use null inside the app, only on code interacting with external interfaces/services,
      like DB and HTTP; convert to undefined before sending inwards into the code
    • Use async/await instead of .then()
    • Use the strict equality operator ===, don’t use abstract equality operator ==
    • When calling a Promise-returning function asynchronously (i.e., not awaiting), use .catch() to
      handle errors (see processAsyncError and emptyFunction depending on the case)
    • Date and Time
      • Always use buildDayjs() to create dayjs instances
      • Prefer dayjs.duration(...) to create duration consts and keep them as duration
  • Prefer Nullish Coalesce (??) than the OR operator (||) to provide a default value
  • Avoid creating arrow functions
  • Use truthy syntax instead of in - i.e., if (data.link) not if ('link' in data)
  • Error handling
    • Pass the original error as the new one’s cause so the stack trace is persisted
    • Error messages should have a static message - add dynamic data to MetriportError's additionalInfo prop
    • Avoid sending multiple events to Sentry for a single error
  • Global constants and variables
    • Move literals to constants declared after imports when possible (avoid magic numbers)
    • Avoid shared, global objects
  • Avoid using console.log and console.error in packages other than utils, infra and shared,
    and try to use out().log instead
  • Avoid multi-line logs
    • don't send objects as a second parameter to console.log() or out().log()
    • don't create multi-line strings when using JSON.stringify()
  • Use eslint to enforce code style
  • Use prettier to format code
  • max column length is 100 chars
  • multi-line comments use /** */
  • scripts: top-level comments go after the import
  • packages/core/src/external/ehr/webhook.ts
  • packages/core/src/external/ehr/healthie/index.ts
🧠 Learnings (2)
packages/core/src/external/ehr/webhook.ts (1)
Learnt from: thomasyopes
PR: metriport/metriport#3771
File: packages/core/src/util/webhook.ts:34-45
Timestamp: 2025-05-01T16:10:45.255Z
Learning: The webhook signature verification code in packages/core/src/util/webhook.ts is copied directly from Healthie's documentation and should not be modified to maintain exact compliance with their implementation.
packages/core/src/external/ehr/healthie/index.ts (1)
Learnt from: thomasyopes
PR: metriport/metriport#3771
File: packages/core/src/external/ehr/healthie/index.ts:348-363
Timestamp: 2025-05-01T16:17:39.110Z
Learning: The Healthie GraphQL API accepts operations with empty parentheses (e.g., `query webhooks()`) even though this isn't standard GraphQL syntax.
🧬 Code Graph Analysis (1)
packages/core/src/external/ehr/webhook.ts (1)
packages/shared/src/index.ts (1)
  • MetriportError (40-40)

Comment on lines +90 to +95
const computedSignature = await generateSignature(key, dataToSign);
const signatureHeader = requestParams.headers["signature"];
if (!signatureHeader) throw new MetriportError("Signature is required");
const actualSignature = signatureHeader.split("=")[1];
if (!actualSignature) throw new MetriportError("Signature is required");
return computedSignature === actualSignature;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use constant-time compare for HMAC signatures
A direct === leaks timing information and makes the endpoint theoretically vulnerable to a timing-attack. Node provides crypto.timingSafeEqual.

-  return computedSignature === actualSignature;
+  // constant-time comparison
+  const a = Buffer.from(computedSignature, "hex");
+  const b = Buffer.from(actualSignature, "hex");
+  // Length mismatch → early return to avoid throwing inside timingSafeEqual
+  if (a.length !== b.length) return false;
+  return crypto.timingSafeEqual(a, b);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const computedSignature = await generateSignature(key, dataToSign);
const signatureHeader = requestParams.headers["signature"];
if (!signatureHeader) throw new MetriportError("Signature is required");
const actualSignature = signatureHeader.split("=")[1];
if (!actualSignature) throw new MetriportError("Signature is required");
return computedSignature === actualSignature;
const computedSignature = await generateSignature(key, dataToSign);
const signatureHeader = requestParams.headers["signature"];
if (!signatureHeader) throw new MetriportError("Signature is required");
const actualSignature = signatureHeader.split("=")[1];
if (!actualSignature) throw new MetriportError("Signature is required");
// constant-time comparison
const a = Buffer.from(computedSignature, "hex");
const b = Buffer.from(actualSignature, "hex");
// Length mismatch → early return to avoid throwing inside timingSafeEqual
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);

Comment on lines +23 to +29
function createPublicKey(key: string) {
return `
-----BEGIN PUBLIC KEY-----
${ed25519Prefix}${key}
-----END PUBLIC KEY-----
`;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

PEM string contains leading whitespace that can break key parsing
The template literal keeps the indentation before every line, producing spaces in the PEM headers/footers. Some crypto libraries (including OpenSSL) refuse to parse such keys.

-function createPublicKey(key: string) {
-  return `
------BEGIN PUBLIC KEY-----
-${ed25519Prefix}${key}
------END PUBLIC KEY-----
-  `;
+function createPublicKey(key: string) {
+  // trim() removes the leading new-line and all indentation
+  return `
+-----BEGIN PUBLIC KEY-----
+${ed25519Prefix}${key}
+-----END PUBLIC KEY-----
+`.trim();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function createPublicKey(key: string) {
return `
-----BEGIN PUBLIC KEY-----
${ed25519Prefix}${key}
-----END PUBLIC KEY-----
`;
}
function createPublicKey(key: string) {
// trim() removes the leading new-line and all indentation
return `
-----BEGIN PUBLIC KEY-----
${ed25519Prefix}${key}
-----END PUBLIC KEY-----
`.trim();
}

Comment on lines +289 to +293
const lastAppointment = appointments[appointments.length - 1];
if (!lastAppointment) return acc;
const nextCursor = lastAppointment.cursor;
return paginateAppointments(api, nextCursor, acc);
}
Copy link

@coderabbitai coderabbitai bot May 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Possible infinite recursion when cursor is unchanged or missing
If every page returns a non-empty cursor, paginateAppointments keeps calling itself even after the last page (where after yields zero results). This can cause an unbounded loop and eventually a stack overflow.

-      const lastAppointment = appointments[appointments.length - 1];
-      if (!lastAppointment) return acc;
-      const nextCursor = lastAppointment.cursor;
-      return paginateAppointments(api, nextCursor, acc);
+      const lastAppointment = appointments[appointments.length - 1];
+      if (!lastAppointment || !lastAppointment.cursor || lastAppointment.cursor === cursor) {
+        return acc; // reached the end
+      }
+      return paginateAppointments(api, lastAppointment.cursor, acc);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const lastAppointment = appointments[appointments.length - 1];
if (!lastAppointment) return acc;
const nextCursor = lastAppointment.cursor;
return paginateAppointments(api, nextCursor, acc);
}
const lastAppointment = appointments[appointments.length - 1];
if (!lastAppointment || !lastAppointment.cursor || lastAppointment.cursor === cursor) {
return acc; // reached the end
}
return paginateAppointments(api, lastAppointment.cursor, acc);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last call with have no appointments and return on if (!lastAppointment) return acc;

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomasyopes You're absolutely right. When the pagination reaches the end, the API will return an empty appointments array, which would make lastAppointment undefined and trigger the existing if (!lastAppointment) return acc; condition. Thank you for explaining the expected API behavior - the termination condition is already properly handled in the current implementation.

Copy link
Member

@leite08 leite08 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving considering we can discuss/address outstanding comments on follow-up PRs.

@thomasyopes thomasyopes added this pull request to the merge queue May 1, 2025
Merged via the queue into master with commit 9ad1828 May 1, 2025
71 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Jul 8, 2025
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants