Compare commits
11 Commits
5ef7f58f5d
...
testing-11
| Author | SHA1 | Date | |
|---|---|---|---|
| d643788919 | |||
| 96fbb71c87 | |||
| bf5546c48b | |||
| 44ca92ac91 | |||
| 1b17dd845a | |||
| 2c0634f444 | |||
| 5757c44e12 | |||
| 487d604e19 | |||
| f7fbbd2352 | |||
| a77e2a7f70 | |||
| b216f7b211 |
@@ -14,9 +14,9 @@ export default async function sendOpenJobsEmails() {
|
||||
const doctorAccounts = await getDoctorAccounts();
|
||||
const doctorEmails = doctorAccounts
|
||||
.map(({ email }) => email)
|
||||
.filter((email) => !!email);
|
||||
.filter((email): email is string => !!email);
|
||||
|
||||
if (doctorEmails !== null) {
|
||||
if (doctorEmails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,14 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
try {
|
||||
const doctors = await sendOpenJobsEmails();
|
||||
const doctorIds = doctors?.join(', ') ?? '-';
|
||||
console.info(
|
||||
'Successfully sent out open job notification emails to doctors',
|
||||
`Successfully sent out open job notification emails to doctorIds: ${doctorIds}`,
|
||||
);
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.DOCTOR_NEW_JOBS,
|
||||
status: 'SUCCESS',
|
||||
comment: `doctors that received email: ${doctors}`,
|
||||
comment: `doctors that received email: ${doctorIds}`,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -35,7 +36,7 @@ export const POST = async (request: NextRequest) => {
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Error sending out open job notification emails to doctors.',
|
||||
e,
|
||||
@@ -43,7 +44,7 @@ export const POST = async (request: NextRequest) => {
|
||||
await createNotificationLog({
|
||||
action: NotificationAction.DOCTOR_NEW_JOBS,
|
||||
status: 'FAIL',
|
||||
comment: e?.message,
|
||||
comment: e instanceof Error ? e.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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';
|
||||
|
||||
@@ -10,29 +9,24 @@ import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { AnalysisElement } from '~/lib/services/analysis-element.service';
|
||||
import { NestedAnalysisElement } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
|
||||
|
||||
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,
|
||||
}
|
||||
export type AnalysisResultForDisplay = {
|
||||
norm_status?: number | null;
|
||||
response_value?: number | null;
|
||||
unit?: string | null;
|
||||
norm_lower_included?: boolean | null;
|
||||
norm_upper_included?: boolean | null;
|
||||
norm_lower?: number | null;
|
||||
norm_upper?: number | null;
|
||||
response_time?: string | null;
|
||||
nestedElements?: NestedAnalysisElement[];
|
||||
};
|
||||
|
||||
const AnalysisDoctor = ({
|
||||
analysisElement,
|
||||
@@ -48,38 +42,41 @@ const AnalysisDoctor = ({
|
||||
endIcon?: ReactNode | null;
|
||||
}) => {
|
||||
const name = analysisElement.analysis_name_lab || '';
|
||||
const status = results?.norm_status || AnalysisStatus.NORMAL;
|
||||
const status = results?.norm_status;
|
||||
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 normLower = results?.norm_lower ?? null;
|
||||
const normUpper = results?.norm_upper ?? null;
|
||||
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const analysisResultLevel = useMemo(() => {
|
||||
if (!results) {
|
||||
if (!results || status === null || status === undefined) {
|
||||
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;
|
||||
case 1:
|
||||
return AnalysisResultLevel.WARNING;
|
||||
case 2:
|
||||
return AnalysisResultLevel.CRITICAL;
|
||||
case 0:
|
||||
default:
|
||||
return AnalysisResultLevel.NORMAL;
|
||||
}
|
||||
}, [results, value, normLower]);
|
||||
}, [results, status]);
|
||||
|
||||
const normRangeText = useMemo(() => {
|
||||
if (normLower === null && normUpper === null) {
|
||||
return null;
|
||||
}
|
||||
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
|
||||
}, [normLower, normUpper]);
|
||||
|
||||
const nestedElements = results?.nestedElements ?? null;
|
||||
const hasNestedElements =
|
||||
Array.isArray(nestedElements) && nestedElements.length > 0;
|
||||
|
||||
const isAnalysisLevelBarHidden = isCancelled || !results || hasNestedElements;
|
||||
|
||||
return (
|
||||
<div className="border-border rounded-lg border px-5">
|
||||
@@ -110,27 +107,41 @@ const AnalysisDoctor = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{results ? (
|
||||
{isAnalysisLevelBarHidden ? null : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 sm:ml-auto">
|
||||
<div className="font-semibold">{value}</div>
|
||||
<div className="text-muted-foreground text-sm">{unit}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
|
||||
{normLower} - {normUpper}
|
||||
{normRangeText}
|
||||
<div>
|
||||
<Trans i18nKey="analysis-results:results.range.normal" />
|
||||
</div>
|
||||
</div>
|
||||
<AnalysisLevelBar
|
||||
results={results}
|
||||
normLowerIncluded={normLowerIncluded}
|
||||
normUpperIncluded={normUpperIncluded}
|
||||
level={analysisResultLevel!}
|
||||
level={analysisResultLevel}
|
||||
normRangeText={normRangeText}
|
||||
/>
|
||||
{endIcon || <div className="mx-2 w-4" />}
|
||||
</>
|
||||
) : isCancelled ? null : (
|
||||
)}
|
||||
{(() => {
|
||||
// If parent has nested elements, don't show anything
|
||||
if (hasNestedElements) {
|
||||
return null;
|
||||
}
|
||||
// If we're showing the level bar, don't show waiting
|
||||
if (!isAnalysisLevelBarHidden) {
|
||||
return null;
|
||||
}
|
||||
// If cancelled, don't show waiting
|
||||
if (isCancelled) {
|
||||
return null;
|
||||
}
|
||||
// Otherwise, show waiting for results
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 sm:ml-auto">
|
||||
<div className="font-semibold">
|
||||
@@ -140,7 +151,8 @@ const AnalysisDoctor = ({
|
||||
<div className="mx-8 w-[60px]"></div>
|
||||
<AnalysisLevelBarSkeleton />
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,9 @@ 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,
|
||||
NORMAL = 'NORMAL',
|
||||
WARNING = 'WARNING',
|
||||
CRITICAL = 'CRITICAL',
|
||||
}
|
||||
|
||||
const Level = ({
|
||||
@@ -20,17 +18,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 (
|
||||
<div
|
||||
className={cn(`bg-${color} relative h-3 flex-1`, {
|
||||
'opacity-20': !isActive,
|
||||
'opacity-60': !isActive,
|
||||
'rounded-l-lg': isFirst,
|
||||
'rounded-r-lg': isLast,
|
||||
})}
|
||||
@@ -38,11 +38,32 @@ const Level = ({
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
|
||||
style={{ left: `${arrowLocation}%` }}
|
||||
{...(arrowLocation
|
||||
? {
|
||||
style: {
|
||||
left: `${arrowLocation}%`,
|
||||
...(arrowLocation > 92.5 && { left: '92.5%' }),
|
||||
...(arrowLocation < 7.5 && { left: '7.5%' }),
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<ArrowDown strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{color === 'success' && typeof normRangeText === 'string' && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
|
||||
{
|
||||
'opacity-60': isActive,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{normRangeText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -50,81 +71,148 @@ const Level = ({
|
||||
export const AnalysisLevelBarSkeleton = () => {
|
||||
return (
|
||||
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
|
||||
<Level color="gray-200" />
|
||||
<Level color="gray-200" isFirst isLast />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AnalysisLevelBar = ({
|
||||
normLowerIncluded = true,
|
||||
normUpperIncluded = true,
|
||||
level,
|
||||
results,
|
||||
normRangeText,
|
||||
}: {
|
||||
normLowerIncluded?: boolean;
|
||||
normUpperIncluded?: boolean;
|
||||
level: AnalysisResultLevel;
|
||||
level: AnalysisResultLevel | null;
|
||||
results: AnalysisResultForDisplay;
|
||||
normRangeText: string | null;
|
||||
}) => {
|
||||
const {
|
||||
norm_lower: lower,
|
||||
norm_upper: upper,
|
||||
response_value: value,
|
||||
} = results;
|
||||
const arrowLocation = useMemo(() => {
|
||||
if (value < lower!) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normLowerIncluded || normUpperIncluded) {
|
||||
// Calculate arrow position based on value within normal range
|
||||
const arrowLocation = useMemo(() => {
|
||||
// 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;
|
||||
// Show appropriate levels based on available norm bounds
|
||||
const hasLowerBound = lower !== null;
|
||||
|
||||
// Calculate level configuration (must be called before any returns)
|
||||
const [first, second, third] = useMemo(() => {
|
||||
const [warning, normal, critical] = [
|
||||
{
|
||||
isActive: isWarning,
|
||||
color: 'warning',
|
||||
...(isWarning ? { arrowLocation } : {}),
|
||||
},
|
||||
{
|
||||
isActive: isNormal,
|
||||
color: 'success',
|
||||
normRangeText,
|
||||
...(isNormal ? { arrowLocation } : {}),
|
||||
},
|
||||
{
|
||||
isActive: isCritical,
|
||||
color: 'destructive',
|
||||
isLast: true,
|
||||
...(isCritical ? { arrowLocation } : {}),
|
||||
},
|
||||
] as const;
|
||||
|
||||
if (!hasLowerBound) {
|
||||
return [{ ...normal, isFirst: true }, warning, critical] as const;
|
||||
}
|
||||
|
||||
return [
|
||||
{ ...warning, isFirst: true },
|
||||
normal,
|
||||
{ ...critical, isLast: true },
|
||||
] as const;
|
||||
}, [
|
||||
arrowLocation,
|
||||
normRangeText,
|
||||
isNormal,
|
||||
isWarning,
|
||||
isCritical,
|
||||
hasLowerBound,
|
||||
]);
|
||||
|
||||
// If pending results, show gray bar
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
|
||||
<Level color="gray-200" isFirst isLast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
|
||||
{normLowerIncluded && (
|
||||
<>
|
||||
<Level isActive={isVeryLow} color="destructive" isFirst />
|
||||
<Level isActive={isLow} color="warning" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Level
|
||||
isFirst={!normLowerIncluded}
|
||||
isLast={!normUpperIncluded}
|
||||
{...(hasAbnormalLevel
|
||||
? { color: 'warning', isActive: false }
|
||||
: { color: 'success', isActive: true })}
|
||||
arrowLocation={arrowLocation}
|
||||
/>
|
||||
|
||||
{normUpperIncluded && (
|
||||
<>
|
||||
<Level isActive={isHigh} color="warning" />
|
||||
<Level isActive={isVeryHigh} color="destructive" isLast />
|
||||
</>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-3 gap-1',
|
||||
'mt-4 sm:mt-0',
|
||||
'w-[60%] sm:w-[35%]',
|
||||
'min-w-[50vw] sm:min-w-auto',
|
||||
'max-w-[360px]',
|
||||
)}
|
||||
>
|
||||
<Level {...first} />
|
||||
<Level {...second} />
|
||||
<Level {...third} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -23,6 +25,7 @@ export default function DoctorAnalysisWrapper({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapsible className="w-full" key={analysisData.id}>
|
||||
<CollapsibleTrigger
|
||||
disabled={!analysisData.latestPreviousAnalysis}
|
||||
@@ -64,7 +67,7 @@ export default function DoctorAnalysisWrapper({
|
||||
</CollapsibleTrigger>
|
||||
{analysisData.latestPreviousAnalysis && (
|
||||
<CollapsibleContent>
|
||||
<div className="my-1 flex flex-col">
|
||||
<div className="my-1 flex flex-col gap-2">
|
||||
<AnalysisDoctor
|
||||
endIcon={
|
||||
analysisData.latestPreviousAnalysis.comment && (
|
||||
@@ -89,15 +92,62 @@ export default function DoctorAnalysisWrapper({
|
||||
analysisElement={{
|
||||
analysis_name_lab: t('doctor:previousResults', {
|
||||
date: formatDate(
|
||||
analysisData.latestPreviousAnalysis.response_time,
|
||||
analysisData.latestPreviousAnalysis.response_time!,
|
||||
),
|
||||
}),
|
||||
}}
|
||||
results={analysisData.latestPreviousAnalysis}
|
||||
/>
|
||||
{analysisData.latestPreviousAnalysis.nestedElements?.map(
|
||||
(nestedElement, nestedIndex) => (
|
||||
<div
|
||||
key={`prev-nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
|
||||
className="ml-8"
|
||||
>
|
||||
<AnalysisDoctor
|
||||
analysisElement={{
|
||||
analysis_name_lab: nestedElement.analysisNameLab ?? '',
|
||||
}}
|
||||
results={{
|
||||
norm_status: nestedElement.normStatus,
|
||||
response_value: nestedElement.responseValue,
|
||||
unit: nestedElement.unit,
|
||||
norm_lower: nestedElement.normLower,
|
||||
norm_upper: nestedElement.normUpper,
|
||||
norm_lower_included: nestedElement.normLowerIncluded,
|
||||
norm_upper_included: nestedElement.normUpperIncluded,
|
||||
response_time: nestedElement.responseTime,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
{analysisData.nestedElements?.map((nestedElement, nestedIndex) => (
|
||||
<div
|
||||
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
|
||||
className="ml-8"
|
||||
>
|
||||
<AnalysisDoctor
|
||||
analysisElement={{
|
||||
analysis_name_lab: nestedElement.analysisNameLab ?? '',
|
||||
}}
|
||||
results={{
|
||||
norm_status: nestedElement.normStatus,
|
||||
response_value: nestedElement.responseValue,
|
||||
unit: nestedElement.unit,
|
||||
norm_lower: nestedElement.normLower,
|
||||
norm_upper: nestedElement.normUpper,
|
||||
norm_lower_included: nestedElement.normLowerIncluded,
|
||||
norm_upper_included: nestedElement.normUpperIncluded,
|
||||
response_time: nestedElement.responseTime,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function ResultsTable({
|
||||
<TableCell>
|
||||
<Trans
|
||||
i18nKey={
|
||||
resultsReceived === elementsInOrder
|
||||
resultsReceived >= elementsInOrder
|
||||
? 'doctor:resultsTable.responsesReceived'
|
||||
: 'doctor:resultsTable.waitingForNr'
|
||||
}
|
||||
|
||||
12
lib/services/analysis-response.service.ts
Normal file
12
lib/services/analysis-response.service.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
export async function getAnalysisResponseAdmin(analysisOrderId: number) {
|
||||
const { data } = await getSupabaseServerAdminClient()
|
||||
.schema('medreport')
|
||||
.from('analysis_responses')
|
||||
.select('*')
|
||||
.eq('analysis_order_id', analysisOrderId)
|
||||
.single()
|
||||
.throwOnError();
|
||||
return data;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { createBillingEventHandlerService } from './billing-event-handler.servic
|
||||
type ClientProvider = () => SupabaseClient<Database>;
|
||||
|
||||
// the billing provider from the database
|
||||
type BillingProvider = Enums<'billing_provider'>;
|
||||
type BillingProvider = Enums<{ schema: 'medreport' }, 'billing_provider'>;
|
||||
|
||||
/**
|
||||
* @name getBillingEventHandlerService
|
||||
|
||||
@@ -19,10 +19,10 @@ import { initializeEmailI18n } from '../lib/i18n';
|
||||
|
||||
export async function renderAllResultsReceivedEmail({
|
||||
language,
|
||||
analysisOrderId,
|
||||
analysisResponseId,
|
||||
}: {
|
||||
language: string;
|
||||
analysisOrderId: number;
|
||||
analysisResponseId: number;
|
||||
}) {
|
||||
const namespace = 'all-results-received-email';
|
||||
|
||||
@@ -57,13 +57,13 @@ export async function renderAllResultsReceivedEmail({
|
||||
{t(`${namespace}:openOrdersHeading`)}
|
||||
</Text>
|
||||
<EmailButton
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
|
||||
>
|
||||
{t(`${namespace}:linkText`)}
|
||||
</EmailButton>
|
||||
<Text>
|
||||
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
|
||||
</Text>
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
|
||||
@@ -19,10 +19,10 @@ import { initializeEmailI18n } from '../lib/i18n';
|
||||
|
||||
export async function renderFirstResultsReceivedEmail({
|
||||
language,
|
||||
analysisOrderId,
|
||||
analysisResponseId,
|
||||
}: {
|
||||
language: string;
|
||||
analysisOrderId: number;
|
||||
analysisResponseId: number;
|
||||
}) {
|
||||
const namespace = 'first-results-received-email';
|
||||
|
||||
@@ -61,14 +61,14 @@ export async function renderFirstResultsReceivedEmail({
|
||||
</Text>
|
||||
|
||||
<EmailButton
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
|
||||
>
|
||||
{t(`${namespace}:linkText`)}
|
||||
</EmailButton>
|
||||
|
||||
<Text>
|
||||
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
|
||||
</Text>
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
|
||||
@@ -52,6 +52,23 @@ export const AnalysisResponsesSchema = z.object({
|
||||
});
|
||||
export type AnalysisResponses = z.infer<typeof AnalysisResponsesSchema>;
|
||||
|
||||
// Nested element schema (used recursively)
|
||||
export const NestedAnalysisElementSchema = z.object({
|
||||
analysisElementOriginalId: z.string(),
|
||||
analysisName: z.string().optional().nullable(),
|
||||
unit: z.string().nullable(),
|
||||
normLower: z.number().nullable(),
|
||||
normUpper: z.number().nullable(),
|
||||
normStatus: z.number().nullable(),
|
||||
responseTime: z.string().nullable(),
|
||||
responseValue: z.number().nullable(),
|
||||
normLowerIncluded: z.boolean(),
|
||||
normUpperIncluded: z.boolean(),
|
||||
status: z.number(),
|
||||
analysisNameLab: z.string().optional().nullable(),
|
||||
});
|
||||
export type NestedAnalysisElement = z.infer<typeof NestedAnalysisElementSchema>;
|
||||
|
||||
export const AnalysisResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
analysis_response_id: z.number(),
|
||||
@@ -69,6 +86,7 @@ export const AnalysisResponseSchema = z.object({
|
||||
analysis_name: z.string().nullable(),
|
||||
analysis_responses: AnalysisResponsesSchema,
|
||||
comment: z.string().nullable(),
|
||||
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
|
||||
latestPreviousAnalysis: z
|
||||
.object({
|
||||
id: z.number(),
|
||||
@@ -86,6 +104,7 @@ export const AnalysisResponseSchema = z.object({
|
||||
updated_at: z.string().nullable(),
|
||||
analysis_name: z.string().nullable(),
|
||||
comment: z.string().nullable(),
|
||||
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
|
||||
@@ -5,12 +5,16 @@ import { isBefore } from 'date-fns';
|
||||
|
||||
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getFullName } from '@kit/shared/utils';
|
||||
import type { UuringuVastus, ResponseUuring } from '@kit/shared/types/medipost-analysis';
|
||||
import { getFullName, toArray } from '@kit/shared/utils';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createUserAnalysesApi } from '@kit/user-analyses/api';
|
||||
|
||||
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service';
|
||||
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
|
||||
import {
|
||||
AnalysisResultDetails,
|
||||
NestedAnalysisElement,
|
||||
} from '../schema/doctor-analysis-detail-view.schema';
|
||||
import {
|
||||
AnalysisResponseBase,
|
||||
DoctorAnalysisFeedbackTable,
|
||||
@@ -20,6 +24,63 @@ import {
|
||||
} from '../schema/doctor-analysis.schema';
|
||||
import { ErrorReason } from '../schema/error.type';
|
||||
|
||||
function mapUuringVastus({ uuringVastus }: { uuringVastus?: UuringuVastus }) {
|
||||
const vastuseVaartus = uuringVastus?.VastuseVaartus;
|
||||
const responseValue = (() => {
|
||||
const valueAsNumber = Number(vastuseVaartus);
|
||||
if (isNaN(valueAsNumber)) {
|
||||
return null;
|
||||
}
|
||||
return valueAsNumber;
|
||||
})();
|
||||
const responseValueNumber = Number(responseValue);
|
||||
const responseValueIsNumeric = !isNaN(responseValueNumber);
|
||||
return {
|
||||
normLower: uuringVastus?.NormAlum?.['#text'] ?? null,
|
||||
normUpper: uuringVastus?.NormYlem?.['#text'] ?? null,
|
||||
normStatus: (uuringVastus?.NormiStaatus ?? null) as number | null,
|
||||
responseTime: uuringVastus?.VastuseAeg ?? null,
|
||||
responseValue:
|
||||
responseValueIsNumeric ? (responseValueNumber ?? null) : null,
|
||||
normLowerIncluded:
|
||||
uuringVastus?.NormAlum?.['@_kaasaarvatud']?.toLowerCase() === 'jah',
|
||||
normUpperIncluded:
|
||||
uuringVastus?.NormYlem?.['@_kaasaarvatud']?.toLowerCase() === 'jah',
|
||||
};
|
||||
}
|
||||
|
||||
function parseNestedElements(
|
||||
originalResponseElement: ResponseUuring | null | undefined,
|
||||
status: number,
|
||||
): NestedAnalysisElement[] {
|
||||
if (!originalResponseElement?.UuringuElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nestedElements = toArray(originalResponseElement.UuringuElement);
|
||||
|
||||
return nestedElements.map<NestedAnalysisElement>((element) => {
|
||||
const mappedResponse = mapUuringVastus({
|
||||
uuringVastus: element.UuringuVastus as UuringuVastus | undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
analysisElementOriginalId: element.UuringId,
|
||||
analysisName: undefined, // Will be populated later from analysis_elements table
|
||||
unit: element.Mootyhik ?? null,
|
||||
normLower: mappedResponse.normLower,
|
||||
normUpper: mappedResponse.normUpper,
|
||||
normStatus: mappedResponse.normStatus,
|
||||
responseTime: mappedResponse.responseTime,
|
||||
responseValue: mappedResponse.responseValue,
|
||||
normLowerIncluded: mappedResponse.normLowerIncluded,
|
||||
normUpperIncluded: mappedResponse.normUpperIncluded,
|
||||
status,
|
||||
analysisNameLab: element.UuringNimi,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
@@ -186,12 +247,13 @@ export async function getUserInProgressResponses({
|
||||
`,
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.neq('status', 'ON_HOLD')
|
||||
.eq('order_status', 'COMPLETED')
|
||||
.in('analysis_order_id', analysisOrderIds)
|
||||
.range(offset, offset + pageSize - 1)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to get analysis responses", error);
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
@@ -388,7 +450,8 @@ export async function getAnalysisResultsForDoctor(
|
||||
.from(`analysis_response_elements`)
|
||||
.select(
|
||||
`*,
|
||||
analysis_responses(user_id, analysis_order:analysis_order_id(id,medusa_order_id, analysis_element_ids))`,
|
||||
analysis_responses(user_id, analysis_order:analysis_order_id(id,medusa_order_id, analysis_element_ids)),
|
||||
original_response_element`,
|
||||
)
|
||||
.eq('analysis_response_id', analysisResponseId);
|
||||
|
||||
@@ -404,8 +467,14 @@ export async function getAnalysisResultsForDoctor(
|
||||
const medusaOrderId =
|
||||
firstAnalysisResponse?.analysis_responses?.analysis_order?.medusa_order_id;
|
||||
|
||||
if (!analysisResponsesData?.length || !userId || !medusaOrderId) {
|
||||
throw new Error('Failed to retrieve full analysis data.');
|
||||
if (!analysisResponsesData?.length) {
|
||||
throw new Error('No analysis responses data found.');
|
||||
}
|
||||
if (!userId) {
|
||||
throw new Error('No user id found.');
|
||||
}
|
||||
if (!medusaOrderId) {
|
||||
throw new Error('No medusa order id found.');
|
||||
}
|
||||
|
||||
const responseElementAnalysisElementOriginalIds = analysisResponsesData.map(
|
||||
@@ -446,7 +515,8 @@ export async function getAnalysisResultsForDoctor(
|
||||
*,
|
||||
analysis_responses!inner(
|
||||
user_id
|
||||
)
|
||||
),
|
||||
original_response_element
|
||||
`,
|
||||
)
|
||||
.in(
|
||||
@@ -485,8 +555,14 @@ export async function getAnalysisResultsForDoctor(
|
||||
preferred_locale,
|
||||
} = accountWithParams[0];
|
||||
|
||||
// Parse nested elements for current and previous analyses
|
||||
const analysisResponseElementsWithPreviousData = [];
|
||||
for (const analysisResponseElement of analysisResponsesData) {
|
||||
const nestedElements = parseNestedElements(
|
||||
analysisResponseElement.original_response_element as ResponseUuring,
|
||||
Number(analysisResponseElement.status),
|
||||
);
|
||||
|
||||
const latestPreviousAnalysis = previousAnalyses.find(
|
||||
({ analysis_element_original_id, response_time }) => {
|
||||
if (response_time && analysisResponseElement.response_time) {
|
||||
@@ -501,12 +577,95 @@ export async function getAnalysisResultsForDoctor(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Parse nested elements for previous analysis if it exists
|
||||
const latestPreviousAnalysisWithNested = latestPreviousAnalysis
|
||||
? {
|
||||
...latestPreviousAnalysis,
|
||||
nestedElements: parseNestedElements(
|
||||
latestPreviousAnalysis.original_response_element as ResponseUuring,
|
||||
Number(latestPreviousAnalysis.status),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
analysisResponseElementsWithPreviousData.push({
|
||||
...analysisResponseElement,
|
||||
latestPreviousAnalysis,
|
||||
nestedElements,
|
||||
latestPreviousAnalysis: latestPreviousAnalysisWithNested,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect all nested element IDs to fetch their names
|
||||
const nestedElementIds = analysisResponseElementsWithPreviousData
|
||||
.flatMap((element) => [
|
||||
...(element.nestedElements?.map((ne) => ne.analysisElementOriginalId) ??
|
||||
[]),
|
||||
...(element.latestPreviousAnalysis?.nestedElements?.map(
|
||||
(ne) => ne.analysisElementOriginalId,
|
||||
) ?? []),
|
||||
])
|
||||
.filter(Boolean);
|
||||
|
||||
// Fetch analysis names for nested elements
|
||||
if (nestedElementIds.length > 0) {
|
||||
const { data: nestedAnalysisElements, error: nestedAnalysisElementsError } =
|
||||
await supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_elements')
|
||||
.select('*')
|
||||
.in('analysis_id_original', nestedElementIds);
|
||||
|
||||
if (!nestedAnalysisElementsError && nestedAnalysisElements) {
|
||||
// Populate analysis names for current nested elements
|
||||
for (const element of analysisResponseElementsWithPreviousData) {
|
||||
if (element.nestedElements) {
|
||||
for (const nestedElement of element.nestedElements) {
|
||||
const analysisElement = nestedAnalysisElements.find(
|
||||
(ae) =>
|
||||
ae.analysis_id_original ===
|
||||
nestedElement.analysisElementOriginalId,
|
||||
);
|
||||
if (analysisElement) {
|
||||
nestedElement.analysisName =
|
||||
analysisElement.analysis_name_lab as string | undefined;
|
||||
}
|
||||
}
|
||||
// Sort nested elements by name
|
||||
element.nestedElements.sort(
|
||||
(a, b) => a.analysisName?.localeCompare(b.analysisName ?? '') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Populate analysis names for previous nested elements
|
||||
if (element.latestPreviousAnalysis?.nestedElements) {
|
||||
for (const nestedElement of element.latestPreviousAnalysis
|
||||
.nestedElements) {
|
||||
const analysisElement = nestedAnalysisElements.find(
|
||||
(ae) =>
|
||||
ae.analysis_id_original ===
|
||||
nestedElement.analysisElementOriginalId,
|
||||
);
|
||||
if (analysisElement) {
|
||||
nestedElement.analysisName =
|
||||
analysisElement.analysis_name_lab as string | undefined;
|
||||
}
|
||||
}
|
||||
// Sort nested elements by name
|
||||
element.latestPreviousAnalysis.nestedElements.sort(
|
||||
(a, b) => a.analysisName?.localeCompare(b.analysisName ?? '') ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
'Failed to get nested analysis elements by ids=',
|
||||
nestedElementIds,
|
||||
nestedAnalysisElementsError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
analysisResponse: analysisResponseElementsWithPreviousData,
|
||||
order: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getDoctorAccounts,
|
||||
getUserContactAdmin,
|
||||
} from '~/lib/services/account.service';
|
||||
import { getAnalysisResponseAdmin } from '~/lib/services/analysis-response.service';
|
||||
import {
|
||||
NotificationAction,
|
||||
createNotificationLog,
|
||||
@@ -74,14 +75,14 @@ class OrderWebhooksService {
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Status change processed. No notifications to send.');
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
if (actions.length)
|
||||
await Promise.all(
|
||||
actions.map((action) =>
|
||||
createNotificationLog({
|
||||
action,
|
||||
status: 'FAIL',
|
||||
comment: e?.message,
|
||||
comment: e instanceof Error ? e.message : 'Unknown error',
|
||||
relatedRecordId: analysisOrder.id,
|
||||
}),
|
||||
),
|
||||
@@ -201,11 +202,11 @@ class OrderWebhooksService {
|
||||
status: 'SUCCESS',
|
||||
relatedRecordId: orderCart.order_id,
|
||||
});
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
createNotificationLog({
|
||||
action: NotificationAction.TTO_ORDER_CONFIRMATION,
|
||||
status: 'FAIL',
|
||||
comment: e?.message,
|
||||
comment: e instanceof Error ? e.message : 'Unknown error',
|
||||
relatedRecordId: ttoReservation.id,
|
||||
});
|
||||
logger.error(
|
||||
@@ -345,10 +346,12 @@ class OrderWebhooksService {
|
||||
.map(({ email }) => email)
|
||||
.filter((email): email is string => !!email);
|
||||
|
||||
|
||||
const analysisResponse = await getAnalysisResponseAdmin(analysisOrder.id);
|
||||
await sendEmailFromTemplate(
|
||||
renderFirstResultsReceivedEmail,
|
||||
{
|
||||
analysisOrderId: analysisOrder.id,
|
||||
analysisResponseId: analysisResponse.id,
|
||||
language: 'et',
|
||||
},
|
||||
doctorEmails,
|
||||
@@ -380,10 +383,12 @@ class OrderWebhooksService {
|
||||
return;
|
||||
}
|
||||
|
||||
const analysisResponse = await getAnalysisResponseAdmin(analysisOrder.id);
|
||||
|
||||
await sendEmailFromTemplate(
|
||||
renderAllResultsReceivedEmail,
|
||||
{
|
||||
analysisOrderId: analysisOrder.id,
|
||||
analysisResponseId: analysisResponse.id,
|
||||
language: 'et',
|
||||
},
|
||||
assignedDoctorEmail,
|
||||
|
||||
Reference in New Issue
Block a user