diff --git a/__mocks__/isikukood.ts b/__mocks__/isikukood.ts new file mode 100644 index 0000000..1705754 --- /dev/null +++ b/__mocks__/isikukood.ts @@ -0,0 +1,44 @@ +// Mock for isikukood library to avoid ES module issues in tests + +export enum Gender { + MALE = 'male', + FEMALE = 'female', +} + +export default class Isikukood { + private code: string; + + constructor(code: string) { + this.code = code; + } + + static validate(code: string): boolean { + return true; // Mock always returns true for tests + } + + static generate(options?: { gender?: Gender; century?: number }): string { + return '39001010002'; // Mock Estonian ID code + } + + isValid(): boolean { + return true; + } + + getGender(): Gender { + return Gender.MALE; + } + + getBirthDate(): Date { + return new Date('1990-01-01'); + } + + getAge(): number { + return 30; + } + + getCentury(): number { + return 3; + } +} + +export { Isikukood }; diff --git a/__mocks__/server-only.ts b/__mocks__/server-only.ts new file mode 100644 index 0000000..780c05b --- /dev/null +++ b/__mocks__/server-only.ts @@ -0,0 +1,3 @@ +// Mock for server-only to avoid Next.js server component issues in tests +// This module does nothing in tests - it's just a marker for Next.js +export {}; diff --git a/app/api/job/handler/sync-analysis-groups-store.ts b/app/api/job/handler/sync-analysis-groups-store.ts index ccf9c0a..7f6ee7b 100644 --- a/app/api/job/handler/sync-analysis-groups-store.ts +++ b/app/api/job/handler/sync-analysis-groups-store.ts @@ -165,7 +165,7 @@ async function createProducts({ medusa.admin.product.list({ category_id: allCategories.map(({ id }) => id), }), - getAnalysisElements({}), + getAnalysisElements({ getAll: true }), getAnalysisPackagesType(), getProductDefaultFields({ medusa }), ]) diff --git a/app/api/job/handler/sync-analysis-groups.ts b/app/api/job/handler/sync-analysis-groups.ts index 8509bbb..59703fc 100644 --- a/app/api/job/handler/sync-analysis-groups.ts +++ b/app/api/job/handler/sync-analysis-groups.ts @@ -2,12 +2,12 @@ import axios from 'axios'; import { XMLParser } from 'fast-xml-parser'; import fs from 'fs'; import { createAnalysisGroup, getAnalysisGroups } from '~/lib/services/analysis-group.service'; -import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost.types'; -import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service'; +import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost/medipost.types'; +import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry, getAnalyses } from '~/lib/services/analyses.service'; import { getLastCheckedDate } from '~/lib/services/sync-entries.service'; -import { createAnalysisElement } from '~/lib/services/analysis-element.service'; +import { AnalysisElement, createAnalysisElement, getAnalysisElements } from '~/lib/services/analysis-element.service'; import { createCodes } from '~/lib/services/codes.service'; -import { getLatestPublicMessageListItem } from '~/lib/services/medipost.service'; +import { getLatestPublicMessageListItem } from '~/lib/services/medipost/medipostPublicMessage.service'; import type { ICode } from '~/lib/types/code'; function toArray(input?: T | T[] | null): T[] { @@ -82,42 +82,60 @@ export default async function syncAnalysisGroups() { const codes: ICode[] = []; for (const analysisGroup of analysisGroups) { const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId); + let groupExistingAnalysisElements: AnalysisElement[] = []; + let analysisGroupId: number; if (existingAnalysisGroup) { - console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`); - continue; + console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists, only creating new analysis elements`); + groupExistingAnalysisElements = await getAnalysisElements({ analysisGroupId: existingAnalysisGroup.id }); + analysisGroupId = existingAnalysisGroup.id; + } else { + analysisGroupId = await createAnalysisGroup({ + id: analysisGroup.UuringuGruppId, + name: analysisGroup.UuringuGruppNimi, + order: analysisGroup.UuringuGruppJarjekord, + }); + + const analysisGroupCodes = toArray(analysisGroup.Kood); + codes.push( + ...analysisGroupCodes.map((kood) => ({ + hk_code: kood.HkKood, + hk_code_multiplier: kood.HkKoodiKordaja, + coefficient: kood.Koefitsient, + price: kood.Hind, + analysis_group_id: analysisGroupId, + analysis_element_id: null, + analysis_id: null, + })), + ); } - // SAVE ANALYSIS GROUP - const analysisGroupId = await createAnalysisGroup({ - id: analysisGroup.UuringuGruppId, - name: analysisGroup.UuringuGruppNimi, - order: analysisGroup.UuringuGruppJarjekord, - }); - - const analysisGroupCodes = toArray(analysisGroup.Kood); - codes.push( - ...analysisGroupCodes.map((kood) => ({ - hk_code: kood.HkKood, - hk_code_multiplier: kood.HkKoodiKordaja, - coefficient: kood.Koefitsient, - price: kood.Hind, - analysis_group_id: analysisGroupId, - analysis_element_id: null, - analysis_id: null, - })), - ); - const analysisGroupItems = toArray(analysisGroup.Uuring); for (const item of analysisGroupItems) { - const analysisElement = item.UuringuElement; + const analysisElement = item.UuringuElement!; + const isExistingAnalysisElement = groupExistingAnalysisElements + .find(({ analysis_id_original }) => analysis_id_original === analysisElement.UuringId); + if (isExistingAnalysisElement) { + console.info(`Analysis element '${analysisElement.UuringNimi}' already exists`); + continue; + } const insertedAnalysisElementId = await createAnalysisElement({ - analysisElement, + analysisElement: analysisElement!, analysisGroupId, materialGroups: toArray(item.MaterjalideGrupp), }); + if (Array.isArray(analysisElement.UuringuElement)) { + for (const nestedAnalysisElement of analysisElement.UuringuElement) { + await createAnalysisElement({ + analysisElement: nestedAnalysisElement, + analysisGroupId, + materialGroups: toArray(item.MaterjalideGrupp), + }); + } + } + if (analysisElement.Kood) { const analysisElementCodes = toArray(analysisElement.Kood); codes.push( @@ -135,7 +153,15 @@ export default async function syncAnalysisGroups() { const analyses = analysisElement.UuringuElement; if (analyses?.length) { + const existingAnalyses = await getAnalyses({ originalIds: analyses.map(({ UuringId }) => UuringId) }); + for (const analysis of analyses) { + const isExistingAnalysis = existingAnalyses.find(({ analysis_id_original }) => analysis_id_original === analysis.UuringId); + if (isExistingAnalysis) { + console.info(`Analysis '${analysis.UuringNimi}' already exists`); + continue; + } + const insertedAnalysisId = await createAnalysis(analysis, analysisGroupId); if (analysis.Kood) { diff --git a/app/api/job/handler/sync-analysis-results.ts b/app/api/job/handler/sync-analysis-results.ts index e666916..4f394f1 100644 --- a/app/api/job/handler/sync-analysis-results.ts +++ b/app/api/job/handler/sync-analysis-results.ts @@ -1,4 +1,4 @@ -import { readPrivateMessageResponse } from "~/lib/services/medipost.service"; +import { readPrivateMessageResponse } from "~/lib/services/medipost/medipostPrivateMessage.service"; type ProcessedMessage = { messageId: string; diff --git a/app/api/job/medipost-retry-dispatch/route.ts b/app/api/job/medipost-retry-dispatch/route.ts index 1a97cef..3ccf0f9 100644 --- a/app/api/job/medipost-retry-dispatch/route.ts +++ b/app/api/job/medipost-retry-dispatch/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import loadEnv from "../handler/load-env"; import validateApiKey from "../handler/validate-api-key"; -import { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service"; +import { getOrderedAnalysisIds } from "~/lib/services/medusaOrder.service"; +import { sendOrderToMedipost } from "~/lib/services/medipost/medipostPrivateMessage.service"; import { retrieveOrder } from "@lib/data/orders"; import { getMedipostDispatchTries } from "~/lib/services/audit.service"; diff --git a/app/api/job/test-medipost-responses/route.ts b/app/api/job/test-medipost-responses/route.ts index 7c2944d..619756f 100644 --- a/app/api/job/test-medipost-responses/route.ts +++ b/app/api/job/test-medipost-responses/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getAnalysisOrdersAdmin } from "~/lib/services/order.service"; -import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service"; +import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipost/medipostTest.service"; import { retrieveOrder } from "@lib/data"; import { getAccountAdmin } from "~/lib/services/account.service"; -import { getOrderedAnalysisIds } from "~/lib/services/medipost.service"; +import { getOrderedAnalysisIds } from "~/lib/services/medusaOrder.service"; import loadEnv from "../handler/load-env"; import validateApiKey from "../handler/validate-api-key"; diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts index 262c1f0..5b7ac9a 100644 --- a/app/api/order/medipost-test-response/route.ts +++ b/app/api/order/medipost-test-response/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; import { getAnalysisOrder } from "~/lib/services/order.service"; -import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service"; +import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipost/medipostTest.service"; import { retrieveOrder } from "@lib/data"; import { getAccountAdmin } from "~/lib/services/account.service"; -import { createMedipostActionLog, getOrderedAnalysisIds } from "~/lib/services/medipost.service"; +import { upsertMedipostActionLog } from "~/lib/services/medipost/medipostMessageBase.service"; +import { getOrderedAnalysisIds } from "~/lib/services/medusaOrder.service"; export async function POST(request: Request) { // const isDev = process.env.NODE_ENV === 'development'; @@ -34,7 +35,7 @@ export async function POST(request: Request) { }); try { - await createMedipostActionLog({ + await upsertMedipostActionLog({ action: 'send_fake_analysis_results_to_medipost', xml: messageXml, medusaOrderId, diff --git a/app/auth/update-account/_components/update-account-form.tsx b/app/auth/update-account/_components/update-account-form.tsx index bccfd87..473325a 100644 --- a/app/auth/update-account/_components/update-account-form.tsx +++ b/app/auth/update-account/_components/update-account-form.tsx @@ -202,7 +202,7 @@ export function UpdateAccountForm({ ( - + @@ -229,7 +229,7 @@ export function UpdateAccountForm({ ( - + diff --git a/app/auth/update-account/page.tsx b/app/auth/update-account/page.tsx index 031120c..382ffeb 100644 --- a/app/auth/update-account/page.tsx +++ b/app/auth/update-account/page.tsx @@ -1,5 +1,6 @@ import { redirect } from 'next/navigation'; +import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; import { signOutAction } from '@/lib/actions/sign-out'; import { BackButton } from '@kit/shared/components/back-button'; @@ -8,10 +9,9 @@ import { pathsConfig } from '@kit/shared/config'; import { Trans } from '@kit/ui/trans'; import { withI18n } from '~/lib/i18n/with-i18n'; +import { toTitleCase } from '~/lib/utils'; import { UpdateAccountForm } from './_components/update-account-form'; -import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; -import { toTitleCase } from '~/lib/utils'; async function UpdateAccount() { const { account, user } = await loadCurrentUserAccount(); @@ -35,14 +35,14 @@ async function UpdateAccount() { })(), phone: account?.phone ?? '+372', city: account?.city ?? '', - weight: account?.accountParams?.weight ?? 0, - height: account?.accountParams?.height ?? 0, + weight: account?.accountParams?.weight ?? null, + height: account?.accountParams?.height ?? null, userConsent: account?.has_consent_personal_data ?? false, }; return ( -
-
+
+

@@ -51,9 +51,12 @@ async function UpdateAccount() {

- +

-
+
); } diff --git a/app/doctor/_components/analysis-doctor.tsx b/app/doctor/_components/analysis-doctor.tsx new file mode 100644 index 0000000..4178237 --- /dev/null +++ b/app/doctor/_components/analysis-doctor.tsx @@ -0,0 +1,149 @@ +'use client'; + +import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; + +import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; +import { format } from 'date-fns'; +import { Info } from 'lucide-react'; + +import { Trans } from '@kit/ui/trans'; +import { cn } from '@kit/ui/utils'; + +import { AnalysisElement } from '~/lib/services/analysis-element.service'; + +import AnalysisLevelBar, { + AnalysisLevelBarSkeleton, + AnalysisResultLevel, +} from './analysis-level-bar'; + +export type AnalysisResultForDisplay = Pick< + UserAnalysisElement, + | 'norm_status' + | 'response_value' + | 'unit' + | 'norm_lower_included' + | 'norm_upper_included' + | 'norm_lower' + | 'norm_upper' + | 'response_time' +>; + +export enum AnalysisStatus { + NORMAL = 0, + MEDIUM = 1, + HIGH = 2, +} + +const AnalysisDoctor = ({ + analysisElement, + results, + startIcon, + endIcon, + isCancelled, +}: { + analysisElement: Pick; + results?: AnalysisResultForDisplay; + isCancelled?: boolean; + startIcon?: ReactElement | null; + endIcon?: ReactNode | null; +}) => { + const name = analysisElement.analysis_name_lab || ''; + const status = results?.norm_status || AnalysisStatus.NORMAL; + const value = results?.response_value || 0; + const unit = results?.unit || ''; + const normLowerIncluded = results?.norm_lower_included || false; + const normUpperIncluded = results?.norm_upper_included || false; + const normLower = results?.norm_lower || 0; + const normUpper = results?.norm_upper || 0; + + const [showTooltip, setShowTooltip] = useState(false); + const analysisResultLevel = useMemo(() => { + if (!results) { + return null; + } + + const isUnderNorm = value < normLower; + if (isUnderNorm) { + switch (status) { + case AnalysisStatus.MEDIUM: + return AnalysisResultLevel.LOW; + default: + return AnalysisResultLevel.VERY_LOW; + } + } + switch (status) { + case AnalysisStatus.MEDIUM: + return AnalysisResultLevel.HIGH; + case AnalysisStatus.HIGH: + return AnalysisResultLevel.VERY_HIGH; + default: + return AnalysisResultLevel.NORMAL; + } + }, [results, value, normLower]); + + return ( +
+
+
+ {startIcon ||
} + {name} + {results?.response_time && ( +
{ + e.stopPropagation(); + setShowTooltip(!showTooltip); + }} + onMouseLeave={() => setShowTooltip(false)} + > + {' '} + +
+ )} +
+ {results ? ( + <> +
+
{value}
+
{unit}
+
+
+ {normLower} - {normUpper} +
+ +
+
+ + {endIcon ||
} + + ) : (isCancelled ? null : ( + <> +
+
+ +
+
+
+ + + ))} +
+
+ ); +}; + +export default AnalysisDoctor; diff --git a/app/doctor/_components/analysis-level-bar.tsx b/app/doctor/_components/analysis-level-bar.tsx new file mode 100644 index 0000000..e9080db --- /dev/null +++ b/app/doctor/_components/analysis-level-bar.tsx @@ -0,0 +1,134 @@ +import { useMemo } from 'react'; + +import { ArrowDown } from 'lucide-react'; + +import { cn } from '@kit/ui/utils'; +import { AnalysisResultForDisplay } from './analysis-doctor'; + +export enum AnalysisResultLevel { + VERY_LOW = 0, + LOW = 1, + NORMAL = 2, + HIGH = 3, + VERY_HIGH = 4, +} + +const Level = ({ + isActive = false, + color, + isFirst = false, + isLast = false, + arrowLocation, +}: { + isActive?: boolean; + color: 'destructive' | 'success' | 'warning' | 'gray-200'; + isFirst?: boolean; + isLast?: boolean; + arrowLocation?: number; +}) => { + return ( +
+ {isActive && ( +
+ +
+ )} +
+ ); +}; + +export const AnalysisLevelBarSkeleton = () => { + return ( +
+ +
+ ); +}; + +const AnalysisLevelBar = ({ + normLowerIncluded = true, + normUpperIncluded = true, + level, + results, +}: { + normLowerIncluded?: boolean; + normUpperIncluded?: boolean; + level: AnalysisResultLevel; + results: AnalysisResultForDisplay; +}) => { + + const { norm_lower: lower, norm_upper: upper, response_value: value } = results; + const arrowLocation = useMemo(() => { + if (value < lower!) { + return 0; + } + + if (normLowerIncluded || normUpperIncluded) { + return 50; + } + + const calculated = ((value - lower!) / (upper! - lower!)) * 100; + + if (calculated > 100) { + return 100; + } + + return calculated; + }, [value, upper, lower]); + + const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [ + level === AnalysisResultLevel.VERY_LOW, + level === AnalysisResultLevel.LOW, + level === AnalysisResultLevel.HIGH, + level === AnalysisResultLevel.VERY_HIGH, + ], [level, value, upper, lower]); + + const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; + + return ( +
+ {normLowerIncluded && ( + <> + + + + )} + + + + {normUpperIncluded && ( + <> + + + + )} +
+ ); +}; + +export default AnalysisLevelBar; diff --git a/app/doctor/_components/doctor-analysis-wrapper.tsx b/app/doctor/_components/doctor-analysis-wrapper.tsx index fec4c35..a28f4f9 100644 --- a/app/doctor/_components/doctor-analysis-wrapper.tsx +++ b/app/doctor/_components/doctor-analysis-wrapper.tsx @@ -13,7 +13,7 @@ import { } from '@kit/ui/collapsible'; import { Trans } from '@kit/ui/trans'; -import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis'; +import AnalysisDoctor from './analysis-doctor'; export default function DoctorAnalysisWrapper({ analysisData, @@ -29,7 +29,7 @@ export default function DoctorAnalysisWrapper({ asChild >
- @@ -65,7 +65,7 @@ export default function DoctorAnalysisWrapper({ {analysisData.latestPreviousAnalysis && (
- diff --git a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx index c69d0b6..386d584 100644 --- a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx @@ -52,6 +52,8 @@ export default async function AnalysisResultsPage({ ); } + const orderedAnalysisElements = analysisResponse.orderedAnalysisElements; + return ( <> @@ -80,7 +82,7 @@ export default async function AnalysisResultsPage({

@@ -88,7 +90,7 @@ export default async function AnalysisResultsPage({ i18nKey={`orders:status.${analysisResponse.order.status}`} />
@@ -102,13 +104,9 @@ export default async function AnalysisResultsPage({
)}
- {analysisResponse.elements ? ( - analysisResponse.elements.map((element, index) => ( - + {orderedAnalysisElements ? ( + orderedAnalysisElements.map((element, index) => ( + )) ) : (
diff --git a/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx b/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx index 71a7036..dadc42b 100644 --- a/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx @@ -3,14 +3,12 @@ import { useMemo } from 'react'; import { ArrowDown } from 'lucide-react'; import { cn } from '@kit/ui/utils'; -import { AnalysisResultForDisplay } from './analysis'; +import { AnalysisResultDetailsElementResults } from '@/packages/features/accounts/src/types/analysis-results'; export enum AnalysisResultLevel { - VERY_LOW = 0, - LOW = 1, - NORMAL = 2, - HIGH = 3, - VERY_HIGH = 4, + NORMAL = 0, + WARNING = 1, + CRITICAL = 2, } const Level = ({ @@ -19,17 +17,19 @@ const Level = ({ isFirst = false, isLast = false, arrowLocation, + normRangeText, }: { isActive?: boolean; color: 'destructive' | 'success' | 'warning' | 'gray-200'; isFirst?: boolean; isLast?: boolean; arrowLocation?: number; + normRangeText?: string | null; }) => { return (
92.5 && { left: '92.5%' }), + ...(arrowLocation < 7.5 && { left: '7.5%' }), + } + } : {})} >
)} + + {color === 'success' && typeof normRangeText === 'string' && ( +

+ {normRangeText} +

+ )}
); }; export const AnalysisLevelBarSkeleton = () => { return ( -
+
); }; const AnalysisLevelBar = ({ - normLowerIncluded = true, - normUpperIncluded = true, level, results, + normRangeText, }: { - normLowerIncluded?: boolean; - normUpperIncluded?: boolean; level: AnalysisResultLevel; - results: AnalysisResultForDisplay; + results: AnalysisResultDetailsElementResults; + normRangeText: string | null; }) => { - const { norm_lower: lower, norm_upper: upper, response_value: value } = results; + const { normLower: lower, normUpper: upper, responseValue: value, normStatus } = results; + const normLowerIncluded = results?.normLowerIncluded || false; + const normUpperIncluded = results?.normUpperIncluded || false; + + // Calculate arrow position based on value within normal range const arrowLocation = useMemo(() => { - if (value < lower!) { - return 0; - } - - if (normLowerIncluded || normUpperIncluded) { + // If no response value, center the arrow + if (value === null || value === undefined) { return 50; } - const calculated = ((value - lower!) / (upper! - lower!)) * 100; - - if (calculated > 100) { - return 100; + // If no normal ranges defined, center the arrow + if (lower === null && upper === null) { + return 50; } - - return calculated; + + // If only upper bound exists + if (lower === null && upper !== null) { + if (value <= upper) { + return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range + } + return 100; // Beyond upper bound + } + + // If only lower bound exists + if (upper === null && lower !== null) { + if (value >= lower) { + // Value is in normal range (above lower bound) + // Position proportionally in the normal range section + const normalizedPosition = Math.min((value - lower) / (lower * 0.5), 1); // Use 50% of lower as scale + return normalizedPosition * 100; + } + // Value is below lower bound - position in the "below normal" section + const belowPosition = Math.max(0, Math.min(1, value / lower)); + return belowPosition * 100; + } + + // Both bounds exist + if (lower !== null && upper !== null) { + if (value < lower) { + return 0; // Below normal range + } + if (value > upper) { + return 100; // Above normal range + } + // Within normal range + return ((value - lower) / (upper - lower)) * 100; + } + + return 50; // Fallback }, [value, upper, lower]); - const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [ - level === AnalysisResultLevel.VERY_LOW, - level === AnalysisResultLevel.LOW, - level === AnalysisResultLevel.HIGH, - level === AnalysisResultLevel.VERY_HIGH, - ], [level, value, upper, lower]); + // Determine level states based on normStatus + const isNormal = level === AnalysisResultLevel.NORMAL; + const isWarning = level === AnalysisResultLevel.WARNING; + const isCritical = level === AnalysisResultLevel.CRITICAL; + const isPending = level === null; - const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; + // If pending results, show gray bar + if (isPending) { + return ( +
+ +
+ ); + } + + // Show appropriate levels based on available norm bounds + const hasLowerBound = lower !== null; + const isLowerBoundZero = hasLowerBound && lower === 0; + console.info('isLowerBoundZero', results.analysisElementOriginalId, { isLowerBoundZero, hasLowerBound, lower }); + const hasUpperBound = upper !== null; + + // Determine which section the value falls into + const isValueBelowLower = hasLowerBound && value !== null && value < lower!; + const isValueAboveUpper = hasUpperBound && value !== null && value > upper!; + const isValueInNormalRange = !isValueBelowLower && !isValueAboveUpper; + + const [first, second, third] = useMemo(() => { + if (!hasLowerBound) { + return [ + { + isActive: isNormal, + color: "success", + isFirst: true, + normRangeText, + ...(isNormal ? { arrowLocation } : {}), + }, + { + isActive: isWarning, + color: "warning", + ...(isWarning ? { arrowLocation } : {}), + }, + { + isActive: isCritical, + color: "destructive", + isLast: true, + ...(isCritical ? { arrowLocation } : {}), + }, + ] as const; + } + + return [ + { + isActive: isWarning, + color: "warning", + isFirst: true, + ...(isWarning ? { arrowLocation } : {}), + }, + { + isActive: isNormal, + color: "success", + normRangeText, + ...(isNormal ? { arrowLocation } : {}), + }, + { + isActive: isCritical, + color: "destructive", + isLast: true, + ...(isCritical ? { arrowLocation } : {}), + }, + ] as const; + }, [isValueBelowLower, isValueAboveUpper, isValueInNormalRange, arrowLocation, normRangeText, isNormal, isWarning, isCritical]); return ( -
- {normLowerIncluded && ( - <> - - - - )} - - - - {normUpperIncluded && ( - <> - - - - )} +
+ + +
); }; diff --git a/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx b/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx index c88e9c3..757b120 100644 --- a/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx @@ -1,33 +1,19 @@ 'use client'; -import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; -import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; +import { AnalysisResultDetailsElement } from '@/packages/features/accounts/src/types/analysis-results'; import { format } from 'date-fns'; import { Info } from 'lucide-react'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; -import { AnalysisElement } from '~/lib/services/analysis-element.service'; - import AnalysisLevelBar, { - AnalysisLevelBarSkeleton, AnalysisResultLevel, } from './analysis-level-bar'; -export type AnalysisResultForDisplay = Pick< - UserAnalysisElement, - | 'norm_status' - | 'response_value' - | 'unit' - | 'norm_lower_included' - | 'norm_upper_included' - | 'norm_lower' - | 'norm_upper' - | 'response_time' ->; - export enum AnalysisStatus { NORMAL = 0, MEDIUM = 1, @@ -35,59 +21,76 @@ export enum AnalysisStatus { } const Analysis = ({ - analysisElement, - results, - startIcon, - endIcon, - isCancelled, + element, }: { - analysisElement: Pick; - results?: AnalysisResultForDisplay; - isCancelled?: boolean; - startIcon?: ReactElement | null; - endIcon?: ReactNode | null; + element: AnalysisResultDetailsElement; }) => { - const name = analysisElement.analysis_name_lab || ''; - const status = results?.norm_status || AnalysisStatus.NORMAL; - const value = results?.response_value || 0; - const unit = results?.unit || ''; - const normLowerIncluded = results?.norm_lower_included || false; - const normUpperIncluded = results?.norm_upper_included || false; - const normLower = results?.norm_lower || 0; - const normUpper = results?.norm_upper || 0; + const { t } = useTranslation(); - const [showTooltip, setShowTooltip] = useState(false); - const analysisResultLevel = useMemo(() => { + const name = element.analysisName || ''; + const results = element.results; + + const hasIsWithinNorm = results?.responseValueIsWithinNorm !== null; + const hasIsNegative = results?.responseValueIsNegative !== null; + + const value = (() => { if (!results) { return null; } - const isUnderNorm = value < normLower; - if (isUnderNorm) { - switch (status) { - case AnalysisStatus.MEDIUM: - return AnalysisResultLevel.LOW; - default: - return AnalysisResultLevel.VERY_LOW; + const { responseValue, responseValueIsNegative, responseValueIsWithinNorm } = results; + if (responseValue === null || responseValue === undefined) { + if (hasIsNegative) { + if (responseValueIsNegative) { + return t('analysis-results:results.value.negative'); + } + return t('analysis-results:results.value.positive'); } + if (hasIsWithinNorm) { + if (responseValueIsWithinNorm) { + return t('analysis-results:results.value.isWithinNorm'); + } + return t('analysis-results:results.value.isNotWithinNorm'); + } + return null; } - switch (status) { - case AnalysisStatus.MEDIUM: - return AnalysisResultLevel.HIGH; - case AnalysisStatus.HIGH: - return AnalysisResultLevel.VERY_HIGH; + + return responseValue; + })(); + const unit = results?.unit || ''; + const normLower = results?.normLower; + const normUpper = results?.normUpper; + const normStatus = results?.normStatus ?? null; + + const [showTooltip, setShowTooltip] = useState(false); + const analysisResultLevel = useMemo(() => { + if (normStatus === null) { + return null; + } + + switch (normStatus) { + case 1: + return AnalysisResultLevel.WARNING; + case 2: + return AnalysisResultLevel.CRITICAL; + case 0: default: return AnalysisResultLevel.NORMAL; } - }, [results, value, normLower]); + }, [normStatus]); + + const isCancelled = Number(results?.status) === 5; + const hasNestedElements = results?.nestedElements.length > 0; + + const normRangeText = normLower !== null ? `${normLower} - ${normUpper || ''}` : null; + const hasTextualResponse = hasIsNegative || hasIsWithinNorm; return (
-
+
- {startIcon ||
} {name} - {results?.response_time && ( + {results?.responseTime && (
{ @@ -105,42 +108,49 @@ const Analysis = ({ > {': '} - {format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')} + {format(new Date(results.responseTime), 'dd.MM.yyyy HH:mm')}
)}
- {results ? ( + + {isCancelled && ( +
+ +
+ )} + + {isCancelled || !results || hasNestedElements ? null : ( <>
-
{value}
+
+ {value} +
{unit}
-
- {normLower} - {normUpper} -
- -
-
- - {endIcon ||
} + {!hasTextualResponse && ( + <> +
+ {normRangeText} +
+ +
+
+ + + )} - ) : (isCancelled ? null : ( - <> -
-
- -
-
-
- - - ))} + )}
); diff --git a/app/home/(user)/(dashboard)/analysis-results/test/page.tsx b/app/home/(user)/(dashboard)/analysis-results/test/page.tsx new file mode 100644 index 0000000..ddd1f4c --- /dev/null +++ b/app/home/(user)/(dashboard)/analysis-results/test/page.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React, { useState } from 'react'; + +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; +import { Button } from '@kit/ui/shadcn/button'; +import Modal from "@modules/common/components/modal" + +import Analysis from '../_components/analysis'; +import { analysisResponses } from './test-responses'; + +export default function AnalysisResultsPage() { + const [openBlocks, setOpenBlocks] = useState([]); + + return ( + <> + + +
+
+

+ Analüüsi tulemused demo +

+
+
+ +
+ {analysisResponses.map(({ id, orderedAnalysisElements }, index) => { + const isOpen = openBlocks.includes(id); + const closeModal = () => setOpenBlocks(openBlocks.filter((block) => block !== id)); + return ( +
+
+

AnalysisOrderId: {id}

+ +
+
OrderedAnalysisElements
+ + {isOpen && ( + +
+ +

NormiStaatus

+
    +
  • 0 - testi väärtus jääb normaalväärtuste piirkonda või on määramata,
  • +
  • 1 - testi väärtus jääb hoiatava (tähelepanu suunava) märkega piirkonda,
  • +
  • 2 - testi väärtus on normaalväärtuste piirkonnast väljas või kõrgendatud tähelepanu nõudvas piirkonnas.
  • +
+ +

UuringOlek

+
    +
  • 1 - Järjekorras,
  • +
  • 2 - Ootel,
  • +
  • 3 - Töös,
  • +
  • 4 - Lõpetatud,
  • +
  • 5 - Tagasi lükatud,
  • +
  • 6 - Tühistatud,
  • +
+ +
+                            {JSON.stringify(orderedAnalysisElements, null, 2)}
+                          
+
+
+ )} +
+ + {orderedAnalysisElements ? ( + orderedAnalysisElements.map((element, index) => ( + + )) + ) : ( +
+ +
+ )} +
+
+
+ ) + })} +
+
+ + ); +} diff --git a/app/home/(user)/(dashboard)/analysis-results/test/test-responses.ts b/app/home/(user)/(dashboard)/analysis-results/test/test-responses.ts new file mode 100644 index 0000000..14ef88e --- /dev/null +++ b/app/home/(user)/(dashboard)/analysis-results/test/test-responses.ts @@ -0,0 +1,860 @@ +import { AnalysisResultDetailsMapped } from "@/packages/features/accounts/src/types/analysis-results"; + +type AnalysisTestResponse = Omit; + +const empty1: AnalysisTestResponse = { + "id": 1, + "orderedAnalysisElements": [], +}; + +const big1: AnalysisTestResponse = { + "id": 2, + "orderedAnalysisElements": [ + { + "analysisIdOriginal": "1744-2", + "isWaitingForResults": false, + "analysisName": "ALAT", + "results": { + "nestedElements": [], + "unit": "U/l", + "normLower": null, + "normUpper": 45, + "normStatus": 2, + "responseTime": "2024-02-29T10:42:25+00:00", + "responseValue": 84, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "1744-2" + } + }, + { + "analysisIdOriginal": "1920-8", + "isWaitingForResults": false, + "analysisName": "ASAT", + "results": { + "nestedElements": [], + "unit": "U/l", + "normLower": 15, + "normUpper": 45, + "normStatus": 0, + "responseTime": "2024-02-29T10:20:55+00:00", + "responseValue": 45, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "1920-8" + } + }, + { + "analysisIdOriginal": "1988-5", + "isWaitingForResults": false, + "analysisName": "CRP", + "results": { + "nestedElements": [], + "unit": "mg/l", + "normLower": null, + "normUpper": 5, + "normStatus": 0, + "responseTime": "2024-02-29T10:18:49+00:00", + "responseValue": 0.79, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "1988-5" + } + }, + { + "analysisIdOriginal": "57747-8", + "isWaitingForResults": false, + "analysisName": "Erütrotsüüdid", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": 5, + "normStatus": 0, + "responseTime": "2024-02-29T10:13:01+00:00", + "responseValue": null, + "responseValueIsNegative": true, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "57747-8" + } + }, + { + "analysisIdOriginal": "2276-4", + "isWaitingForResults": false, + "analysisName": "Ferritiin", + "results": { + "nestedElements": [], + "unit": "µg/l", + "normLower": 28, + "normUpper": 370, + "normStatus": 0, + "responseTime": "2024-02-29T10:46:54+00:00", + "responseValue": 204.1, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "2276-4" + } + }, + { + "analysisIdOriginal": "14771-0", + "isWaitingForResults": false, + "analysisName": "Glükoos", + "results": { + "nestedElements": [], + "unit": "mmol/l", + "normLower": 4.1, + "normUpper": 6, + "normStatus": 0, + "responseTime": "2024-02-29T10:06:24+00:00", + "responseValue": 5.4, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "14771-0" + } + }, + { + "analysisIdOriginal": "59156-0", + "isWaitingForResults": false, + "analysisName": "Glükoos", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": 2, + "normStatus": 2, + "responseTime": "2024-02-29T10:13:01+00:00", + "responseValue": null, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": false, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "59156-0" + } + }, + { + "analysisIdOriginal": "59156-0", + "isWaitingForResults": false, + "analysisName": "Glükoos", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": 2, + "normStatus": 0, + "responseTime": "2024-02-29T10:13:01+00:00", + "responseValue": null, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": true, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "59156-0" + } + }, + { + "analysisIdOriginal": "13955-0", + "isWaitingForResults": false, + "analysisName": "HCV Ab", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": null, + "normStatus": 0, + "responseTime": "2024-02-29T13:44:48+00:00", + "responseValue": null, + "responseValueIsNegative": true, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "13955-0" + } + }, + { + "analysisIdOriginal": "14646-4", + "isWaitingForResults": false, + "analysisName": "HDL kolesterool", + "results": { + "nestedElements": [], + "unit": "mmol/l", + "normLower": 1, + "normUpper": null, + "normStatus": 1, + "responseTime": "2024-02-29T10:20:55+00:00", + "responseValue": 0.8, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "14646-4" + } + }, + { + "analysisIdOriginal": "2000-8", + "isWaitingForResults": false, + "analysisName": "Kaltsium", + "results": { + "nestedElements": [], + "unit": "mmol/l", + "normLower": 2.1, + "normUpper": 2.55, + "normStatus": 0, + "responseTime": "2024-02-29T10:12:10+00:00", + "responseValue": 2.49, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "2000-8" + } + }, + { + "analysisIdOriginal": "59158-6", + "isWaitingForResults": false, + "analysisName": "Ketokehad", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": 0.5, + "normStatus": 0, + "responseTime": "2024-02-29T10:13:01+00:00", + "responseValue": null, + "responseValueIsNegative": true, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "59158-6" + } + }, + { + "analysisIdOriginal": "14647-2", + "isWaitingForResults": false, + "analysisName": "Kolesterool", + "results": { + "nestedElements": [], + "unit": "mmol/l", + "normLower": null, + "normUpper": 5, + "normStatus": 1, + "responseTime": "2024-02-29T10:20:34+00:00", + "responseValue": 5.7, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "14647-2" + } + }, + { + "analysisIdOriginal": "14682-9", + "isWaitingForResults": false, + "analysisName": "Kreatiniin", + "results": { + "nestedElements": [], + "unit": "µmol/l", + "normLower": 64, + "normUpper": 111, + "normStatus": 0, + "responseTime": "2024-02-29T10:19:00+00:00", + "responseValue": 89, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "14682-9" + } + }, + { + "analysisIdOriginal": "22748-8", + "isWaitingForResults": false, + "analysisName": "LDL kolesterool", + "results": { + "nestedElements": [], + "unit": "mmol/l", + "normLower": null, + "normUpper": 3, + "normStatus": 1, + "responseTime": "2024-02-29T10:21:15+00:00", + "responseValue": 4.3, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "22748-8" + } + }, + { + "analysisIdOriginal": "58805-3", + "isWaitingForResults": false, + "analysisName": "Leukotsüüdid", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": 10, + "normStatus": 0, + "responseTime": "2024-02-29T10:13:01+00:00", + "responseValue": null, + "responseValueIsNegative": true, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "58805-3" + } + }, + { + "analysisIdOriginal": "2601-3", + "isWaitingForResults": false, + "analysisName": "Magneesium", + "results": { + "nestedElements": [], + "unit": "mmol/l", + "normLower": 0.66, + "normUpper": 1.07, + "normStatus": 0, + "responseTime": "2024-02-29T10:17:26+00:00", + "responseValue": 0.82, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "2601-3" + } + }, + { + "analysisIdOriginal": "70204-3", + "isWaitingForResults": false, + "analysisName": "Mitte-HDL kolesterool", + "results": { + "nestedElements": [], + "labComment": "Mitte-paastu veri <3,9 mmol/L", + "unit": "mmol/l", + "normLower": null, + "normUpper": 3.8, + "normStatus": 1, + "responseTime": "2024-02-29T10:20:55+00:00", + "responseValue": 4.9, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "70204-3" + } + }, + { + "analysisIdOriginal": "14798-3", + "isWaitingForResults": false, + "analysisName": "Raud", + "results": { + "nestedElements": [], + "unit": "µmol/l", + "normLower": 11.6, + "normUpper": 31.3, + "normStatus": 0, + "responseTime": "2024-02-29T10:21:16+00:00", + "responseValue": 16.5, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "14798-3" + } + }, + { + "analysisIdOriginal": "14927-8", + "isWaitingForResults": false, + "analysisName": "Triglütseriidid", + "results": { + "nestedElements": [], + "labComment": "Mitte-paastu veri <2,0 mmol/L", + "unit": "mmol/l", + "normLower": null, + "normUpper": 1.7, + "normStatus": 1, + "responseTime": "2024-02-29T10:21:16+00:00", + "responseValue": 1.89, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "14927-8" + } + }, + { + "analysisIdOriginal": "3016-3", + "isWaitingForResults": false, + "analysisName": "TSH", + "results": { + "nestedElements": [], + "unit": "mIU/l", + "normLower": 0.4, + "normUpper": 4, + "normStatus": 0, + "responseTime": "2024-02-29T10:49:02+00:00", + "responseValue": 1.27, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "3016-3" + } + }, + { + "analysisIdOriginal": "22664-7", + "isWaitingForResults": false, + "analysisName": "Uurea", + "results": { + "nestedElements": [], + "unit": "mmol/l", + "normLower": 3.2, + "normUpper": 7.4, + "normStatus": 0, + "responseTime": "2024-02-29T10:19:11+00:00", + "responseValue": 6.4, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "22664-7" + } + }, + { + "analysisIdOriginal": "50561-0", + "isWaitingForResults": false, + "analysisName": "Valk", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": 0.25, + "normStatus": 0, + "responseTime": "2024-02-29T10:13:01+00:00", + "responseValue": null, + "responseValueIsNegative": true, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "50561-0" + } + }, + { + "analysisIdOriginal": "60493-4", + "isWaitingForResults": false, + "analysisName": "Vitamiin D (25-OH)", + "results": { + "nestedElements": [], + "labComment": "Väärtus >75 nmol/l on D-vitamiini tervislik tase", + "unit": "nmol/l", + "normLower": 75, + "normUpper": null, + "normStatus": 0, + "responseTime": "2024-02-29T10:49:22+00:00", + "responseValue": 105.5, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "60493-4" + } + }, + { + "analysisIdOriginal": "60025-4", + "isWaitingForResults": false, + "analysisName": "Urobilinogeen", + "results": { + "nestedElements": [], + "unit": null, + "normLower": null, + "normUpper": 17, + "normStatus": 0, + "responseTime": "2024-02-29T10:13:01+00:00", + "responseValue": null, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": true, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "60025-4" + }, + } + ], +}; + +const big2: AnalysisTestResponse = { + "id": 3, + "orderedAnalysisElements": [ + { + "analysisIdOriginal": "1988-5", + "isWaitingForResults": false, + "analysisName": "CRP", + "results": { + "nestedElements": [], + "unit": "mg/L", + "normLower": null, + "normUpper": 5, + "normStatus": 0, + "responseTime": "2025-09-12T14:02:04+00:00", + "responseValue": 1, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "status": "4", + "analysisElementOriginalId": "1988-5" + } + }, + { + "analysisIdOriginal": "57021-8", + "isWaitingForResults": false, + "analysisName": "Hemogramm", + "results": { + "nestedElements": [ + { + "status": 4, + "unit": "g/L", + "normLower": 134, + "normUpper": 170, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:03", + "responseValue": 150, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "718-7" + }, + { + "status": 4, + "unit": "%", + "normLower": 40, + "normUpper": 49, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:03", + "responseValue": 45, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "4544-3" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 4.1, + "normUpper": 9.7, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:03", + "responseValue": 5, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "6690-2" + }, + { + "status": 4, + "unit": "E12/L", + "normLower": 4.5, + "normUpper": 5.7, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:03", + "responseValue": 5, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "789-8" + }, + { + "status": 4, + "unit": "fL", + "normLower": 82, + "normUpper": 95, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 85, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "787-2" + }, + { + "status": 4, + "unit": "pg", + "normLower": 28, + "normUpper": 33, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 30, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "785-6" + }, + { + "status": 4, + "unit": "g/L", + "normLower": 322, + "normUpper": 356, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 355, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "786-4" + }, + { + "status": 4, + "unit": "%", + "normLower": 12, + "normUpper": 15, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 15, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "788-0" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 157, + "normUpper": 372, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 255, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "777-3" + }, + { + "status": 4, + "unit": "%", + "normLower": 0.18, + "normUpper": 0.38, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 0.2, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "51637-7" + }, + { + "status": 4, + "unit": "fL", + "normLower": 9.2, + "normUpper": 12.3, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 10, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "32623-1" + }, + { + "status": 4, + "unit": "fL", + "normLower": 10.1, + "normUpper": 16.2, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 15, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "32207-3" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 0.01, + "normUpper": 0.08, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 0.05, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "704-7" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 0.02, + "normUpper": 0.4, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 0.05, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "711-2" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 1.9, + "normUpper": 6.7, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 5, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "751-8" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 0.24, + "normUpper": 0.8, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 0.5, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "742-7" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 1.3, + "normUpper": 3.1, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 1.5, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "731-0" + }, + { + "status": 4, + "unit": "E9/L", + "normLower": 0, + "normUpper": 0.03, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:03", + "responseValue": 0, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "51584-1" + }, + { + "status": 4, + "unit": "%", + "normLower": 0, + "normUpper": 0.5, + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 0, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "38518-7" + }, + { + "status": 4, + "unit": "E9/L", + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 0, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "771-6" + }, + { + "status": 4, + "unit": "/100WBC", + "normStatus": 0, + "responseTime": "2025-09-12 14:02:04", + "responseValue": 0, + "normLowerIncluded": false, + "normUpperIncluded": false, + "analysisElementOriginalId": "58413-6" + } + ], + "unit": null, + "normLower": null, + "normUpper": null, + "normStatus": null, + "responseTime": null, + "responseValue": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "responseValueIsNegative": false, + "responseValueIsWithinNorm": false, + "status": "4", + "analysisElementOriginalId": "57021-8" + } + }, + { + "analysisIdOriginal": "43583-4", + "isWaitingForResults": false, + "analysisName": "Lipoproteiin a", + "results": { + "nestedElements": [], + "labComment": "Kliendi soovil analüüs tühistatud.", + "unit": null, + "normLower": null, + "normUpper": null, + "normStatus": null, + "responseTime": null, + "responseValue": null, + "normLowerIncluded": false, + "normUpperIncluded": false, + "responseValueIsNegative": false, + "responseValueIsWithinNorm": false, + "status": "5", + "analysisElementOriginalId": "43583-4" + } + }, + { + "analysisIdOriginal": "60493-4", + "isWaitingForResults": false, + "analysisName": "Vitamiin D (25-OH)", + "results": { + "nestedElements": [], + "labComment": "Väärtus vahemikus 30-49.9 nmol/L on D-vitamiini ebapiisav tase.", + "unit": "nmol/L", + "normLower": 75, + "normUpper": null, + "normStatus": 1, + "responseTime": "2025-09-12T14:02:04+00:00", + "responseValue": 30, + "normLowerIncluded": false, + "normUpperIncluded": false, + "responseValueIsNegative": null, + "responseValueIsWithinNorm": null, + "status": "4", + "analysisElementOriginalId": "60493-4" + } + } + ], +}; + +export const analysisResponses: AnalysisTestResponse[] = [ + empty1, + big1, + big2, +]; diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index f705399..7e7b22e 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -8,10 +8,11 @@ import { listProductTypes } from "@lib/data/products"; import { placeOrder, retrieveCart } from "@lib/data/cart"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; -import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; +import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; +import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { AccountWithParams } from '@kit/accounts/api'; +import type { AccountWithParams } from '@kit/accounts/types/accounts'; import type { StoreOrder } from '@medusajs/types'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index ca9fc22..de4d375 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; import { toTitleCase } from '@/lib/utils'; -import { createAccountsApi } from '@/packages/features/accounts/src/server/api'; +import { createUserAnalysesApi } from '@kit/user-analyses/api'; import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; import { PageBody, PageHeader } from '@kit/ui/page'; @@ -27,7 +27,7 @@ async function UserHomePage() { const client = getSupabaseServerClient(); const { account } = await loadCurrentUserAccount(); - const api = createAccountsApi(client); + const api = createUserAnalysesApi(client); const bmiThresholds = await api.fetchBmiThresholds(); if (!account) { diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index f8b2e79..51faecd 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; -import type { AccountWithParams } from '@/packages/features/accounts/src/server/api'; +import type { AccountWithParams } from '@kit/accounts/types/accounts'; import { Database } from '@/packages/supabase/src/database.types'; import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons'; import { diff --git a/app/home/(user)/_components/home-mobile-navigation.tsx b/app/home/(user)/_components/home-mobile-navigation.tsx index 83e9fa0..892ebd7 100644 --- a/app/home/(user)/_components/home-mobile-navigation.tsx +++ b/app/home/(user)/_components/home-mobile-navigation.tsx @@ -4,12 +4,12 @@ import { useMemo } from 'react'; import Link from 'next/link'; -import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item'; import { StoreCart } from '@medusajs/types'; -import { Cross, Menu, Shield, ShoppingCart } from 'lucide-react'; +import { Cross, Menu, Shield, ShoppingCart, UserCircle } from 'lucide-react'; import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data'; import { ApplicationRoleEnum } from '@kit/accounts/types/accounts'; +import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item'; import DropdownLink from '@kit/shared/components/ui/dropdown-link'; import { pathsConfig, @@ -102,7 +102,14 @@ export function HomeMobileNavigation(props: { - {Links} + + {Links} + } + path={pathsConfig.app.personalAccountSettings} + label="common:routes.profile" + /> + @@ -146,5 +153,3 @@ export function HomeMobileNavigation(props: { ); } - - diff --git a/app/home/(user)/_components/order/order-details.tsx b/app/home/(user)/_components/order/order-details.tsx index 1188566..e10f3bc 100644 --- a/app/home/(user)/_components/order/order-details.tsx +++ b/app/home/(user)/_components/order/order-details.tsx @@ -1,6 +1,6 @@ import { Trans } from '@kit/ui/trans'; import { formatDate } from 'date-fns'; -import { AnalysisOrder } from "~/lib/services/order.service"; +import type { AnalysisOrder } from "~/lib/types/analysis-order"; export default function OrderDetails({ order }: { order: AnalysisOrder diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx index 077d761..ccffd9e 100644 --- a/app/home/(user)/_components/orders/order-block.tsx +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -1,4 +1,4 @@ -import { AnalysisOrder } from "~/lib/services/order.service"; +import type { AnalysisOrder } from "~/lib/types/analysis-order"; import { Trans } from '@kit/ui/makerkit/trans'; import { StoreOrderLineItem } from "@medusajs/types"; import OrderItemsTable from "./order-items-table"; @@ -14,6 +14,7 @@ export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsO

+ {` (${analysisOrder.id})`}

diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index b1e4852..fc27d81 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -18,7 +18,7 @@ import { } from '@kit/ui/table'; import { Trans } from '@kit/ui/trans'; -import { AnalysisOrder } from '~/lib/services/order.service'; +import type { AnalysisOrder } from '~/lib/types/analysis-order'; import { logAnalysisResultsNavigateAction } from './actions'; diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index f7fffb9..596a041 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -5,8 +5,8 @@ import { listRegions } from '@lib/data/regions'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import type { StoreProduct } from '@medusajs/types'; import { loadCurrentUserAccount } from './load-user-account'; -import { AccountWithParams } from '@/packages/features/accounts/src/server/api'; -import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package'; +import type { AccountWithParams } from '@kit/accounts/types/accounts'; +import type { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package'; import PersonalCode from '~/lib/utils'; async function countryCodesLoader() { diff --git a/app/home/(user)/_lib/server/load-user-analyses.ts b/app/home/(user)/_lib/server/load-user-analyses.ts deleted file mode 100644 index 388bfec..0000000 --- a/app/home/(user)/_lib/server/load-user-analyses.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { cache } from 'react'; - -import { createAccountsApi } from '@kit/accounts/api'; -import { UserAnalysis } from '@kit/accounts/types/accounts'; -import { getSupabaseServerClient } from '@kit/supabase/server-client'; - -export type UserAnalyses = Awaited>; - -/** - * @name loadUserAnalyses - * @description - * Load the user's analyses. It's a cached per-request function that fetches the user workspace data. - * It can be used across the server components to load the user workspace data. - */ -export const loadUserAnalyses = cache(analysesLoader); - -async function analysesLoader(): Promise { - const client = getSupabaseServerClient(); - const api = createAccountsApi(client); - - return api.getUserAnalyses(); -} diff --git a/app/home/(user)/_lib/server/load-user-analysis.ts b/app/home/(user)/_lib/server/load-user-analysis.ts index 09efd46..77c40bd 100644 --- a/app/home/(user)/_lib/server/load-user-analysis.ts +++ b/app/home/(user)/_lib/server/load-user-analysis.ts @@ -1,8 +1,8 @@ import { cache } from 'react'; -import { createAccountsApi } from '@kit/accounts/api'; -import { AnalysisResultDetails } from '@kit/accounts/types/accounts'; +import { AnalysisResultDetailsMapped } from '@kit/accounts/types/analysis-results'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; export type UserAnalyses = Awaited>; @@ -15,9 +15,9 @@ export const loadUserAnalysis = cache(analysisLoader); async function analysisLoader( analysisOrderId: number, -): Promise { +): Promise { const client = getSupabaseServerClient(); - const api = createAccountsApi(client); + const api = createUserAnalysesApi(client); return api.getUserAnalysis(analysisOrderId); } diff --git a/app/home/(user)/settings/_components/account-preferences-form.tsx b/app/home/(user)/settings/_components/account-preferences-form.tsx index aa19746..f36adb3 100644 --- a/app/home/(user)/settings/_components/account-preferences-form.tsx +++ b/app/home/(user)/settings/_components/account-preferences-form.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { Trans } from 'react-i18next'; -import { AccountWithParams } from '@kit/accounts/api'; +import type { AccountWithParams } from '@kit/accounts/types/accounts'; import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data'; import { Button } from '@kit/ui/button'; import { Card, CardDescription, CardTitle } from '@kit/ui/card'; diff --git a/app/home/(user)/settings/_components/account-settings-form.tsx b/app/home/(user)/settings/_components/account-settings-form.tsx index 8513798..5aba272 100644 --- a/app/home/(user)/settings/_components/account-settings-form.tsx +++ b/app/home/(user)/settings/_components/account-settings-form.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { Trans } from 'react-i18next'; -import { AccountWithParams } from '@kit/accounts/api'; +import type { AccountWithParams } from '@kit/accounts/types/accounts'; import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data'; import { Button } from '@kit/ui/button'; import { diff --git a/app/home/[account]/page.tsx b/app/home/[account]/page.tsx index 8bc2ade..8279c92 100644 --- a/app/home/[account]/page.tsx +++ b/app/home/[account]/page.tsx @@ -2,10 +2,10 @@ import { use } from 'react'; -import { createAccountsApi } from '@/packages/features/accounts/src/server/api'; import { CompanyGuard } from '@/packages/features/team-accounts/src/components'; import { createTeamAccountsApi } from '@/packages/features/team-accounts/src/server/api'; import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; +import { createUserAnalysesApi } from '@kit/user-analyses/api'; import { PageBody } from '@kit/ui/page'; @@ -35,10 +35,10 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { const account = use(params).account; const client = getSupabaseServerClient(); const teamAccountsApi = createTeamAccountsApi(client); - const accountsApi = createAccountsApi(client); + const userAnalysesApi = createUserAnalysesApi(client); const teamAccount = use(teamAccountsApi.getTeamAccount(account)); const { memberParams, members } = use(teamAccountsApi.getMembers(account)); - const bmiThresholds = use(accountsApi.fetchBmiThresholds()); + const bmiThresholds = use(userAnalysesApi.fetchBmiThresholds()); const companyParams = use( teamAccountsApi.getTeamAccountParams(teamAccount.id), ); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..eac3110 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,66 @@ +import { pathsToModuleNameMapper } from 'ts-jest'; +import { readFileSync } from 'fs'; + +const tsconfig = JSON.parse(readFileSync('./tsconfig.json', 'utf8')); + +/** @type {import('jest').Config} */ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + + // Handle module resolution for TypeScript paths + moduleNameMapper: { + ...pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { + prefix: '/', + }), + // Mock problematic libraries + '^isikukood$': '/__mocks__/isikukood.ts', + '^server-only$': '/__mocks__/server-only.ts', + }, + + // Test file patterns + testMatch: [ + '**/__tests__/**/*.(ts|tsx|js)', + '**/*.(test|spec).(ts|tsx|js)' + ], + + // Setup files + setupFilesAfterEnv: ['/jest.setup.js'], + + // Coverage configuration + collectCoverageFrom: [ + 'lib/**/*.{ts,tsx}', + 'app/**/*.{ts,tsx}', + 'components/**/*.{ts,tsx}', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/.next/**' + ], + + // Transform configuration + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { + useESM: true, + tsconfig: { + jsx: 'react-jsx', + }, + }], + }, + + // Module file extensions + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + + // Ignore patterns + testPathIgnorePatterns: [ + '/.next/', + '/node_modules/', + ], + + // Transform ignore patterns for node_modules + transformIgnorePatterns: [ + 'node_modules/(?!(.*\\.mjs$))', + ], + + // ESM support + extensionsToTreatAsEsm: ['.ts'], +}; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..540bbba --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,59 @@ +// Jest setup file for global test configuration + +// Mock Next.js router +jest.mock('next/router', () => ({ + useRouter() { + return { + route: '/', + pathname: '/', + query: {}, + asPath: '/', + push: jest.fn(), + pop: jest.fn(), + reload: jest.fn(), + back: jest.fn(), + prefetch: jest.fn().mockResolvedValue(undefined), + beforePopState: jest.fn(), + events: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, + isFallback: false, + }; + }, +})); + +// Mock Next.js navigation +jest.mock('next/navigation', () => ({ + useRouter() { + return { + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + }; + }, + useSearchParams() { + return new URLSearchParams(); + }, + usePathname() { + return '/'; + }, +})); + +// Global test utilities +global.console = { + ...console, + // Suppress console.log in tests unless needed + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +// Set up environment variables for tests +process.env.NODE_ENV = 'test'; diff --git a/lib/services/analyses.service.ts b/lib/services/analyses.service.ts index 790b201..7eabeac 100644 --- a/lib/services/analyses.service.ts +++ b/lib/services/analyses.service.ts @@ -1,6 +1,6 @@ import type { Tables } from '@/packages/supabase/src/database.types'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { IUuringElement } from "./medipost.types"; +import type { IUuringElement } from "./medipost/medipost.types"; export type AnalysesWithGroupsAndElements = ({ analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & { diff --git a/lib/services/analysis-element.service.ts b/lib/services/analysis-element.service.ts index 635320c..e4f3aff 100644 --- a/lib/services/analysis-element.service.ts +++ b/lib/services/analysis-element.service.ts @@ -1,17 +1,21 @@ import { Json, Tables } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { IMaterialGroup, IUuringElement } from './medipost.types'; +import type { IMaterialGroup, IUuringElement } from './medipost/medipost.types'; export type AnalysisElement = Tables<{ schema: 'medreport' }, 'analysis_elements'> & { analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>; }; export async function getAnalysisElements({ + getAll, originalIds, ids, + analysisGroupId, }: { + getAll?: boolean; originalIds?: string[]; ids?: number[]; + analysisGroupId?: number; }): Promise { const query = getSupabaseServerAdminClient() .schema('medreport') @@ -19,14 +23,26 @@ export async function getAnalysisElements({ .select(`*, analysis_groups(*)`) .order('order', { ascending: true }); - if (Array.isArray(originalIds)) { + const hasOriginalIdsFilter = Array.isArray(originalIds); + const hasIdsFilter = Array.isArray(ids); + const hasAnalysisGroupIdFilter = typeof analysisGroupId === 'number'; + + if (!hasOriginalIdsFilter && !hasIdsFilter && !hasAnalysisGroupIdFilter && getAll !== true) { + throw new Error('Either originalIds, ids, or analysisGroupId must be provided'); + } + + if (hasOriginalIdsFilter) { query.in('analysis_id_original', [...new Set(originalIds)]); } - if (Array.isArray(ids)) { + if (hasIdsFilter) { query.in('id', ids); } + if (hasAnalysisGroupIdFilter) { + query.eq('parent_analysis_group_id', analysisGroupId); + } + const { data: analysisElements, error } = await query; if (error) { diff --git a/lib/services/analysis-order.service.ts b/lib/services/analysis-order.service.ts new file mode 100644 index 0000000..5fa5ed5 --- /dev/null +++ b/lib/services/analysis-order.service.ts @@ -0,0 +1,84 @@ +import { getSupabaseServerAdminClient } from "@/packages/supabase/src/clients/server-admin-client"; +import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis'; + +import type { AnalysisResponseElement } from "../types/analysis-response-element"; + +export async function getExistingAnalysisResponseElements({ + analysisResponseId, +}: { + analysisResponseId: number; +}): Promise { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_response_elements') + .select('*') + .eq('analysis_response_id', analysisResponseId) + .throwOnError(); + + return data as AnalysisResponseElement[]; +} + +export async function upsertAnalysisResponseElement({ + element, +}: { + element: Omit; +}) { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_response_elements') + .upsert( + element, + { + onConflict: 'analysis_response_id,analysis_element_original_id', + ignoreDuplicates: false + } + ) + .select('id') + .throwOnError(); + + const analysisResponseElementId = data?.[0]?.id; + if (!analysisResponseElementId) { + throw new Error( + `Failed to insert or update analysis response element (response id: ${element.analysis_response_id}, element id: ${element.analysis_element_original_id})` + ); + } + + return { analysisResponseElementId }; +} + +export async function upsertAnalysisResponse({ + analysisOrderId, + orderNumber, + orderStatus, + userId, +}: { + analysisOrderId: number; + orderNumber: string; + orderStatus: typeof AnalysisOrderStatus[keyof typeof AnalysisOrderStatus]; + userId: string; +}) { + const { data: analysisResponse } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_responses') + .upsert( + { + analysis_order_id: analysisOrderId, + order_number: orderNumber, + order_status: orderStatus, + user_id: userId, + }, + { onConflict: 'order_number', ignoreDuplicates: false }, + ) + .select('id') + .throwOnError(); + + + const analysisResponseId = analysisResponse?.[0]?.id; + if (!analysisResponseId) { + throw new Error( + `Failed to insert or update analysis order response (order id: ${analysisOrderId}, order number: ${orderNumber})`, + ); + } + + return { analysisResponseId }; +} diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts deleted file mode 100644 index 6fb4302..0000000 --- a/lib/services/medipost.service.ts +++ /dev/null @@ -1,791 +0,0 @@ -'use server'; - -import { - SupabaseClient, - createClient as createCustomClient, -} from '@supabase/supabase-js'; - -import { SyncStatus } from '@/lib/types/audit'; -import { - AnalysisOrderStatus, - GetMessageListResponse, - IMedipostResponseXMLBase, - MedipostAction, - MedipostOrderResponse, - MedipostPublicMessageResponse, - Message, - ResponseUuringuGrupp, - UuringuGrupp, -} from '@/lib/types/medipost'; -import { toArray } from '@/lib/utils'; -import axios from 'axios'; -import { XMLParser } from 'fast-xml-parser'; - -import { Tables } from '@kit/supabase/database'; -import { createAnalysisGroup } from './analysis-group.service'; -import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; -import { getAnalysisOrder, updateAnalysisOrderStatus } from './order.service'; -import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service'; -import { getAnalyses } from './analyses.service'; -import { getAccountAdmin } from './account.service'; -import { StoreOrder } from '@medusajs/types'; -import { listProducts } from '@lib/data/products'; -import { listRegions } from '@lib/data/regions'; -import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; -import { MedipostValidationError } from './medipost/MedipostValidationError'; -import { logMedipostDispatch } from './audit.service'; -import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service'; - -const BASE_URL = process.env.MEDIPOST_URL!; -const USER = process.env.MEDIPOST_USER!; -const PASSWORD = process.env.MEDIPOST_PASSWORD!; -const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; - -const ANALYSIS_PACKAGE_HANDLE_PREFIX = 'analysis-package-'; - -function parseXML(xml: string) { - const parser = new XMLParser({ ignoreAttributes: false }); - return parser.parse(xml); -} - -export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) { - const parsed: IMedipostResponseXMLBase = parseXML(response); - const code = parsed.ANSWER?.CODE; - if (canHaveEmptyCode) { - if (code && code !== 0) { - console.error("Bad response", response); - throw new MedipostValidationError(response); - } - return; - } - - if (typeof code !== 'number' || (code !== 0 && !canHaveEmptyCode)) { - console.error("Bad response", response); - throw new MedipostValidationError(response); - } -} - -export async function getMessages() { - try { - const publicMessage = await getLatestPublicMessageListItem(); - - if (!publicMessage) { - return null; - } - - //Teenused tuleb mappida kokku MedReport teenustega. alusel - return getPublicMessage(publicMessage.messageId); - } catch (error) { - console.error(error); - } -} - -export async function getLatestPublicMessageListItem() { - const { data } = await axios.get(BASE_URL, { - params: { - Action: MedipostAction.GetPublicMessageList, - User: USER, - Password: PASSWORD, - Sender: RECIPIENT, - // LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created - // MessageType check only for messages of certain type - }, - }); - - if (data.code && data.code !== 0) { - throw new Error('Failed to get public message list'); - } - - return getLatestMessage({ messages: data?.messages }); -} - -export async function getPublicMessage(messageId: string) { - const { data } = await axios.get(BASE_URL, { - params: { - Action: MedipostAction.GetPublicMessage, - User: USER, - Password: PASSWORD, - MessageId: messageId, - }, - headers: { - Accept: 'application/xml', - }, - }); - await validateMedipostResponse(data); - return parseXML(data) as MedipostPublicMessageResponse; -} - -export async function sendPrivateMessage(messageXml: string) { - const body = new FormData(); - body.append('Action', MedipostAction.SendPrivateMessage); - body.append('User', USER); - body.append('Password', PASSWORD); - body.append('Receiver', RECIPIENT); - body.append('MessageType', 'Tellimus'); - body.append( - 'Message', - new Blob([messageXml], { - type: 'text/xml; charset=UTF-8', - }), - ); - - const { data } = await axios.post(BASE_URL, body); - - await validateMedipostResponse(data); -} - -export async function getLatestPrivateMessageListItem({ - excludedMessageIds, -}: { - excludedMessageIds: string[]; -}) { - const { data } = await axios.get(BASE_URL, { - params: { - Action: MedipostAction.GetPrivateMessageList, - User: USER, - Password: PASSWORD, - }, - }); - - if (data.code && data.code !== 0) { - throw new Error('Failed to get private message list'); - } - - return getLatestMessage({ messages: data?.messages, excludedMessageIds }); -} - -export async function getPrivateMessage(messageId: string) { - const { data } = await axios.get(BASE_URL, { - params: { - Action: MedipostAction.GetPrivateMessage, - User: USER, - Password: PASSWORD, - MessageId: messageId, - }, - headers: { - Accept: 'application/xml', - }, - }); - - await validateMedipostResponse(data, { canHaveEmptyCode: true }); - - return { - message: parseXML(data) as MedipostOrderResponse, - xml: data as string, - }; -} - -export async function deletePrivateMessage(messageId: string) { - const { data } = await axios.get(BASE_URL, { - params: { - Action: MedipostAction.DeletePrivateMessage, - User: USER, - Password: PASSWORD, - MessageId: messageId, - }, - }); - - if (data.code && data.code !== 0) { - throw new Error(`Failed to delete private message (id: ${messageId})`); - } -} - -export async function readPrivateMessageResponse({ - excludedMessageIds, -}: { - excludedMessageIds: string[]; -}): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined; analysisOrderId: number | undefined }> { - let messageId: string | null = null; - let hasAnalysisResponse = false; - let hasPartialAnalysisResponse = false; - let hasFullAnalysisResponse = false; - let medusaOrderId: string | undefined = undefined; - let analysisOrderId: number | undefined = undefined; - - try { - const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds }); - messageId = privateMessage?.messageId ?? null; - - if (!privateMessage || !messageId) { - return { - messageId: null, - hasAnalysisResponse: false, - hasPartialAnalysisResponse: false, - hasFullAnalysisResponse: false, - medusaOrderId: undefined, - analysisOrderId: undefined - }; - } - - const { message: privateMessageContent, xml: privateMessageXml } = await getPrivateMessage( - privateMessage.messageId, - ); - - const messageResponse = privateMessageContent?.Saadetis?.Vastus; - analysisOrderId = Number(privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId); - - const hasInvalidOrderId = isNaN(analysisOrderId) - - if (hasInvalidOrderId || !messageResponse) { - await createMedipostActionLog({ - action: 'sync_analysis_results_from_medipost', - xml: privateMessageXml, - hasAnalysisResults: false, - }); - return { - messageId, - hasAnalysisResponse: false, - hasPartialAnalysisResponse: false, - hasFullAnalysisResponse: false, - medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId, - analysisOrderId: hasInvalidOrderId ? undefined : analysisOrderId - }; - } - - const analysisOrder = await getAnalysisOrder({ analysisOrderId: analysisOrderId }) - medusaOrderId = analysisOrder.medusa_order_id; - - let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; - try { - order = await getAnalysisOrder({ medusaOrderId }); - } catch (e) { - await deletePrivateMessage(privateMessage.messageId); - throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`); - } - - const status = await syncPrivateMessage({ messageResponse, order }); - - if (status.isPartial) { - await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); - hasAnalysisResponse = true; - hasPartialAnalysisResponse = true; - } else if (status.isCompleted) { - await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); - await deletePrivateMessage(privateMessage.messageId); - hasAnalysisResponse = true; - hasFullAnalysisResponse = true; - } - } catch (e) { - console.warn(`Failed to process private message id=${messageId}, message=${(e as Error).message}`); - } - - return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId, analysisOrderId }; -} - -async function saveAnalysisGroup( - analysisGroup: UuringuGrupp, - supabase: SupabaseClient, -) { - const analysisGroupId = await createAnalysisGroup({ - id: analysisGroup.UuringuGruppId, - name: analysisGroup.UuringuGruppNimi, - order: analysisGroup.UuringuGruppJarjekord, - }); - - const analysisGroupCodes = toArray(analysisGroup.Kood); - const codes: Partial>[] = - analysisGroupCodes.map((kood) => ({ - hk_code: kood.HkKood, - hk_code_multiplier: kood.HkKoodiKordaja, - coefficient: kood.Koefitsient, - price: kood.Hind, - analysis_group_id: analysisGroupId, - })); - - const analysisGroupItems = toArray(analysisGroup.Uuring); - - for (const item of analysisGroupItems) { - const analysisElement = item.UuringuElement; - - const { data: insertedAnalysisElement, error } = await supabase - .schema('medreport') - .from('analysis_elements') - .upsert( - { - analysis_id_oid: analysisElement.UuringIdOID, - analysis_id_original: analysisElement.UuringId, - tehik_short_loinc: analysisElement.TLyhend, - tehik_loinc_name: analysisElement.KNimetus, - analysis_name_lab: analysisElement.UuringNimi, - order: analysisElement.Jarjekord, - parent_analysis_group_id: analysisGroupId, - material_groups: toArray(item.MaterjalideGrupp), - }, - { onConflict: 'analysis_id_original', ignoreDuplicates: false }, - ) - .select('id'); - - if (error || !insertedAnalysisElement[0]?.id) { - throw new Error( - `Failed to insert analysis element (id: ${analysisElement.UuringId}), error: ${error?.message}`, - ); - } - - const insertedAnalysisElementId = insertedAnalysisElement[0].id; - - if (analysisElement.Kood) { - const analysisElementCodes = toArray(analysisElement.Kood); - codes.push( - ...analysisElementCodes.map((kood) => ({ - hk_code: kood.HkKood, - hk_code_multiplier: kood.HkKoodiKordaja, - coefficient: kood.Koefitsient, - price: kood.Hind, - analysis_element_id: insertedAnalysisElementId, - })), - ); - } - - const analyses = analysisElement.UuringuElement; - if (analyses?.length) { - for (const analysis of analyses) { - const { data: insertedAnalysis, error } = await supabase - .schema('medreport') - .from('analyses') - .upsert( - { - analysis_id_oid: analysis.UuringIdOID, - analysis_id_original: analysis.UuringId, - tehik_short_loinc: analysis.TLyhend, - tehik_loinc_name: analysis.KNimetus, - analysis_name_lab: analysis.UuringNimi, - order: analysis.Jarjekord, - parent_analysis_element_id: insertedAnalysisElementId, - }, - { onConflict: 'analysis_id_original', ignoreDuplicates: false }, - ) - .select('id'); - - if (error || !insertedAnalysis[0]?.id) { - throw new Error( - `Failed to insert analysis (id: ${analysis.UuringId}) error: ${error?.message}`, - ); - } - - const insertedAnalysisId = insertedAnalysis[0].id; - if (analysisElement.Kood) { - const analysisCodes = toArray(analysis.Kood); - - codes.push( - ...analysisCodes.map((kood) => ({ - hk_code: kood.HkKood, - hk_code_multiplier: kood.HkKoodiKordaja, - coefficient: kood.Koefitsient, - price: kood.Hind, - analysis_id: insertedAnalysisId, - })), - ); - } - } - } - } - - const { error: codesError } = await supabase - .schema('medreport') - .from('codes') - .upsert(codes, { ignoreDuplicates: false }); - - if (codesError?.code) { - throw new Error( - `Failed to insert codes (analysis group id: ${analysisGroup.UuringuGruppId})`, - ); - } -} - -export async function syncPublicMessage( - message?: MedipostPublicMessageResponse | null, -) { - const supabase = createCustomClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, - { - auth: { - persistSession: false, - autoRefreshToken: false, - detectSessionInUrl: false, - }, - }, - ); - - try { - const providers = toArray(message?.Saadetis?.Teenused.Teostaja); - const analysisGroups = providers.flatMap((provider) => - toArray(provider.UuringuGrupp), - ); - if (!message || !analysisGroups.length) { - return supabase.schema('audit').from('sync_entries').insert({ - operation: 'ANALYSES_SYNC', - comment: 'No data received', - status: SyncStatus.Fail, - changed_by_role: 'service_role', - }); - } - - for (const analysisGroup of analysisGroups) { - await saveAnalysisGroup(analysisGroup, supabase); - } - - await supabase.schema('audit').from('sync_entries').insert({ - operation: 'ANALYSES_SYNC', - status: SyncStatus.Success, - changed_by_role: 'service_role', - }); - } catch (e) { - console.error(e); - await supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'ANALYSES_SYNC', - status: SyncStatus.Fail, - comment: JSON.stringify(e), - changed_by_role: 'service_role', - }); - } -} - -function getLatestMessage({ - messages, - excludedMessageIds, -}: { - messages?: Message[]; - excludedMessageIds?: string[]; -}) { - if (!messages?.length) { - return null; - } - - const filtered = messages.filter(({ messageId }) => !excludedMessageIds?.includes(messageId)); - - if (!filtered.length) { - return null; - } - - return filtered.reduce((prev, current) => - Number(prev.messageId) > Number(current.messageId) ? prev : current, - { messageId: '' } as Message, - ); -} - -async function syncPrivateMessage({ - messageResponse, - order, -}: { - messageResponse: NonNullable; - order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; -}) { - const supabase = getSupabaseServerAdminClient() - - const { data: analysisOrder, error: analysisOrderError } = await supabase - .schema('medreport') - .from('analysis_orders') - .select('user_id') - .eq('id', order.id); - - if (analysisOrderError || !analysisOrder?.[0]?.user_id) { - throw new Error( - `Could not find analysis order with id ${messageResponse.ValisTellimuseId}`, - ); - } - - const { data: analysisResponse, error } = await supabase - .schema('medreport') - .from('analysis_responses') - .upsert( - { - analysis_order_id: order.id, - order_number: messageResponse.TellimuseNumber, - order_status: AnalysisOrderStatus[messageResponse.TellimuseOlek], - user_id: analysisOrder[0].user_id, - }, - { onConflict: 'order_number', ignoreDuplicates: false }, - ) - .select('id'); - - if (error || !analysisResponse?.[0]?.id) { - throw new Error( - `Failed to insert or update analysis order response (external id: ${messageResponse?.TellimuseNumber})`, - ); - } - const analysisGroups = toArray(messageResponse.UuringuGrupp); - console.info(`Order has results for ${analysisGroups.length} analysis groups`); - - const responses: Omit< - Tables<{ schema: 'medreport' }, 'analysis_response_elements'>, - 'id' | 'created_at' | 'updated_at' - >[] = []; - - const analysisResponseId = analysisResponse[0]!.id; - - for (const analysisGroup of analysisGroups) { - const groupItems = toArray( - analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], - ); - console.info(`Order has results in group ${analysisGroup.UuringuGruppNimi} for ${groupItems.length} analysis elements`); - for (const item of groupItems) { - const element = item.UuringuElement; - const elementAnalysisResponses = toArray(element.UuringuVastus); - - responses.push( - ...elementAnalysisResponses.map((response) => ({ - analysis_element_original_id: element.UuringId, - analysis_response_id: analysisResponseId, - norm_lower: response.NormAlum?.['#text'] ?? null, - norm_lower_included: - response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', - norm_status: response.NormiStaatus, - norm_upper: response.NormYlem?.['#text'] ?? null, - norm_upper_included: - response.NormYlem?.['@_kaasaarvatud'].toLowerCase() === 'jah', - response_time: response.VastuseAeg ?? null, - response_value: response.VastuseVaartus, - unit: element.Mootyhik ?? null, - original_response_element: element, - analysis_name: element.UuringNimi || element.KNimetus, - comment: element.UuringuKommentaar ?? '', - })), - ); - } - } - - const { error: deleteError } = await supabase - .schema('medreport') - .from('analysis_response_elements') - .delete() - .eq('analysis_response_id', analysisResponseId); - - if (deleteError) { - throw new Error( - `Failed to clean up response elements for response id ${analysisResponseId}`, - ); - } - - const { error: elementInsertError } = await supabase - .schema('medreport') - .from('analysis_response_elements') - .insert(responses); - - if (elementInsertError) { - throw new Error( - `Failed to insert order response elements for response id ${analysisResponseId}`, - ); - } - - const { data: allOrderResponseElements } = await supabase - .schema('medreport') - .from('analysis_response_elements') - .select('*') - .eq('analysis_response_id', analysisResponseId) - .throwOnError(); - const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0; - if (allOrderResponseElements.length !== expectedOrderResponseElements) { - return { isPartial: true }; - } - - const statusFromResponse = AnalysisOrderStatus[messageResponse.TellimuseOlek]; - return { isCompleted: statusFromResponse === 'COMPLETED' }; -} - -export async function sendOrderToMedipost({ - medusaOrderId, - orderedAnalysisElements, -}: { - medusaOrderId: string; - orderedAnalysisElements: OrderedAnalysisElement[]; -}) { - const medreportOrder = await getAnalysisOrder({ medusaOrderId }); - const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); - - const orderedAnalysesIds = orderedAnalysisElements - .map(({ analysisId }) => analysisId) - .filter(Boolean) as number[]; - const orderedAnalysisElementsIds = orderedAnalysisElements - .map(({ analysisElementId }) => analysisElementId) - .filter(Boolean) as number[]; - - const analyses = await getAnalyses({ ids: orderedAnalysesIds }); - if (analyses.length !== orderedAnalysesIds.length) { - throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`); - } - const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); - if (analysisElements.length !== orderedAnalysisElementsIds.length) { - throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`); - } - - const orderXml = await composeOrderXML({ - analyses, - analysisElements, - person: { - idCode: account.personal_code!, - firstName: account.name ?? '', - lastName: account.last_name ?? '', - phone: account.phone ?? '', - }, - orderId: medreportOrder.id, - orderCreatedAt: new Date(medreportOrder.created_at), - comment: '', - }); - - try { - await sendPrivateMessage(orderXml); - } catch (e) { - const isMedipostError = e instanceof MedipostValidationError; - if (isMedipostError) { - await logMedipostDispatch({ - medusaOrderId, - isSuccess: false, - isMedipostError, - errorMessage: e.response, - }); - await createMedipostActionLog({ - action: 'send_order_to_medipost', - xml: orderXml, - hasAnalysisResults: false, - medusaOrderId, - responseXml: e.response, - hasError: true, - }); - } else { - await logMedipostDispatch({ - medusaOrderId, - isSuccess: false, - isMedipostError, - }); - await createMedipostActionLog({ - action: 'send_order_to_medipost', - xml: orderXml, - hasAnalysisResults: false, - medusaOrderId, - hasError: true, - }); - } - - throw e; - } - await logMedipostDispatch({ - medusaOrderId, - isSuccess: true, - isMedipostError: false, - }); - await createMedipostActionLog({ - action: 'send_order_to_medipost', - xml: orderXml, - hasAnalysisResults: false, - medusaOrderId, - }); - await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); -} - -export async function getOrderedAnalysisIds({ - medusaOrder, -}: { - medusaOrder: StoreOrder; -}): Promise<{ - analysisElementId?: number; - analysisId?: number; -}[]> { - const countryCodes = await listRegions(); - const countryCode = countryCodes[0]!.countries![0]!.iso_2!; - - async function getOrderedAnalysisElements(medusaOrder: StoreOrder) { - const originalIds = (medusaOrder?.items ?? []) - .map((a) => a.product?.metadata?.analysisIdOriginal) - .filter((a) => typeof a === 'string') as string[]; - const analysisElements = await getAnalysisElements({ originalIds }); - return analysisElements.map(({ id }) => ({ analysisElementId: id })); - } - - async function getOrderedAnalyses(medusaOrder: StoreOrder) { - const originalIds = (medusaOrder?.items ?? []) - .map((a) => a.product?.metadata?.analysisIdOriginal) - .filter((a) => typeof a === 'string') as string[]; - const analyses = await getAnalyses({ originalIds }); - return analyses.map(({ id }) => ({ analysisId: id })); - } - - async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) { - const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); - const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[]; - if (orderedPackageIds.length === 0) { - return []; - } - console.info(`Order has ${orderedPackageIds.length} packages`); - const { response: { products: orderedPackagesProducts } } = await listProducts({ - countryCode, - queryParams: { limit: 100, id: orderedPackageIds }, - }); - console.info(`Order has ${orderedPackagesProducts.length} packages = ${JSON.stringify(orderedPackageIds, null, 2)}`); - if (orderedPackagesProducts.length !== orderedPackageIds.length) { - throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`); - } - - const ids = getAnalysisElementMedusaProductIds( - orderedPackagesProducts.map(({ id, metadata }) => ({ - metadata, - variant: orderedPackages.find(({ product }) => product?.id === id)?.variant, - })), - ); - if (ids.length === 0) { - return []; - } - const { response: { products: analysisPackagesProducts } } = await listProducts({ - countryCode, - queryParams: { limit: 100, id: ids }, - }); - if (analysisPackagesProducts.length !== ids.length) { - throw new Error(`Got ${analysisPackagesProducts.length} analysis packages products, expected ${ids.length}`); - } - - const originalIds = analysisPackagesProducts - .map(({ metadata }) => metadata?.analysisIdOriginal) - .filter((id) => typeof id === 'string'); - if (originalIds.length !== ids.length) { - throw new Error(`Got ${originalIds.length} analysis packages products with analysisIdOriginal, expected ${ids.length}`); - } - const analysisElements = await getAnalysisElements({ originalIds }); - - return analysisElements.map(({ id }) => ({ analysisElementId: id })); - } - - const [analysisPackageElements, orderedAnalysisElements, orderedAnalyses] = await Promise.all([ - getOrderedAnalysisPackages(medusaOrder), - getOrderedAnalysisElements(medusaOrder), - getOrderedAnalyses(medusaOrder), - ]); - - return [...analysisPackageElements, ...orderedAnalysisElements, ...orderedAnalyses]; -} - -export async function createMedipostActionLog({ - action, - xml, - hasAnalysisResults = false, - medusaOrderId, - responseXml, - hasError = false, -}: { - action: - | 'send_order_to_medipost' - | 'sync_analysis_results_from_medipost' - | 'send_fake_analysis_results_to_medipost' - | 'send_analysis_results_to_medipost'; - xml: string; - hasAnalysisResults?: boolean; - medusaOrderId?: string | null; - responseXml?: string | null; - hasError?: boolean; -}) { - await getSupabaseServerAdminClient() - .schema('medreport') - .from('medipost_actions') - .insert({ - action, - xml, - has_analysis_results: hasAnalysisResults, - medusa_order_id: medusaOrderId, - response_xml: responseXml, - has_error: hasError, - }) - .select('id') - .throwOnError(); -} diff --git a/lib/services/medipost.types.ts b/lib/services/medipost/medipost.types.ts similarity index 97% rename from lib/services/medipost.types.ts rename to lib/services/medipost/medipost.types.ts index 3f14de8..ed2cbca 100644 --- a/lib/services/medipost.types.ts +++ b/lib/services/medipost/medipost.types.ts @@ -68,7 +68,7 @@ export interface IMedipostPublicMessageDataParsed { Koefitsient: number; Hind: number; }[]; - UuringuElement: IUuringElement; + UuringuElement?: IUuringElement[]; }[]; MaterjalideGrupp: IMaterialGroup[]; Kood: { diff --git a/lib/services/medipost/medipostMessageBase.service.ts b/lib/services/medipost/medipostMessageBase.service.ts new file mode 100644 index 0000000..d3194e8 --- /dev/null +++ b/lib/services/medipost/medipostMessageBase.service.ts @@ -0,0 +1,82 @@ +'use server'; + +import type { Message } from '@/lib/types/medipost'; + +import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; + +export async function getLatestMessage({ + messages, + excludedMessageIds, +}: { + messages?: Message[]; + excludedMessageIds?: string[]; +}) { + if (!messages?.length) { + return null; + } + + const filtered = messages.filter(({ messageId }) => !excludedMessageIds?.includes(messageId)); + + if (!filtered.length) { + return null; + } + + return filtered.reduce((prev, current) => + Number(prev.messageId) > Number(current.messageId) ? prev : current, + { messageId: '' } as Message, + ); +} + +export async function upsertMedipostActionLog({ + action, + xml, + hasAnalysisResults = false, + medusaOrderId, + responseXml, + hasError = false, + medipostExternalOrderId, + medipostPrivateMessageId, +}: { + action: + | 'send_order_to_medipost' + | 'sync_analysis_results_from_medipost' + | 'send_fake_analysis_results_to_medipost'; + xml: string; + hasAnalysisResults?: boolean; + medusaOrderId?: string | null; + responseXml?: string | null; + hasError?: boolean; + medipostExternalOrderId?: string | null; + medipostPrivateMessageId?: string | null; +}) { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('medipost_actions') + .upsert( + { + action, + xml, + has_analysis_results: hasAnalysisResults, + medusa_order_id: medusaOrderId, + response_xml: responseXml, + has_error: hasError, + medipost_external_order_id: medipostExternalOrderId, + medipost_private_message_id: medipostPrivateMessageId, + }, + { + onConflict: 'medipost_private_message_id', + ignoreDuplicates: false + } + ) + .select('id') + .throwOnError(); + + const medipostActionId = data?.[0]?.id; + if (!medipostActionId) { + throw new Error( + `Failed to insert or update medipost action (private message id: ${medipostPrivateMessageId})` + ); + } + + return { medipostActionId }; +} diff --git a/lib/services/medipost/medipostPrivateMessage.service.test.ts b/lib/services/medipost/medipostPrivateMessage.service.test.ts new file mode 100644 index 0000000..aeb16f1 --- /dev/null +++ b/lib/services/medipost/medipostPrivateMessage.service.test.ts @@ -0,0 +1,112 @@ +import { AnalysisResponseElement } from "~/lib/types/analysis-response-element"; +import { canCreateAnalysisResponseElement, getAnalysisResponseElementsForGroup } from "./medipostPrivateMessage.service"; +import { ResponseUuring } from "@/packages/shared/src/types/medipost-analysis"; + +type TestExistingElement = Pick; + +describe('medipostPrivateMessage.service', () => { + describe('canCreateAnalysisResponseElement', () => { + it('should return true if the analysis response element does not exist', async () => { + const existingElements = [] as TestExistingElement[]; + const groupUuring = { + UuringuElement: { + UuringOlek: 1, + UuringId: '1', + }, + } as const; + const responseValue = 1; + const log = jest.fn(); + expect(await canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })).toBe(true); + }); + + it('should return false if the analysis response element exists and the status is higher', async () => { + const existingElements = [{ analysis_element_original_id: '1', status: '2', response_value: 1 }] as TestExistingElement[]; + const groupUuring = { + UuringuElement: { + UuringOlek: 1, + UuringId: '1', + }, + } as const; + const responseValue = 1; + const log = jest.fn(); + expect(await canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })).toBe(false); + }); + }); + + describe('getAnalysisResponseElementsForGroup', () => { + it('should return single new element', async () => { + const analysisGroup = { + UuringuGruppNimi: '1', + Uuring: [ + { + UuringuElement: { + UuringOlek: 1, + UuringId: '1', + UuringuVastus: [{ VastuseVaartus: '1' }], + }, + }, + ] as unknown as ResponseUuring[], + } as const; + const existingElements = [] as TestExistingElement[]; + const log = jest.fn(); + expect(await getAnalysisResponseElementsForGroup({ analysisGroup, existingElements, log })) + .toEqual([{ + analysis_element_original_id: '1', + analysis_name: undefined, + comment: null, + norm_lower: null, + norm_lower_included: false, + norm_status: undefined, + norm_upper: null, + norm_upper_included: false, + response_time: null, + response_value: 1, + unit: null, + original_response_element: { + UuringOlek: 1, + UuringId: '1', + UuringuVastus: [{ VastuseVaartus: '1' }], + }, + status: '1', + }]); + }); + + it('should return no new element if element already exists in higher status', async () => { + const analysisGroup = { + UuringuGruppNimi: '1', + Uuring: [ + { + UuringuElement: { + UuringOlek: 1, + UuringId: '1', + UuringuVastus: [{ VastuseVaartus: '1' }], + }, + }, + ] as unknown as ResponseUuring[], + } as const; + const existingElements = [{ analysis_element_original_id: '1', status: '2', response_value: 1 }] as TestExistingElement[]; + const log = jest.fn(); + expect(await getAnalysisResponseElementsForGroup({ analysisGroup, existingElements, log })) + .toEqual([]); + }); + + it('should return no new element if element already exists with response value', async () => { + const analysisGroup = { + UuringuGruppNimi: '1', + Uuring: [ + { + UuringuElement: { + UuringOlek: 1, + UuringId: '1', + UuringuVastus: [{ VastuseVaartus: '' }], + }, + }, + ] as unknown as ResponseUuring[], + } as const; + const existingElements = [{ analysis_element_original_id: '1', status: '1', response_value: 1 }] as TestExistingElement[]; + const log = jest.fn(); + expect(await getAnalysisResponseElementsForGroup({ analysisGroup, existingElements, log })) + .toEqual([]); + }); + }); +}); diff --git a/lib/services/medipost/medipostPrivateMessage.service.ts b/lib/services/medipost/medipostPrivateMessage.service.ts new file mode 100644 index 0000000..fba8b76 --- /dev/null +++ b/lib/services/medipost/medipostPrivateMessage.service.ts @@ -0,0 +1,515 @@ +'use server'; + +import type { PostgrestError } from '@supabase/supabase-js'; +import axios from 'axios'; + +import { + GetMessageListResponse, + MedipostAction, +} from '@/lib/types/medipost'; +import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis'; +import type { + ResponseUuringuGrupp, + MedipostOrderResponse, + UuringElement, +} from '@/packages/shared/src/types/medipost-analysis'; +import { toArray } from '@/lib/utils'; +import type { AnalysisOrder } from '~/lib/types/analysis-order'; +import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element'; + +import { Tables } from '@kit/supabase/database'; +import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; +import { getAnalysisElementsAdmin } from '../analysis-element.service'; +import { getAnalyses } from '../analyses.service'; +import { upsertMedipostActionLog, getLatestMessage } from './medipostMessageBase.service'; +import { validateMedipostResponse } from './medipostValidate.service'; +import { getAnalysisOrder, updateAnalysisOrderStatus } from '../order.service'; +import { parseXML } from '../util/xml.service'; +import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service'; +import { getAccountAdmin } from '../account.service'; +import { logMedipostDispatch } from '../audit.service'; +import { MedipostValidationError } from './MedipostValidationError'; +import { upsertAnalysisResponseElement, getExistingAnalysisResponseElements, upsertAnalysisResponse } from '../analysis-order.service'; + +const BASE_URL = process.env.MEDIPOST_URL!; +const USER = process.env.MEDIPOST_USER!; +const PASSWORD = process.env.MEDIPOST_PASSWORD!; +const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; + +const IS_ENABLED_DELETE_PRIVATE_MESSAGE = false as boolean; + +export async function getLatestPrivateMessageListItem({ + excludedMessageIds, +}: { + excludedMessageIds: string[]; +}) { + const { data } = await axios.get(BASE_URL, { + params: { + Action: MedipostAction.GetPrivateMessageList, + User: USER, + Password: PASSWORD, + }, + }); + + if (data.code && data.code !== 0) { + throw new Error('Failed to get private message list'); + } + + return await getLatestMessage({ messages: data?.messages, excludedMessageIds }); +} + +const logger = (analysisOrder: AnalysisOrder, externalId: string, analysisResponseId: string) => (message: string, error?: PostgrestError | null) => { + const messageFormatted = `[${analysisOrder.id}] [${externalId}] [${analysisResponseId}] ${message}`; + if (error) { + console.info(messageFormatted, error); + } else { + console.info(messageFormatted); + } +}; + +export async function canCreateAnalysisResponseElement({ + existingElements, + groupUuring: { + UuringuElement: { + UuringOlek: status, + UuringId: analysisElementOriginalId, + }, + }, + responseValue, + log, +}: { + existingElements: Pick[]; + groupUuring: { UuringuElement: Pick }; + responseValue: number | null; + log: ReturnType; +}) { + const existingAnalysisResponseElement = existingElements.find(({ analysis_element_original_id }) => analysis_element_original_id === analysisElementOriginalId); + if (!existingAnalysisResponseElement) { + return true; + } + + if (Number(existingAnalysisResponseElement.status) > status) { + log(`Analysis response element id=${analysisElementOriginalId} already exists for order in higher status ${existingAnalysisResponseElement.status} than ${status}`); + return false; + } + + if (existingAnalysisResponseElement.response_value && !responseValue) { + log(`Analysis response element id=${analysisElementOriginalId} already exists for order with response value ${existingAnalysisResponseElement.response_value} but new response has no value`); + return false; + } + + return true; +} + + +export async function getAnalysisResponseElementsForGroup({ + analysisGroup, + existingElements, + log, +}: { + analysisGroup: Pick; + existingElements: Pick[]; + log: ReturnType; +}) { + const groupUuringItems = toArray(analysisGroup.Uuring as ResponseUuringuGrupp['Uuring']); + log(`Order has results in group '${analysisGroup.UuringuGruppNimi}' for ${groupUuringItems.length} analysis elements`); + + const results: Omit[] = []; + + for (const groupUuring of groupUuringItems) { + const groupUuringElement = groupUuring.UuringuElement; + const elementAnalysisResponses = toArray(groupUuringElement.UuringuVastus); + + const status = groupUuringElement.UuringOlek; + log(`Group uuring '${analysisGroup.UuringuGruppNimi}' has status ${status}`); + + for (const response of elementAnalysisResponses) { + const analysisElementOriginalId = groupUuringElement.UuringId; + const vastuseVaartus = response.VastuseVaartus; + const responseValue = (() => { + const valueAsNumber = Number(vastuseVaartus); + if (isNaN(valueAsNumber)) { + return null; + } + return valueAsNumber; + })(); + + if (!await canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })) { + continue; + } + + const responseValueIsNumeric = responseValue !== null; + const responseValueIsNegative = vastuseVaartus === 'Negatiivne'; + const responseValueIsWithinNorm = vastuseVaartus === 'Normi piires'; + + results.push({ + analysis_element_original_id: analysisElementOriginalId, + norm_lower: response.NormAlum?.['#text'] ?? null, + norm_lower_included: + response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', + norm_status: response.NormiStaatus, + norm_upper: response.NormYlem?.['#text'] ?? null, + norm_upper_included: + response.NormYlem?.['@_kaasaarvatud'].toLowerCase() === 'jah', + response_time: response.VastuseAeg ?? null, + response_value: responseValue, + unit: groupUuringElement.Mootyhik ?? null, + original_response_element: groupUuringElement, + analysis_name: groupUuringElement.UuringNimi || groupUuringElement.KNimetus, + comment: groupUuringElement.UuringuKommentaar ?? null, + status: status.toString(), + response_value_is_within_norm: responseValueIsNumeric ? null : responseValueIsWithinNorm, + response_value_is_negative: responseValueIsNumeric ? null : responseValueIsNegative, + }); + } + } + + return results; +} + +async function getNewAnalysisResponseElements({ + analysisGroups, + existingElements, + log, +}: { + analysisGroups: ResponseUuringuGrupp[]; + existingElements: AnalysisResponseElement[]; + log: ReturnType; +}) { + const newElements: Omit[] = []; + for (const analysisGroup of analysisGroups) { + log(`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`); + const elements = await getAnalysisResponseElementsForGroup({ + analysisGroup, + existingElements, + log, + }); + newElements.push(...elements); + } + return newElements; +} + +async function hasAllAnalysisResponseElements({ + analysisResponseId, + order, +}: { + analysisResponseId: number; + order: Pick; +}) { + const allOrderResponseElements = await getExistingAnalysisResponseElements({ analysisResponseId }); + const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0; + return allOrderResponseElements.length === expectedOrderResponseElements; +} + +export async function syncPrivateMessage({ + messageResponse: { + ValisTellimuseId: externalId, + TellimuseNumber: orderNumber, + TellimuseOlek, + UuringuGrupp, + }, + order, +}: { + messageResponse: Pick, 'ValisTellimuseId' | 'TellimuseNumber' | 'TellimuseOlek' | 'UuringuGrupp'>; + order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; +}) { + const supabase = getSupabaseServerAdminClient(); + + const orderStatus = AnalysisOrderStatus[TellimuseOlek]; + + const log = logger(order, externalId, orderNumber); + + const { data: analysisOrder } = await supabase + .schema('medreport') + .from('analysis_orders') + .select('id, user_id') + .eq('id', order.id) + .single() + .throwOnError(); + + const { analysisResponseId } = await upsertAnalysisResponse({ + analysisOrderId: order.id, + orderNumber, + orderStatus, + userId: analysisOrder.user_id, + }); + + const existingElements = await getExistingAnalysisResponseElements({ analysisResponseId }); + + const analysisGroups = toArray(UuringuGrupp); + log(`Order has results for ${analysisGroups.length} analysis groups`); + const newElements = await getNewAnalysisResponseElements({ analysisGroups, existingElements, log }); + + for (const element of newElements) { + try { + await upsertAnalysisResponseElement({ + element: { + ...element, + analysis_response_id: analysisResponseId, + }, + }); + } catch (e) { + log(`Failed to create order response element for response id ${analysisResponseId}, element id '${element.analysis_element_original_id}' (order id: ${order.id})`, e as PostgrestError); + } + } + + return await hasAllAnalysisResponseElements({ analysisResponseId, order }) + ? { isCompleted: orderStatus === 'COMPLETED' } + : { isPartial: true }; +} + +export async function readPrivateMessageResponse({ + excludedMessageIds, +}: { + excludedMessageIds: string[]; +}): Promise<{ + messageId: string | null; + hasAnalysisResponse: boolean; + hasPartialAnalysisResponse: boolean; + hasFullAnalysisResponse: boolean; + medusaOrderId: string | undefined; + analysisOrderId: number | undefined; +}> { + let messageId: string | null = null; + let hasAnalysisResponse = false; + let hasPartialAnalysisResponse = false; + let hasFullAnalysisResponse = false; + let medusaOrderId: string | undefined = undefined; + let analysisOrderId: number | undefined = undefined; + + try { + const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds }); + messageId = privateMessage?.messageId ?? null; + + if (!privateMessage || !messageId) { + return { + messageId: null, + hasAnalysisResponse: false, + hasPartialAnalysisResponse: false, + hasFullAnalysisResponse: false, + medusaOrderId: undefined, + analysisOrderId: undefined + }; + } + + const { messageId: privateMessageId } = privateMessage; + const { message: privateMessageContent, xml: privateMessageXml } = await getPrivateMessage( + privateMessageId, + ); + + const messageResponse = privateMessageContent?.Saadetis?.Vastus; + const medipostExternalOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId; + const patientPersonalCode = messageResponse?.Patsient.Isikukood?.toString(); + analysisOrderId = Number(medipostExternalOrderId); + + const hasInvalidOrderId = isNaN(analysisOrderId); + + if (hasInvalidOrderId || !messageResponse || !patientPersonalCode) { + await upsertMedipostActionLog({ + action: 'sync_analysis_results_from_medipost', + xml: privateMessageXml, + hasAnalysisResults: false, + medipostPrivateMessageId: privateMessageId, + medusaOrderId, + medipostExternalOrderId, + hasError: true, + }); + return { + messageId, + hasAnalysisResponse: false, + hasPartialAnalysisResponse: false, + hasFullAnalysisResponse: false, + medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId, + analysisOrderId: hasInvalidOrderId ? undefined : analysisOrderId + }; + } + + let analysisOrder: AnalysisOrder; + try { + analysisOrder = await getAnalysisOrder({ analysisOrderId }) + medusaOrderId = analysisOrder.medusa_order_id; + } catch (e) { + if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) { + await deletePrivateMessage(privateMessageId); + } + throw new Error(`No analysis order found for Medipost message ValisTellimuseId=${medipostExternalOrderId}`); + } + + const orderPerson = await getAccountAdmin({ primaryOwnerUserId: analysisOrder.user_id }); + if (orderPerson.personal_code !== patientPersonalCode) { + throw new Error(`Order person personal code does not match Medipost message Patsient.Isikukood=${patientPersonalCode}, orderPerson.personal_code=${orderPerson.personal_code}`); + } + + const status = await syncPrivateMessage({ messageResponse, order: analysisOrder }); + + await upsertMedipostActionLog({ + action: 'sync_analysis_results_from_medipost', + xml: privateMessageXml, + hasAnalysisResults: true, + medipostPrivateMessageId: privateMessageId, + medusaOrderId, + medipostExternalOrderId, + }); + if (status.isPartial) { + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); + hasAnalysisResponse = true; + hasPartialAnalysisResponse = true; + } else if (status.isCompleted) { + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); + if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) { + await deletePrivateMessage(privateMessageId); + } + hasAnalysisResponse = true; + hasFullAnalysisResponse = true; + } + } catch (e) { + console.warn(`Failed to process private message id=${messageId}, message=${(e as Error).message}`); + } + + return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId, analysisOrderId }; +} + +export async function deletePrivateMessage(messageId: string) { + const { data } = await axios.get(BASE_URL, { + params: { + Action: MedipostAction.DeletePrivateMessage, + User: USER, + Password: PASSWORD, + MessageId: messageId, + }, + }); + + if (data.code && data.code !== 0) { + throw new Error(`Failed to delete private message (id: ${messageId})`); + } +} + +export async function sendPrivateMessage(messageXml: string) { + const body = new FormData(); + body.append('Action', MedipostAction.SendPrivateMessage); + body.append('User', USER); + body.append('Password', PASSWORD); + body.append('Receiver', RECIPIENT); + body.append('MessageType', 'Tellimus'); + body.append( + 'Message', + new Blob([messageXml], { + type: 'text/xml; charset=UTF-8', + }), + ); + + const { data } = await axios.post(BASE_URL, body); + + await validateMedipostResponse(data); +} + +export async function getPrivateMessage(messageId: string) { + const { data } = await axios.get(BASE_URL, { + params: { + Action: MedipostAction.GetPrivateMessage, + User: USER, + Password: PASSWORD, + MessageId: messageId, + }, + headers: { + Accept: 'application/xml', + }, + }); + + await validateMedipostResponse(data, { canHaveEmptyCode: true }); + + return { + message: (await parseXML(data)) as MedipostOrderResponse, + xml: data as string, + }; +} + +export async function sendOrderToMedipost({ + medusaOrderId, + orderedAnalysisElements, +}: { + medusaOrderId: string; + orderedAnalysisElements: OrderedAnalysisElement[]; +}) { + const medreportOrder = await getAnalysisOrder({ medusaOrderId }); + const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); + + const orderedAnalysesIds = orderedAnalysisElements + .map(({ analysisId }) => analysisId) + .filter(Boolean) as number[]; + const orderedAnalysisElementsIds = orderedAnalysisElements + .map(({ analysisElementId }) => analysisElementId) + .filter(Boolean) as number[]; + + const analyses = await getAnalyses({ ids: orderedAnalysesIds }); + if (analyses.length !== orderedAnalysesIds.length) { + throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`); + } + const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); + if (analysisElements.length !== orderedAnalysisElementsIds.length) { + throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`); + } + + const orderXml = await composeOrderXML({ + analyses, + analysisElements, + person: { + idCode: account.personal_code!, + firstName: account.name ?? '', + lastName: account.last_name ?? '', + phone: account.phone ?? '', + }, + orderId: medreportOrder.id, + orderCreatedAt: new Date(medreportOrder.created_at), + comment: '', + }); + + try { + await sendPrivateMessage(orderXml); + } catch (e) { + const isMedipostError = e instanceof MedipostValidationError; + if (isMedipostError) { + await logMedipostDispatch({ + medusaOrderId, + isSuccess: false, + isMedipostError, + errorMessage: e.response, + }); + await upsertMedipostActionLog({ + action: 'send_order_to_medipost', + xml: orderXml, + hasAnalysisResults: false, + medusaOrderId, + responseXml: e.response, + hasError: true, + }); + } else { + await logMedipostDispatch({ + medusaOrderId, + isSuccess: false, + isMedipostError, + }); + await upsertMedipostActionLog({ + action: 'send_order_to_medipost', + xml: orderXml, + hasAnalysisResults: false, + medusaOrderId, + hasError: true, + }); + } + + throw e; + } + await logMedipostDispatch({ + medusaOrderId, + isSuccess: true, + isMedipostError: false, + }); + await upsertMedipostActionLog({ + action: 'send_order_to_medipost', + xml: orderXml, + hasAnalysisResults: false, + medusaOrderId, + }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); +} diff --git a/lib/services/medipost/medipostPublicMessage.service.ts b/lib/services/medipost/medipostPublicMessage.service.ts new file mode 100644 index 0000000..c6c823e --- /dev/null +++ b/lib/services/medipost/medipostPublicMessage.service.ts @@ -0,0 +1,33 @@ +'use server'; + +import { + GetMessageListResponse, + MedipostAction, +} from '@/lib/types/medipost'; +import axios from 'axios'; + +import { getLatestMessage } from './medipostMessageBase.service'; + +const BASE_URL = process.env.MEDIPOST_URL!; +const USER = process.env.MEDIPOST_USER!; +const PASSWORD = process.env.MEDIPOST_PASSWORD!; +const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; + +export async function getLatestPublicMessageListItem() { + const { data } = await axios.get(BASE_URL, { + params: { + Action: MedipostAction.GetPublicMessageList, + User: USER, + Password: PASSWORD, + Sender: RECIPIENT, + // LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created + // MessageType check only for messages of certain type + }, + }); + + if (data.code && data.code !== 0) { + throw new Error('Failed to get public message list'); + } + + return await getLatestMessage({ messages: data?.messages }); +} diff --git a/lib/services/medipostTest.service.ts b/lib/services/medipost/medipostTest.service.ts similarity index 97% rename from lib/services/medipostTest.service.ts rename to lib/services/medipost/medipostTest.service.ts index 4b1d1ca..a7ce270 100644 --- a/lib/services/medipostTest.service.ts +++ b/lib/services/medipost/medipostTest.service.ts @@ -16,9 +16,9 @@ import { uniqBy } from 'lodash'; import { Tables } from '@kit/supabase/database'; import { formatDate } from 'date-fns'; -import { getAnalyses } from './analyses.service'; -import { getAnalysisElementsAdmin } from './analysis-element.service'; -import { validateMedipostResponse } from './medipost.service'; +import { getAnalyses } from '../analyses.service'; +import { getAnalysisElementsAdmin } from '../analysis-element.service'; +import { validateMedipostResponse } from './medipostValidate.service'; const BASE_URL = process.env.MEDIPOST_URL!; const USER = process.env.MEDIPOST_USER!; diff --git a/lib/services/medipost/medipostValidate.service.ts b/lib/services/medipost/medipostValidate.service.ts new file mode 100644 index 0000000..aa85192 --- /dev/null +++ b/lib/services/medipost/medipostValidate.service.ts @@ -0,0 +1,25 @@ +'use server'; + +import type { + IMedipostResponseXMLBase, +} from '@/packages/shared/src/types/medipost-analysis'; + +import { MedipostValidationError } from './MedipostValidationError'; +import { parseXML } from '../util/xml.service'; + +export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) { + const parsed: IMedipostResponseXMLBase = await parseXML(response); + const code = parsed.ANSWER?.CODE; + if (canHaveEmptyCode) { + if (code && code !== 0) { + console.error("Bad response", response); + throw new MedipostValidationError(response); + } + return; + } + + if (typeof code !== 'number' || (code !== 0 && !canHaveEmptyCode)) { + console.error("Bad response", response); + throw new MedipostValidationError(response); + } +} diff --git a/lib/services/medipostXML.service.ts b/lib/services/medipost/medipostXML.service.ts similarity index 93% rename from lib/services/medipostXML.service.ts rename to lib/services/medipost/medipostXML.service.ts index 77e00eb..064aec5 100644 --- a/lib/services/medipostXML.service.ts +++ b/lib/services/medipost/medipostXML.service.ts @@ -18,8 +18,8 @@ import { toArray } from '@/lib/utils'; import { uniqBy } from 'lodash'; import { Tables } from '@kit/supabase/database'; -import { AnalysisElement } from './analysis-element.service'; -import { AnalysesWithGroupsAndElements } from './analyses.service'; +import { AnalysisElement } from '../analysis-element.service'; +import { AnalysesWithGroupsAndElements } from '../analyses.service'; const USER = process.env.MEDIPOST_USER!; const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; @@ -145,7 +145,6 @@ export async function composeOrderXML({ ); } - // Find the specimen order numbers for analysis elements const uuringElementInputs: { analysisElement: Tables<{ schema: 'medreport' }, 'analysis_elements'>, specimenOrderNr: number, @@ -156,6 +155,7 @@ export async function composeOrderXML({ for (const material of materials) { const uniqueMaterial = uniqueMaterials.get(material.MaterjaliTyyp); if (!uniqueMaterial) { + console.info(`Unique material not found for material: ${material.MaterjaliTyyp}, analysis element: ${analysisElement.id} ${analysisElement.analysis_id_original} ${analysisElement.analysis_name_lab}`); continue; } uuringElementInputs.push({ @@ -177,7 +177,7 @@ export async function composeOrderXML({ return ` - ${getPais(USER, RECIPIENT, orderId)} + ${getPais(USER, RECIPIENT, orderId, "OL")} ${orderId} ${getClientInstitution()} diff --git a/lib/services/medusaOrder.service.ts b/lib/services/medusaOrder.service.ts new file mode 100644 index 0000000..5b3396f --- /dev/null +++ b/lib/services/medusaOrder.service.ts @@ -0,0 +1,90 @@ +'use server'; + +import { getAnalysisElements } from './analysis-element.service'; +import { getAnalyses } from './analyses.service'; +import { StoreOrder } from '@medusajs/types'; +import { listProducts } from '@lib/data/products'; +import { listRegions } from '@lib/data/regions'; +import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; + +const ANALYSIS_PACKAGE_HANDLE_PREFIX = 'analysis-package-'; + +export async function getOrderedAnalysisIds({ + medusaOrder, +}: { + medusaOrder: StoreOrder; +}): Promise<{ + analysisElementId?: number; + analysisId?: number; +}[]> { + const countryCodes = await listRegions(); + const countryCode = countryCodes[0]!.countries![0]!.iso_2!; + + async function getOrderedAnalysisElements(medusaOrder: StoreOrder) { + const originalIds = (medusaOrder?.items ?? []) + .map((a) => a.product?.metadata?.analysisIdOriginal) + .filter((a) => typeof a === 'string') as string[]; + const analysisElements = await getAnalysisElements({ originalIds }); + return analysisElements.map(({ id }) => ({ analysisElementId: id })); + } + + async function getOrderedAnalyses(medusaOrder: StoreOrder) { + const originalIds = (medusaOrder?.items ?? []) + .map((a) => a.product?.metadata?.analysisIdOriginal) + .filter((a) => typeof a === 'string') as string[]; + const analyses = await getAnalyses({ originalIds }); + return analyses.map(({ id }) => ({ analysisId: id })); + } + + async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) { + const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); + const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[]; + if (orderedPackageIds.length === 0) { + return []; + } + console.info(`Order has ${orderedPackageIds.length} packages`); + const { response: { products: orderedPackagesProducts } } = await listProducts({ + countryCode, + queryParams: { limit: 100, id: orderedPackageIds }, + }); + console.info(`Order has ${orderedPackagesProducts.length} packages = ${JSON.stringify(orderedPackageIds, null, 2)}`); + if (orderedPackagesProducts.length !== orderedPackageIds.length) { + throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`); + } + + const ids = getAnalysisElementMedusaProductIds( + orderedPackagesProducts.map(({ id, metadata }) => ({ + metadata, + variant: orderedPackages.find(({ product }) => product?.id === id)?.variant, + })), + ); + if (ids.length === 0) { + return []; + } + const { response: { products: analysisPackagesProducts } } = await listProducts({ + countryCode, + queryParams: { limit: 100, id: ids }, + }); + if (analysisPackagesProducts.length !== ids.length) { + throw new Error(`Got ${analysisPackagesProducts.length} analysis packages products, expected ${ids.length}`); + } + + const originalIds = analysisPackagesProducts + .map(({ metadata }) => metadata?.analysisIdOriginal) + .filter((id) => typeof id === 'string'); + if (originalIds.length !== ids.length) { + throw new Error(`Got ${originalIds.length} analysis packages products with analysisIdOriginal, expected ${ids.length}`); + } + const analysisElements = await getAnalysisElements({ originalIds }); + + return analysisElements.map(({ id }) => ({ analysisElementId: id })); + } + + const [analysisPackageElements, orderedAnalysisElements, orderedAnalyses] = await Promise.all([ + getOrderedAnalysisPackages(medusaOrder), + getOrderedAnalysisElements(medusaOrder), + getOrderedAnalyses(medusaOrder), + ]); + + return [...analysisPackageElements, ...orderedAnalysisElements, ...orderedAnalyses]; +} diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index eced8f9..4e934fd 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -2,8 +2,7 @@ import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client' import type { Tables } from '@kit/supabase/database'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import type { StoreOrder } from '@medusajs/types'; - -export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; +import type { AnalysisOrder } from '../types/analysis-order'; export async function createAnalysisOrder({ medusaOrder, @@ -103,7 +102,7 @@ export async function getAnalysisOrder({ if (error) { throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`); } - return order; + return order as AnalysisOrder; } export async function getAnalysisOrders({ diff --git a/lib/services/util/xml.service.ts b/lib/services/util/xml.service.ts new file mode 100644 index 0000000..a9d156e --- /dev/null +++ b/lib/services/util/xml.service.ts @@ -0,0 +1,8 @@ +'use server'; + +import { XMLParser } from 'fast-xml-parser'; + +export async function parseXML(xml: string) { + const parser = new XMLParser({ ignoreAttributes: false }); + return parser.parse(xml); +} diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 9ca1f55..46a9e8a 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -9,11 +9,8 @@ export const getPais = ( sender: string, recipient: string, orderId: number, - packageName = "OL", + packageName: string, ) => { - if (isProd) { - // return correct data - } return ` ${packageName} ${sender} diff --git a/lib/types/analysis-order.ts b/lib/types/analysis-order.ts new file mode 100644 index 0000000..a7ce721 --- /dev/null +++ b/lib/types/analysis-order.ts @@ -0,0 +1,3 @@ +import type { Tables } from '@kit/supabase/database'; + +export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; diff --git a/lib/types/analysis-response-element.ts b/lib/types/analysis-response-element.ts new file mode 100644 index 0000000..db5b20f --- /dev/null +++ b/lib/types/analysis-response-element.ts @@ -0,0 +1,3 @@ +import type { Database } from "@/packages/supabase/src/database.types"; + +export type AnalysisResponseElement = Database['medreport']['Tables']['analysis_response_elements']['Row']; diff --git a/lib/types/medipost.ts b/lib/types/medipost.ts index 583fc33..b8228fb 100644 --- a/lib/types/medipost.ts +++ b/lib/types/medipost.ts @@ -1,11 +1,4 @@ -export interface IMedipostResponseXMLBase { - '?xml': { - '@_version': string; - '@_encoding': string; - '@_standalone': 'yes' | 'no'; - }; - ANSWER?: { CODE: number }; -} +import type { IMedipostResponseXMLBase } from "@/packages/shared/src/types/medipost-analysis"; export type Message = { messageId: string; @@ -143,125 +136,3 @@ export type MedipostPublicMessageResponse = IMedipostResponseXMLBase & { }; }; }; - -export type UuringuVastus = { - VastuseVaartus: number; // text according to docs, but based on examples and logically, float - VastuseAeg: string; - NormYlem?: { - '#text': number; - '@_kaasaarvatud': string; - }; // 0..1 - NormAlum?: { - '#text': number; - '@_kaasaarvatud': string; - }; - NormiStaatus: keyof typeof NormStatus; - ProoviJarjenumber: number; -}; - -export type ResponseUuring = { - UuringuElement: { - UuringIdOID: string; - UuringId: string; - TLyhend: string; - KNimetus: string; - UuringNimi: string; - TellijaUuringId: number; - TeostajaUuringId: string; - UuringOlek: keyof typeof AnalysisOrderStatus; - Mootyhik?: string; // 0..1 - Kood: { - HkKood: number; - HkKoodiKordaja: number; - Koefitsient: number; - Hind: number; - }; - UuringuVastus?: UuringuVastus | UuringuVastus[]; // 0..n - UuringuKommentaar?: string; - }; // 1..1 - UuringuTaitjaAsutuseJnr: number; -}; - -export type ResponseUuringuGrupp = { - UuringuGruppId: string; - UuringuGruppNimi: string; - Uuring: ResponseUuring | ResponseUuring[]; // 1..n -}; - -// type for UuringuGrupp is correct, but some of this is generated by an LLM and should be checked if data in use -export type MedipostOrderResponse = IMedipostResponseXMLBase & { - Saadetis: { - Pais: { - Pakett: { - '#text': string; - '@_versioon': string; - }; - Saatja: string; - Saaja: string; - Aeg: string; - SaadetisId: string; - Email: string; - }; - Vastus?: { - ValisTellimuseId: string; - Asutus: { - '@_tyyp': string; // TEOSTAJA - '@_jarjenumber': string; - AsutuseId: number; - AsutuseNimi: string; - AsutuseKood: string; - AllyksuseNimi?: string; - Telefon: number; - }[]; //@_tyyp = TELLIJA 1..1, @_tyyp = TEOSTAJA 1..n - Personal: { - '@_tyyp': string; - '@_jarjenumber': string; - PersonalOID: string; - PersonalKood: string; - PersonalPerekonnaNimi: string; - PersonalEesNimi: string; - Telefon: number; - }; - Patsient: { - IsikukoodiOID: string; - Isikukood: string; - PerekonnaNimi: string; - EesNimi: string; - SynniAeg: string; - SuguOID: string; - Sugu: string; - }; - Proov: Array<{ - ProovinouIdOID: string; - ProovinouId: string; - MaterjaliTyypOID: string; - MaterjaliTyyp: number; - MaterjaliNimi: string; - Ribakood: string; - Jarjenumber: number; - VotmisAeg: string; - SaabumisAeg: string; - }>; - TellimuseNumber: string; - TellimuseOlek: keyof typeof AnalysisOrderStatus; - UuringuGrupp?: ResponseUuringuGrupp | ResponseUuringuGrupp[]; - }; - Tellimus?: { - ValisTellimuseId: string; - } - }; -}; - -export const AnalysisOrderStatus = { - 1: 'QUEUED', - 2: 'ON_HOLD', - 3: 'PROCESSING', - 4: 'COMPLETED', - 5: 'REJECTED', - 6: 'CANCELLED', -} as const; -export const NormStatus: Record = { - 0: 'NORMAL', - 1: 'WARNING', - 2: 'REQUIRES_ATTENTION', -} as const; diff --git a/package.json b/package.json index 33ec639..c038bb1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "start": "next start", "start:test": "NODE_ENV=test next start", "typecheck": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:ci": "jest --ci --coverage --watchAll=false", "supabase": "supabase", "supabase:start": "supabase status || supabase start", "supabase:stop": "supabase stop", @@ -50,6 +54,7 @@ "@kit/supabase": "workspace:*", "@kit/team-accounts": "workspace:*", "@kit/ui": "workspace:*", + "@kit/user-analyses": "workspace:*", "@makerkit/data-loader-supabase-core": "^0.0.10", "@makerkit/data-loader-supabase-nextjs": "^1.2.5", "@marsidev/react-turnstile": "^1.1.0", @@ -87,6 +92,7 @@ }, "devDependencies": { "@hookform/resolvers": "^5.0.1", + "@jest/globals": "^30.1.2", "@kit/eslint-config": "workspace:*", "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*", @@ -94,6 +100,7 @@ "@medusajs/ui-preset": "latest", "@next/bundle-analyzer": "15.3.2", "@tailwindcss/postcss": "^4.1.10", + "@types/jest": "^30.0.0", "@types/jsonwebtoken": "9.0.10", "@types/lodash": "^4.17.17", "@types/node": "^22.15.32", @@ -102,11 +109,14 @@ "babel-plugin-react-compiler": "19.1.0-rc.2", "cssnano": "^7.0.7", "dotenv": "^16.5.0", + "jest": "^30.1.3", + "jest-environment-node": "^30.1.2", "pino-pretty": "13.0.0", "prettier": "^3.5.3", "supabase": "^2.30.4", "tailwindcss": "4.1.7", "tailwindcss-animate": "^1.0.7", + "ts-jest": "^29.4.2", "typescript": "^5.8.3", "yup": "^1.6.1" }, diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts index d1faaef..015e0f4 100644 --- a/packages/features/accounts/src/server/api.ts +++ b/packages/features/accounts/src/server/api.ts @@ -1,23 +1,9 @@ import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@kit/supabase/database'; - -import { AnalysisResultDetails, UserAnalysis } from '../types/accounts'; import PersonalCode from '~/lib/utils'; -export type AccountWithParams = - Database['medreport']['Tables']['accounts']['Row'] & { - accountParams: - | (Pick< - Database['medreport']['Tables']['account_params']['Row'], - 'weight' | 'height' - > & { - isSmoker: - | Database['medreport']['Tables']['account_params']['Row']['is_smoker'] - | null; - }) - | null; - }; +import { AccountWithParams } from '../types/accounts'; /** * Class representing an API for interacting with user accounts. @@ -25,7 +11,7 @@ export type AccountWithParams = * @param {SupabaseClient} client - The Supabase client instance. */ class AccountsApi { - constructor(private readonly client: SupabaseClient) {} + constructor(private readonly client: SupabaseClient) { } /** * @name getAccount @@ -218,89 +204,6 @@ class AccountsApi { return response.data?.customer_id; } - async getUserAnalysis( - analysisOrderId: number, - ): Promise { - const authUser = await this.client.auth.getUser(); - const { data, error: userError } = authUser; - - if (userError) { - console.error('Failed to get user', userError); - throw userError; - } - - const { user } = data; - - const { data: analysisResponse } = await this.client - .schema('medreport') - .from('analysis_responses') - .select( - `*, - elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time), - order:analysis_order_id(medusa_order_id, status, created_at), - summary:analysis_order_id(doctor_analysis_feedback(*))`, - ) - .eq('user_id', user.id) - .eq('analysis_order_id', analysisOrderId) - .throwOnError(); - - const responseWithElements = analysisResponse?.[0]; - if (!responseWithElements) { - return null; - } - - const feedback = responseWithElements.summary.doctor_analysis_feedback?.[0]; - - return { - ...responseWithElements, - summary: - feedback?.status === 'COMPLETED' - ? responseWithElements.summary.doctor_analysis_feedback?.[0] - : null, - }; - } - - async getUserAnalyses(): Promise { - const authUser = await this.client.auth.getUser(); - const { data, error: userError } = authUser; - - if (userError) { - console.error('Failed to get user', userError); - throw userError; - } - - const { user } = data; - - const { data: analysisResponses } = await this.client - .schema('medreport') - .from('analysis_responses') - .select('*') - .eq('user_id', user.id); - - if (!analysisResponses) { - return null; - } - - const analysisResponseIds = analysisResponses.map((r) => r.id); - - const { data: analysisResponseElements } = await this.client - .schema('medreport') - .from('analysis_response_elements') - .select('*') - .in('analysis_response_id', analysisResponseIds); - - if (!analysisResponseElements) { - return null; - } - - return analysisResponses.map((r) => ({ - ...r, - elements: analysisResponseElements.filter( - (e) => e.analysis_response_id === r.id, - ), - })); - } - async hasAccountTeamMembership(accountId?: string) { if (!accountId) { return false; @@ -318,23 +221,6 @@ class AccountsApi { return (count ?? 0) > 0; } - - async fetchBmiThresholds() { - // Fetch BMI - const { data, error } = await this.client - .schema('medreport') - .from('bmi_thresholds') - .select( - 'age_min,age_max,underweight_max,normal_min,normal_max,overweight_min,strong_min,obesity_min', - ) - .order('age_min', { ascending: true }); - - if (error) { - console.error('Error fetching BMI thresholds:', error); - throw error; - } - return data; - } } export function createAccountsApi(client: SupabaseClient) { diff --git a/packages/features/accounts/src/types/accounts.ts b/packages/features/accounts/src/types/accounts.ts index 86d51f3..7259cfb 100644 --- a/packages/features/accounts/src/types/accounts.ts +++ b/packages/features/accounts/src/types/accounts.ts @@ -1,15 +1,5 @@ -import * as z from 'zod'; - import { Database } from '@kit/supabase/database'; -export type UserAnalysisElement = - Database['medreport']['Tables']['analysis_response_elements']['Row']; -export type UserAnalysisResponse = - Database['medreport']['Tables']['analysis_responses']['Row'] & { - elements: UserAnalysisElement[]; - }; -export type UserAnalysis = UserAnalysisResponse[]; - export type ApplicationRole = Database['medreport']['Tables']['accounts']['Row']['application_role']; export enum ApplicationRoleEnum { @@ -18,50 +8,16 @@ export enum ApplicationRoleEnum { SuperAdmin = 'super_admin', } -export const ElementSchema = z.object({ - unit: z.string(), - norm_lower: z.number(), - norm_upper: z.number(), - norm_status: z.number(), - analysis_name: z.string(), - response_time: z.string(), - response_value: z.number(), - norm_lower_included: z.boolean(), - norm_upper_included: z.boolean(), -}); -export type Element = z.infer; - -export const OrderSchema = z.object({ - status: z.string(), - medusa_order_id: z.string(), - created_at: z.coerce.date(), -}); -export type Order = z.infer; - -export const SummarySchema = z.object({ - id: z.number(), - value: z.string(), - status: z.string(), - user_id: z.string(), - created_at: z.coerce.date(), - created_by: z.string(), - updated_at: z.coerce.date().nullable(), - updated_by: z.string(), - doctor_user_id: z.string().nullable(), - analysis_order_id: z.number(), -}); -export type Summary = z.infer; - -export const AnalysisResultDetailsSchema = z.object({ - id: z.number(), - analysis_order_id: z.number(), - order_number: z.string(), - order_status: z.string(), - user_id: z.string(), - created_at: z.coerce.date(), - updated_at: z.coerce.date().nullable(), - elements: z.array(ElementSchema), - order: OrderSchema, - summary: SummarySchema.nullable(), -}); -export type AnalysisResultDetails = z.infer; +export type AccountWithParams = + Database['medreport']['Tables']['accounts']['Row'] & { + accountParams: + | (Pick< + Database['medreport']['Tables']['account_params']['Row'], + 'weight' | 'height' + > & { + isSmoker: + | Database['medreport']['Tables']['account_params']['Row']['is_smoker'] + | null; + }) + | null; + }; diff --git a/packages/features/accounts/src/types/analysis-orders.ts b/packages/features/accounts/src/types/analysis-orders.ts new file mode 100644 index 0000000..4ef4027 --- /dev/null +++ b/packages/features/accounts/src/types/analysis-orders.ts @@ -0,0 +1,3 @@ +import { Tables } from '@kit/supabase/database'; + +export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; diff --git a/packages/features/accounts/src/types/analysis-results.ts b/packages/features/accounts/src/types/analysis-results.ts new file mode 100644 index 0000000..e2b5e66 --- /dev/null +++ b/packages/features/accounts/src/types/analysis-results.ts @@ -0,0 +1,139 @@ +import * as z from 'zod'; + +import { Database } from '@kit/supabase/database'; + +export type UserAnalysisElement = + Database['medreport']['Tables']['analysis_response_elements']['Row']; +export type UserAnalysisResponse = + Database['medreport']['Tables']['analysis_responses']['Row'] & { + elements: UserAnalysisElement[]; + }; +export type UserAnalysis = UserAnalysisResponse[]; + +const ElementSchema = z.object({ + unit: z.string(), + norm_lower: z.number(), + norm_upper: z.number(), + norm_status: z.number(), + analysis_name: z.string(), + response_time: z.string(), + response_value: z.number(), + response_value_is_negative: z.boolean(), + norm_lower_included: z.boolean(), + norm_upper_included: z.boolean(), + status: z.string(), + analysis_element_original_id: z.string(), + original_response_element: z.object({ + + }), +}); + +const OrderSchema = z.object({ + status: z.string(), + medusa_order_id: z.string(), + created_at: z.coerce.date(), +}); + +const DoctorAnalysisFeedbackSchema = z.object({ + id: z.number(), + status: z.string(), + user_id: z.string(), + created_at: z.coerce.date(), + created_by: z.string(), +}); + +const SummarySchema = z.object({ + id: z.number(), + value: z.string(), + status: z.string(), + user_id: z.string(), + created_at: z.coerce.date(), + created_by: z.string(), + updated_at: z.coerce.date().nullable(), + updated_by: z.string(), + doctor_user_id: z.string().nullable(), + analysis_order_id: z.number(), + doctor_analysis_feedback: z.array(DoctorAnalysisFeedbackSchema), +}); + +export const AnalysisResultDetailsSchema = z.object({ + id: z.number(), + analysis_order_id: z.number(), + order_number: z.string(), + order_status: z.string(), + user_id: z.string(), + created_at: z.coerce.date(), + updated_at: z.coerce.date().nullable(), + elements: z.array(ElementSchema), + order: OrderSchema, + summary: SummarySchema.nullable(), +}); +export type AnalysisResultDetails = z.infer; + +export type AnalysisResultDetailsElementResults = { + unit: string | null; + normLower: number | null; + normUpper: number | null; + normStatus: number | null; + responseTime: string | null; + responseValue: number | null; + responseValueIsNegative: boolean | null; + responseValueIsWithinNorm: boolean | null; + normLowerIncluded: boolean; + normUpperIncluded: boolean; + status: string; + analysisElementOriginalId: string; + nestedElements: { + analysisElementOriginalId: string; + normLower?: number | null; + normLowerIncluded: boolean; + normStatus: number; + normUpper?: number | null; + normUpperIncluded: boolean; + responseTime: string; + responseValue: number; + status: number; + unit: string; + }[]; + labComment?: string | null; +}; + +export type AnalysisResultDetailsElement = { + analysisIdOriginal: string; + isWaitingForResults: boolean; + analysisName: string; + results: AnalysisResultDetailsElementResults; +}; + +export type AnalysisResultDetailsMapped = { + id: number; + order: { + status: string; + medusaOrderId: string; + createdAt: Date | string; + }; + elements: { + id: string; + unit: string; + norm_lower: number; + norm_upper: number; + norm_status: number; + analysis_name: string; + response_time: string; + response_value: number; + norm_lower_included: boolean; + norm_upper_included: boolean; + status: string; + analysis_element_original_id: string; + }[]; + orderedAnalysisElementIds: number[]; + orderedAnalysisElements: AnalysisResultDetailsElement[]; + summary: { + id: number; + status: string; + user_id: string; + created_at: Date; + created_by: string; + value?: string; + } | null; +}; diff --git a/packages/features/user-analyses/eslint.config.mjs b/packages/features/user-analyses/eslint.config.mjs new file mode 100644 index 0000000..97563ae --- /dev/null +++ b/packages/features/user-analyses/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@kit/eslint-config/base.js'; + +export default eslintConfigBase; diff --git a/packages/features/user-analyses/package.json b/packages/features/user-analyses/package.json new file mode 100644 index 0000000..5d40175 --- /dev/null +++ b/packages/features/user-analyses/package.json @@ -0,0 +1,33 @@ +{ + "name": "@kit/user-analyses", + "private": true, + "version": "0.1.0", + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "format": "prettier --check \"**/*.{ts,tsx}\"", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "exports": { + "./api": "./src/server/api.ts", + "./types/*": "./src/types/*.ts" + }, + "dependencies": { + "nanoid": "^5.1.5" + }, + "devDependencies": { + "@kit/eslint-config": "workspace:*", + "@kit/prettier-config": "workspace:*", + "@kit/shared": "workspace:*", + "@kit/supabase": "workspace:*", + "@kit/tsconfig": "workspace:*" + }, + "prettier": "@kit/prettier-config", + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + } +} diff --git a/packages/features/user-analyses/src/server/api.ts b/packages/features/user-analyses/src/server/api.ts new file mode 100644 index 0000000..e44faec --- /dev/null +++ b/packages/features/user-analyses/src/server/api.ts @@ -0,0 +1,312 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { Database } from '@kit/supabase/database'; +import type { UuringElement, UuringuVastus } from '@kit/shared/types/medipost-analysis'; + +import type { AnalysisResultDetails, AnalysisResultDetailsMapped, UserAnalysis } from '../types/analysis-results'; +import type { AnalysisOrder } from '../types/analysis-orders'; + +/** + * Class representing an API for interacting with user accounts. + * @constructor + * @param {SupabaseClient} client - The Supabase client instance. + */ +class UserAnalysesApi { + constructor(private readonly client: SupabaseClient) { } + + async getAnalysisOrder({ + medusaOrderId, + analysisOrderId, + }: { + medusaOrderId?: string; + analysisOrderId?: number; + }) { + const query = this.client + .schema('medreport') + .from('analysis_orders') + .select('*') + if (medusaOrderId) { + query.eq('medusa_order_id', medusaOrderId); + } else if (analysisOrderId) { + query.eq('id', analysisOrderId); + } else { + throw new Error('Either medusaOrderId or orderId must be provided'); + } + + const { data: order, error } = await query.single(); + if (error) { + throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`); + } + return order as AnalysisOrder; + } + + async getUserAnalysis( + analysisOrderId: number, + ): Promise { + const authUser = await this.client.auth.getUser(); + const { data, error: userError } = authUser; + + if (userError) { + console.error('Failed to get user', userError); + throw userError; + } + + const { user } = data; + + const analysisOrder = await this.getAnalysisOrder({ analysisOrderId }); + const orderedAnalysisElementIds = analysisOrder.analysis_element_ids ?? []; + if (orderedAnalysisElementIds.length === 0) { + console.error('No ordered analysis element ids found for analysis order id=', analysisOrderId); + return null; + } + const { data: orderedAnalysisElements, error: orderedAnalysisElementsError } = await this.client + .schema('medreport') + .from('analysis_elements') + .select('analysis_id_original,analysis_name_lab') + .in('id', orderedAnalysisElementIds); + if (orderedAnalysisElementsError) { + console.error('Failed to get ordered analysis elements for analysis order id=', analysisOrderId, orderedAnalysisElementsError); + throw orderedAnalysisElementsError; + } + + const orderedAnalysisElementOriginalIds = orderedAnalysisElements.map(({ analysis_id_original }) => analysis_id_original); + if (orderedAnalysisElementOriginalIds.length === 0) { + console.error('No ordered analysis element original ids found for analysis order id=', analysisOrderId); + return null; + } + + const { data: analysisResponse } = await this.client + .schema('medreport') + .from('analysis_responses') + .select( + `*, + elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time,status,analysis_element_original_id,original_response_element,response_value_is_negative,response_value_is_within_norm), + summary:analysis_order_id(doctor_analysis_feedback(*))`, + ) + .eq('user_id', user.id) + .eq('analysis_order_id', analysisOrderId) + .throwOnError(); + + const responseWithElements = analysisResponse?.[0] as AnalysisResultDetails | null; + if (!responseWithElements) { + return null; + } + + const analysisResponseElements = responseWithElements.elements; + + const feedback = responseWithElements.summary?.doctor_analysis_feedback?.[0]; + + const mappedOrderedAnalysisElements = orderedAnalysisElements.map(({ analysis_id_original, analysis_name_lab }) => { + return this.getOrderedAnalysisElements({ + analysisIdOriginal: analysis_id_original, + analysisNameLab: analysis_name_lab, + analysisResponseElements, + }); + }).sort((a, b) => a.analysisName.localeCompare(b.analysisName)); + const nestedAnalysisElementIds = mappedOrderedAnalysisElements.map(({ results }) => results?.nestedElements.map(({ analysisElementOriginalId }) => analysisElementOriginalId)).flat().filter(Boolean); + if (nestedAnalysisElementIds.length > 0) { + const { data: nestedAnalysisElements, error: nestedAnalysisElementsError } = await this.client + .schema('medreport') + .from('analysis_elements') + .select('*') + .in('id', nestedAnalysisElementIds); + if (!nestedAnalysisElementsError && nestedAnalysisElements) { + for (const mappedOrderedAnalysisElement of mappedOrderedAnalysisElements) { + const { results } = mappedOrderedAnalysisElement; + if (!results) { + continue; + } + for (const nestedElement of results.nestedElements) { + const { analysisElementOriginalId } = nestedElement; + const nestedAnalysisElement = nestedAnalysisElements.find(({ id }) => id === analysisElementOriginalId); + if (!nestedAnalysisElement) { + continue; + } + results.nestedElements.push({ + ...nestedAnalysisElement, + analysisElementOriginalId, + analysisName: nestedAnalysisElement.analysis_name_lab, + }); + } + } + + mappedOrderedAnalysisElements.forEach(({ results }) => { + results?.nestedElements.forEach(({ analysisElementOriginalId }) => { + const nestedAnalysisElement = nestedAnalysisElements.find(({ id }) => id === analysisElementOriginalId); + if (nestedAnalysisElement) { + results?.nestedElements.push({ + ...nestedAnalysisElement, + analysisElementOriginalId, + analysisName: nestedAnalysisElement.analysis_name_lab, + }); + } + }); + }); + } + } + + return { + id: analysisOrderId, + order: { + status: analysisOrder.status, + medusaOrderId: analysisOrder.medusa_order_id, + createdAt: new Date(analysisOrder.created_at), + }, + orderedAnalysisElementIds, + orderedAnalysisElements: mappedOrderedAnalysisElements, + summary: + feedback?.status === 'COMPLETED' + ? (responseWithElements.summary?.doctor_analysis_feedback?.[0] ?? null) + : null, + }; + } + + getOrderedAnalysisElements({ + analysisIdOriginal, + analysisNameLab, + analysisResponseElements, + }: { + analysisIdOriginal: string; + analysisNameLab: string; + analysisResponseElements: AnalysisResultDetails['elements']; + }) { + const elementResponse = analysisResponseElements.find((element) => element.analysis_element_original_id === analysisIdOriginal); + if (!elementResponse) { + return { + analysisIdOriginal, + isWaitingForResults: true, + analysisName: analysisNameLab, + }; + } + const labComment = elementResponse.original_response_element?.UuringuKommentaar; + return { + analysisIdOriginal, + isWaitingForResults: false, + analysisName: analysisNameLab, + results: { + nestedElements: (() => { + const nestedElements = elementResponse.original_response_element?.UuringuElement as UuringElement[] | undefined; + if (!nestedElements) { + return []; + } + return nestedElements.map((element) => { + const elementVastus = element.UuringuVastus as UuringuVastus | undefined; + const responseValue = elementVastus?.VastuseVaartus; + const responseValueIsNumeric = !isNaN(Number(responseValue)); + const responseValueIsNegative = responseValue === 'Negatiivne'; + const responseValueIsWithinNorm = responseValue === 'Normi piires'; + return { + status: element.UuringOlek, + unit: element.Mootyhik, + normLower: elementVastus?.NormAlum?.['#text'], + normUpper: elementVastus?.NormYlem?.['#text'], + normStatus: elementVastus?.NormiStaatus, + responseTime: elementVastus?.VastuseAeg, + response_value: responseValueIsNegative || !responseValueIsNumeric ? null : (responseValue ?? null), + response_value_is_negative: responseValueIsNumeric ? null : responseValueIsNegative, + response_value_is_within_norm: responseValueIsNumeric ? null : responseValueIsWithinNorm, + normLowerIncluded: elementVastus?.NormAlum?.['@_kaasaarvatud'] === 'JAH', + normUpperIncluded: elementVastus?.NormYlem?.['@_kaasaarvatud'] === 'JAH', + analysisElementOriginalId: element.UuringId, + }; + }); + })(), + labComment, + //originalResponseElement: elementResponse.original_response_element ?? null, + unit: elementResponse.unit, + normLower: elementResponse.norm_lower, + normUpper: elementResponse.norm_upper, + normStatus: elementResponse.norm_status, + responseTime: elementResponse.response_time, + responseValue: elementResponse.response_value, + responseValueIsNegative: elementResponse.response_value_is_negative === true, + responseValueIsWithinNorm: elementResponse.response_value_is_within_norm === true, + normLowerIncluded: elementResponse.norm_lower_included, + normUpperIncluded: elementResponse.norm_upper_included, + status: elementResponse.status, + analysisElementOriginalId: elementResponse.analysis_element_original_id, + } + }; + } + + // @TODO unused currently + async getUserAnalyses(): Promise { + const authUser = await this.client.auth.getUser(); + const { data, error: userError } = authUser; + + if (userError) { + console.error('Failed to get user', userError); + throw userError; + } + + const { user } = data; + + const { data: analysisResponses } = await this.client + .schema('medreport') + .from('analysis_responses') + .select('*') + .eq('user_id', user.id); + + if (!analysisResponses) { + return null; + } + + const analysisResponseIds = analysisResponses.map((r) => r.id); + + const { data: analysisResponseElements } = await this.client + .schema('medreport') + .from('analysis_response_elements') + .select('*') + .in('analysis_response_id', analysisResponseIds); + + if (!analysisResponseElements) { + return null; + } + + return analysisResponses.map((r) => ({ + ...r, + elements: analysisResponseElements.filter( + (e) => e.analysis_response_id === r.id, + ), + })); + } + + async hasAccountTeamMembership(accountId?: string) { + if (!accountId) { + return false; + } + + const { count, error } = await this.client + .schema('medreport') + .from('accounts_memberships') + .select('account_id', { count: 'exact', head: true }) + .eq('account_id', accountId); + + if (error) { + throw error; + } + + return (count ?? 0) > 0; + } + + async fetchBmiThresholds() { + // Fetch BMI + const { data, error } = await this.client + .schema('medreport') + .from('bmi_thresholds') + .select( + 'age_min,age_max,underweight_max,normal_min,normal_max,overweight_min,strong_min,obesity_min', + ) + .order('age_min', { ascending: true }); + + if (error) { + console.error('Error fetching BMI thresholds:', error); + throw error; + } + return data; + } +} + +export function createUserAnalysesApi(client: SupabaseClient) { + return new UserAnalysesApi(client); +} diff --git a/packages/features/user-analyses/src/types/analysis-orders.ts b/packages/features/user-analyses/src/types/analysis-orders.ts new file mode 100644 index 0000000..4ef4027 --- /dev/null +++ b/packages/features/user-analyses/src/types/analysis-orders.ts @@ -0,0 +1,3 @@ +import { Tables } from '@kit/supabase/database'; + +export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; diff --git a/packages/features/user-analyses/src/types/analysis-results.ts b/packages/features/user-analyses/src/types/analysis-results.ts new file mode 100644 index 0000000..3176499 --- /dev/null +++ b/packages/features/user-analyses/src/types/analysis-results.ts @@ -0,0 +1,140 @@ +import * as z from 'zod'; + +import { Database } from '@kit/supabase/database'; + +export type UserAnalysisElement = + Database['medreport']['Tables']['analysis_response_elements']['Row']; +export type UserAnalysisResponse = + Database['medreport']['Tables']['analysis_responses']['Row'] & { + elements: UserAnalysisElement[]; + }; +export type UserAnalysis = UserAnalysisResponse[]; + +const ElementSchema = z.object({ + unit: z.string(), + norm_lower: z.number(), + norm_upper: z.number(), + norm_status: z.number(), + analysis_name: z.string(), + response_time: z.string(), + response_value: z.number(), + response_value_is_negative: z.boolean(), + response_value_is_within_norm: z.boolean(), + norm_lower_included: z.boolean(), + norm_upper_included: z.boolean(), + status: z.string(), + analysis_element_original_id: z.string(), + original_response_element: z.object({ + + }), +}); + +const OrderSchema = z.object({ + status: z.string(), + medusa_order_id: z.string(), + created_at: z.coerce.date(), +}); + +const DoctorAnalysisFeedbackSchema = z.object({ + id: z.number(), + status: z.string(), + user_id: z.string(), + created_at: z.coerce.date(), + created_by: z.string(), +}); + +const SummarySchema = z.object({ + id: z.number(), + value: z.string(), + status: z.string(), + user_id: z.string(), + created_at: z.coerce.date(), + created_by: z.string(), + updated_at: z.coerce.date().nullable(), + updated_by: z.string(), + doctor_user_id: z.string().nullable(), + analysis_order_id: z.number(), + doctor_analysis_feedback: z.array(DoctorAnalysisFeedbackSchema), +}); + +export const AnalysisResultDetailsSchema = z.object({ + id: z.number(), + analysis_order_id: z.number(), + order_number: z.string(), + order_status: z.string(), + user_id: z.string(), + created_at: z.coerce.date(), + updated_at: z.coerce.date().nullable(), + elements: z.array(ElementSchema), + order: OrderSchema, + summary: SummarySchema.nullable(), +}); +export type AnalysisResultDetails = z.infer; + +export type AnalysisResultDetailsElementResults = { + unit: string | null; + normLower: number | null; + normUpper: number | null; + normStatus: number | null; + responseTime: string | null; + responseValue: number | null; + responseValueIsNegative: boolean | null; + responseValueIsWithinNorm: boolean | null; + normLowerIncluded: boolean; + normUpperIncluded: boolean; + status: string; + analysisElementOriginalId: string; + nestedElements: { + analysisElementOriginalId: string; + normLower?: number | null; + normLowerIncluded: boolean; + normStatus: number; + normUpper?: number | null; + normUpperIncluded: boolean; + responseTime: string; + responseValue: number; + status: number; + unit: string; + }[]; + labComment?: string | null; +}; + +export type AnalysisResultDetailsElement = { + analysisIdOriginal: string; + isWaitingForResults: boolean; + analysisName: string; + results: AnalysisResultDetailsElementResults; +}; + +export type AnalysisResultDetailsMapped = { + id: number; + order: { + status: string; + medusaOrderId: string; + createdAt: Date | string; + }; + elements: { + id: string; + unit: string; + norm_lower: number; + norm_upper: number; + norm_status: number; + analysis_name: string; + response_time: string; + response_value: number; + norm_lower_included: boolean; + norm_upper_included: boolean; + status: string; + analysis_element_original_id: string; + }[]; + orderedAnalysisElementIds: number[]; + orderedAnalysisElements: AnalysisResultDetailsElement[]; + summary: { + id: number; + status: string; + user_id: string; + created_at: Date; + created_by: string; + value?: string; + } | null; +}; diff --git a/packages/features/user-analyses/tsconfig.json b/packages/features/user-analyses/tsconfig.json new file mode 100644 index 0000000..8d5bae9 --- /dev/null +++ b/packages/features/user-analyses/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@kit/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "paths": { + "~/lib/utils": ["../../../lib/utils.ts"] + } + }, + "include": ["*.ts", "*.tsx", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 27fc7b2..a0fc483 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,7 +16,8 @@ "./events": "./src/events/index.tsx", "./components/*": "./src/components/*.tsx", "./registry": "./src/registry/index.ts", - "./config": "./src/config/index.ts" + "./config": "./src/config/index.ts", + "./types/*": "./src/types/*.ts" }, "devDependencies": { "@kit/eslint-config": "workspace:*", diff --git a/packages/shared/src/components/select-analysis-package.tsx b/packages/shared/src/components/select-analysis-package.tsx index f46d2bc..83ce3ec 100644 --- a/packages/shared/src/components/select-analysis-package.tsx +++ b/packages/shared/src/components/select-analysis-package.tsx @@ -87,7 +87,7 @@ export default function SelectAnalysisPackage({ }; return ( - + {description && ( - + {subtitle} - +