8000 198 ecw v2 by thomasyopes · Pull Request #3874 · metriport/metriport · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

198 ecw v2 #3874

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/api/src/command/jwt-token.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { uuidv7 } from "@metriport/core/util/uuid-v7";
import { NotFoundError } from "@metriport/shared";
import { Op } from "sequelize";
import { JwtToken, JwtTokenData, JwtTokenPerSource, JwtTokenSource } from "../domain/jwt-token";
import { JwtTokenModel } from "../models/jwt-token";
import { Op } from "sequelize";

export type JwtTokenParams = JwtTokenPerSource;

Expand Down Expand Up @@ -49,6 +49,26 @@ export async function getJwtTokenOrFail({
return jwtToken;
}

/**
* DOES NOT CHECK EXPIRATION
*/
export async function getJwtTokenById(id: string): Promise<JwtToken | undefined> {
const existing = await JwtTokenModel.findOne({
where: { id },
});
if (!existing) return undefined;
return existing.dataValues;
}

/**
* DOES NOT CHECK EXPIRATION
*/
export async function getJwtTokenByIdOrFail(id: string): Promise<JwtToken> {
const existing = await getJwtTokenById(id);
if (!existing) throw new NotFoundError("JwtToken not found", undefined, { id });
return existing;
}

/**
* DOES NOT CHECK EXPIRATION
*/
Expand Down
112 changes: 112 additions & 0 deletions packages/api/src/external/ehr/eclinicalworks/command/sync-patient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import EClinicalWorksApi from "@metriport/core/external/ehr/eclinicalworks/index";
import { processAsyncError } from "@metriport/core/util/error/shared";
import { MetriportError } from "@metriport/shared";
import { eclinicalworksDashSource } from "@metriport/shared/interface/external/ehr/eclinicalworks/jwt-token";
import { EhrSources } from "@metriport/shared/interface/external/ehr/source";
import { getJwtTokenByIdOrFail } from "../../../../command/jwt-token";
import { findOrCreatePatientMapping, getPatientMapping } from "../../../../command/mapping/patient";
import { queryDocumentsAcrossHIEs } from "../../../../command/medical/document/document-query";
import { getPatientOrFail } from "../../../../command/medical/patient/get-patient";
import {
createMetriportPatientDemosFhir,
getOrCreateMetriportPatientFhir,
} from "../../shared/utils/fhir";
import { createEClinicalWorksClient } from "../shared";

export type SyncEClinicalWorksPatientIntoMetriportParams = {
cxId: string;
eclinicalworksPracticeId: string;
eclinicalworksPatientId: string;
eclinicalworksTokenId: string;
api?: EClinicalWorksApi;
triggerDq?: boolean;
};

export async function syncEClinicalWorksPatientIntoMetriport({
cxId,
eclinicalworksPracticeId,
eclinicalworksPatientId,
eclinicalworksTokenId,
api,
triggerDq = false,
}: SyncEClinicalWorksPatientIntoMetriportParams): Promise<string> {
const existingPatient = await getPatientMapping({
cxId,
externalId: eclinicalworksPatientId,
source: EhrSources.eclinicalworks,
});
if (existingPatient) {
const metriportPatient = await getPatientOrFail({
cxId,
id: existingPatient.patientId,
});
const metriportPatientId = metriportPatient.id;
return metriportPatientId;
}

const eclinicalworksApi =
api ??
(await createEClinicalWorksClientFromTokenId({
cxId,
practiceId: eclinicalworksPracticeId,
tokenId: eclinicalworksTokenId,
}));
const eclinicalworksPatient = await eclinicalworksApi.getPatient({
cxId,
patientId: eclinicalworksPatientId,
});
const possibleDemographics = createMetriportPatientDemosFhir(eclinicalworksPatient);
const metriportPatient = await getOrCreateMetriportPatientFhir({
cxId,
source: EhrSources.eclinicalworks,
practiceId: eclinicalworksPracticeId,
possibleDemographics,
externalId: eclinicalworksPatientId,
});
if (triggerDq) {
queryDocumentsAcrossHIEs({
cxId,
patientId: metriportPatient.id,
}).catch(processAsyncError(`EClinicalWorks queryDocumentsAcrossHIEs`));
}
await findOrCreatePatientMapping({
cxId,
patientId: metriportPatient.id,
externalId: eclinicalworksPatientId,
source: EhrSources.eclinicalworks,
});
return metriportPatient.id;
}

async function createEClinicalWorksClientFromTokenId({
cxId,
practiceId,
tokenId,
}: {
cxId: string;
practiceId: string;
tokenId: string;
}): Promise<EClinicalWorksApi> {
const token = await getJwtTokenByIdOrFail(tokenId);
if (token.data.source !== eclinicalworksDashSource) {
throw new MetriportError("Invalid token source", undefined, {
tokenId,
source: token.data.source,
});
}
const tokenPracticeId = token.data.practiceId;
if (tokenPracticeId !== practiceId) {
throw new MetriportError("Invalid token practiceId", undefined, {
tokenId,
source: token.data.source,
tokenPracticeId,
practiceId,
});
}
const api = await createEClinicalWorksClient({
cxId,
practiceId,
authToken: token.token,
});
return api;
}
35 changes: 35 additions & 0 deletions packages/api/src/external/ehr/eclinicalworks/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import EClinicalWorksApi, {
EClinicalWorksEnv,
isEClinicalWorksEnv,
} from "@metriport/core/external/ehr/eclinicalworks/index";
import { MetriportError } from "@metriport/shared";
import { Config } from "../../../shared/config";
import { EhrPerPracticeParams } from "../shared/utils/client";

function getEClinicalWorksEnv(): {
environment: EClinicalWorksEnv;
} {
const environment = Config.getEClinicalWorksEnv();
if (!environment) throw new MetriportError("EClinicalWorks environment not set");
if (!isEClinicalWorksEnv(environment)) {
throw new MetriportError("Invalid EClinicalWorks environment", undefined, { environment });
}
return {
environment,
};
}

type EClinicalWorksPerPracticeParams = EhrPerPracticeParams & {
authToken: string;
};

export async function createEClinicalWorksClient(
perPracticeParams: EClinicalWorksPerPracticeParams
): Promise<EClinicalWorksApi> {
const { environment } = getEClinicalWorksEnv();
return await EClinicalWorksApi.create({
practiceId: perPracticeParams.practiceId,
environment,
authToken: perPracticeParams.authToken,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const bundleFunctionsByEhr: Record<EhrSources, BundleFunctions | undefined> = {
[EhrSources.athena]: undefined,
[EhrSources.elation]: undefined,
[EhrSources.healthie]: undefined,
[EhrSources.eclinicalworks]: undefined,
};

export function getBundleFunctions(ehr: EhrSources): BundleFunctions {
Expand Down
8 changes: 5 additions & 3 deletions packages/api/src/external/ehr/shared/utils/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import AthenaHealthApi, { AthenaEnv } from "@metriport/core/external/ehr/athenahealth/index";
import CanvasApi, { CanvasEnv } from "@metriport/core/external/ehr/canvas/index";
import { EClinicalWorksEnv } from "@metriport/core/external/ehr/eclinicalworks/index";
import ElationApi, { ElationEnv } from "@metriport/core/external/ehr/elation/index";
import { HealthieEnv } from "@metriport/core/external/ehr/healthie/index";
Copy link
Member

Choose a reason for hiding this comment

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

Did we forget to do this before?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes

import { JwtTokenInfo, MetriportError } from "@metriport/shared";
import {
findOrCreateJwtToken,
getLatestExpiringJwtTokenBySourceAndData,
} from "../../../../command/jwt-token";
import { EhrClientJwtTokenSource } from "./jwt-token";

type EhrEnv = AthenaEnv | ElationEnv | CanvasEnv;
type EhrEnv = AthenaEnv | ElationEnv | CanvasEnv | HealthieEnv | EClinicalWorksEnv;
export type EhrEnvAndClientCredentials<Env extends EhrEnv> = {
environment: Env;
clientKey: string;
Expand All @@ -20,7 +22,7 @@ export type EhrEnvAndApiKey<Env extends EhrEnv> = {
apiKey: string;
};

type EhrClient = AthenaHealthApi | ElationApi | CanvasApi;
type EhrClientTwoLeggedAuth = AthenaHealthApi | ElationApi | CanvasApi;
export type EhrClientParams<Env extends EhrEnv> = {
twoLeggedAuthTokenInfo: JwtTokenInfo | undefined;
practiceId: string;
Expand Down Expand Up @@ -52,7 +54,7 @@ export type GetEnvParams<Env extends EhrEnv, EnvArgs> = {

export async function createEhrClient<
Env extends EhrEnv,
Client extends EhrClient,
Client extends EhrClientTwoLeggedAuth,
EnvArgs = undefined
>({
cxId,
Expand Down
8 changes: 7 additions & 1 deletion packages/api/src/external/ehr/shared/utils/jwt-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
CanvasWebhookJwtTokenData,
canvasWebhookSource,
} from "@metriport/shared/interface/external/ehr/canvas/jwt-token";
import {
EClinicalWorksDashJwtTokenData,
eclinicalworksDashSource,
} from "@metriport/shared/interface/external/ehr/eclinicalworks/jwt-token";
import {
ElationClientJwtTokenData,
elationClientSource,
Expand All @@ -30,6 +34,7 @@ export const ehrDashJwtTokenSources = [
canvasDashSource,
elationDashSource,
healthieDashSource,
eclinicalworksDashSource,
] as const;
export type EhrDashJwtTokenSource = (typeof ehrDashJwtTokenSources)[number];
export function isEhrDashJwtTokenSource(source: string): source is EhrDashJwtTokenSource {
Expand All @@ -40,7 +45,8 @@ export type EhrDashJwtTokenData =
| AthenaDashJwtTokenData
| CanvasDashJwtTokenData
| ElationDashJwtTokenData
| HealthieDashJwtTokenData;
| HealthieDashJwtTokenData
| EClinicalWorksDashJwtTokenData;

export const ehrClientJwtTokenSources = [
athenaClientSource,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/external/ehr/shared/utils/mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const ehrCxMappingSecondaryMappingsSchemaMap: {
[EhrSources.elation]: elationSecondaryMappingsSchema,
[EhrSources.canvas]: undefined,
[EhrSources.healthie]: healthieSecondaryMappingsSchema,
[EhrSources.eclinicalworks]: undefined,
};
45 changes: 45 additions & 0 deletions packages/api/src/routes/ehr/eclinicalworks/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { eclinicalworksDashSource } from "@metriport/shared/interface/external/ehr/eclinicalworks/jwt-token";
import { NextFunction, Request, Response } from "express";
import { JwtTokenData } from "../../../../domain/jwt-token";
import ForbiddenError from "../../../../errors/forbidden";
import {
ParseResponse,
processCxId as processCxIdShared,
processDocumentRoute as processDocumentRouteShared,
processPatientRoute as processPatientRouteShared,
} from "../../shared";

export const tokenEhrPatientIdQueryParam = "eclinicalworksPatientIdFromToken";

function parseEClinicalWorksPracticeIdDash(
tokenData: JwtTokenData,
tokenId: string
): ParseResponse {
if (tokenData.source !== eclinicalworksDashSource) throw new ForbiddenError();
const practiceId = tokenData.practiceId;
if (!practiceId) throw new ForbiddenError();
const patientId = tokenData.patientId;
if (!patientId) throw new ForbiddenError();
return {
externalId: practiceId,
queryParams: {
practiceId,
tokenId,
[tokenEhrPatientIdQueryParam]: patientId,
},
};
}

export function processCxIdDash(req: Request, res: Response, next: NextFunction) {
processCxIdShared(req, eclinicalworksDashSource, parseEClinicalWorksPracticeIdDash)
.then(next)
.catch(next);
}

export function processPatientRoute(req: Request, res: Response, next: NextFunction) {
processPatientRouteShared(req, eclinicalworksDashSource).then(next).catch(next);
}

export function processDocumentRoute(req: Request, res: Response, next: NextFunction) {
processDocumentRouteShared(req, eclinicalworksDashSource).then(next).catch(next);
}
67 changes: 67 additions & 0 deletions packages/api/src/routes/ehr/eclinicalworks/patient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Request, Response } from "express";
import Router from "express-promise-router";
import httpStatus from "http-status";
import { syncEClinicalWorksPatientIntoMetriport } from "../../../external/ehr/eclinicalworks/command/sync-patient";
import { handleParams } from "../../helpers/handle-params";
import { requestLogger } from "../../helpers/request-logger";
import { asyncHandler, getCxIdOrFail, getFrom, getFromQueryOrFail } from "../../util";

const router = Router();

/**
* GET /ehr/eclinicalworks/patient/:id
*
* Tries to retrieve the matching Metriport patient
* @param req.params.id The ID of Eclinicalworks Patient.
* @param req.query.practiceId The ID of Eclinicalworks Practice.
* @param req.query.tokenId The ID of Eclinicalworks Token.
* @returns Metriport Patient if found.
*/
router.get(
"/:id",
handleParams,
requestLogger,
asyncHandler(async (req: Request, res: Response) => {
const cxId = getCxIdOrFail(req);
const eclinicalworksPatientId = getFrom("params").orFail("id", req);
const eclinicalworksPracticeId = getFromQueryOrFail("practiceId", req);
const eclinicalworksTokenId = getFromQueryOrFail("tokenId", req);
const patientId = await syncEClinicalWorksPatientIntoMetriport({
cxId,
eclinicalworksPracticeId,
eclinicalworksPatientId,
eclinicalworksTokenId,
});
return res.status(httpStatus.OK).json(patientId);
})
);

/**
* POST /ehr/eclinicalworks/patient/:id
*
* Tries to retrieve the matching Metriport patient
* @param req.params.id The ID of Eclinicalworks Patient.
* @param req.query.practiceId The ID of Eclinicalworks Practice.
* @param req.query.tokenId The ID of Eclinicalworks Token.
* @returns Metriport Patient if found.
*/
router.post(
"/:id",
handleParams,
requestLogger,
asyncHandler(async (req: Request, res: Response) => {
const cxId = getCxIdOrFail(req);
const eclinicalworksPatientId = getFrom("params").orFail("id", req);
const eclinicalworksPracticeId = getFromQueryOrFail("practiceId", req);
const eclinicalworksTokenId = getFromQueryOrFail("tokenId", req);
const patientId = await syncEClinicalWorksPatientIntoMetriport({
cxId,
eclinicalworksPracticeId,
eclinicalworksPatientId,
eclinicalworksTokenId,
});
return res.status(httpStatus.OK).json(patientId);
})
);

Comment on lines +39 to +66
Copy link
Member

Choose a reason for hiding this comment

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

Why do we have the same logic but one is post and the other get?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We wanted to eventually migrate to POST as it creates a mapping on our end (so not a pure GET) but frontend needs an update to roll this out completely

export default router;
Loading
Loading
0