Merge pull request #60 from MR-medreport/MED-85

feat(MED-85): testing feedback updates, some fixes+improvements
This commit is contained in:
2025-08-27 08:40:25 +03:00
committed by GitHub
41 changed files with 923 additions and 209 deletions

View File

@@ -1,29 +1,62 @@
import { readPrivateMessageResponse } from "~/lib/services/medipost.service"; import { readPrivateMessageResponse } from "~/lib/services/medipost.service";
type ProcessedMessage = {
messageId: string;
hasAnalysisResponse: boolean;
hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined;
};
type GroupedResults = {
processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
};
export default async function syncAnalysisResults() { export default async function syncAnalysisResults() {
console.info("Syncing analysis results"); console.info("Syncing analysis results");
let processedMessageIds: string[] = []; let processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = []; const excludedMessageIds: string[] = [];
while (true) { while (true) {
console.info("Fetching private messages"); const result = await readPrivateMessageResponse({ excludedMessageIds });
const { messageIdErrored, messageIdProcessed } = await readPrivateMessageResponse({ excludedMessageIds }); if (result.messageId) {
if (messageIdProcessed) { processedMessages.push(result as ProcessedMessage);
processedMessageIds.push(messageIdProcessed);
} }
if (!messageIdErrored) { if (!result.messageId) {
console.info("No more messages to process"); console.info("No more messages to process");
break; break;
} }
if (excludedMessageIds.includes(messageIdErrored)) { if (!excludedMessageIds.includes(result.messageId)) {
console.info(`Message id=${messageIdErrored} has already been processed, stopping`); excludedMessageIds.push(result.messageId);
} else {
break; break;
} }
excludedMessageIds.push(messageIdErrored);
} }
console.info(`Processed ${processedMessageIds.length} messages, ids: ${processedMessageIds.join(', ')}`); const groupedResults = processedMessages.reduce((acc, result) => {
if (result.medusaOrderId) {
if (result.hasAnalysisResponse) {
if (!acc.processed) {
acc.processed = [];
}
acc.processed.push({
messageId: result.messageId,
medusaOrderId: result.medusaOrderId,
});
} else {
if (!acc.waitingForResults) {
acc.waitingForResults = [];
}
acc.waitingForResults.push({
messageId: result.messageId,
medusaOrderId: result.medusaOrderId,
});
}
}
return acc;
}, {} as GroupedResults);
console.info(`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults, undefined, 2)}`);
} }

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
import { retrieveOrder } from "@lib/data/orders";
import { getMedipostDispatchTries } from "~/lib/services/audit.service";
export const POST = async (request: NextRequest) => {
loadEnv();
const { medusaOrderId } = await request.json();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
const tries = await getMedipostDispatchTries(medusaOrderId);
if (tries >= 3) {
return NextResponse.json({
message: 'Order has been retried too many times',
}, { status: 400 });
}
try {
const medusaOrder = await retrieveOrder(medusaOrderId);
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
console.info("Successfully sent order to medipost");
return NextResponse.json({
message: 'Successfully sent order to medipost',
}, { status: 200 });
} catch (e) {
console.error("Error sending order to medipost", e);
return NextResponse.json({
message: 'Failed to send order to medipost',
}, { status: 500 });
}
};

View File

@@ -18,8 +18,8 @@ async function AnalysisPage({
id: string; id: string;
}>; }>;
}) { }) {
const { id } = await params; const { id: analysisResponseId } = await params;
const analysisResultDetails = await loadResult(Number(id)); const analysisResultDetails = await loadResult(Number(analysisResponseId));
if (!analysisResultDetails) { if (!analysisResultDetails) {
return null; return null;

View File

@@ -85,35 +85,43 @@ const AnalysisLevelBar = ({
return calculated; return calculated;
}, [value, upper, lower]); }, [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 ( return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0"> <div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
{normLowerIncluded && ( {normLowerIncluded && (
<> <>
<Level <Level
isActive={level === AnalysisResultLevel.VERY_LOW} isActive={isVeryLow}
color="destructive" color="destructive"
isFirst isFirst
/> />
<Level isActive={level === AnalysisResultLevel.LOW} color="warning" /> <Level isActive={isLow} color="warning" />
</> </>
)} )}
<Level <Level
isFirst={!normLowerIncluded} isFirst={!normLowerIncluded}
isLast={!normUpperIncluded} isLast={!normUpperIncluded}
color={level === AnalysisResultLevel.NORMAL ? "success" : "warning"} {...(hasAbnormalLevel ? { color: "warning", isActive: false } : { color: "success", isActive: true })}
isActive
arrowLocation={arrowLocation} arrowLocation={arrowLocation}
/> />
{normUpperIncluded && ( {normUpperIncluded && (
<> <>
<Level <Level
isActive={level === AnalysisResultLevel.HIGH} isActive={isHigh}
color="warning" color="warning"
/> />
<Level <Level
isActive={level === AnalysisResultLevel.VERY_HIGH} isActive={isVeryHigh}
color="destructive" color="destructive"
isLast isLast
/> />

View File

@@ -39,9 +39,11 @@ const Analysis = ({
results, results,
startIcon, startIcon,
endIcon, endIcon,
isCancelled,
}: { }: {
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>; analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
results?: AnalysisResultForDisplay; results?: AnalysisResultForDisplay;
isCancelled?: boolean;
startIcon?: ReactElement | null; startIcon?: ReactElement | null;
endIcon?: ReactNode | null; endIcon?: ReactNode | null;
}) => { }) => {
@@ -128,7 +130,7 @@ const Analysis = ({
/> />
{endIcon || <div className="mx-2 w-4" />} {endIcon || <div className="mx-2 w-4" />}
</> </>
) : ( ) : (isCancelled ? null : (
<> <>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold"> <div className="font-semibold">
@@ -138,7 +140,7 @@ const Analysis = ({
<div className="mx-8 w-[60px]"></div> <div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton /> <AnalysisLevelBarSkeleton />
</> </>
)} ))}
</div> </div>
</div> </div>
); );

View File

@@ -104,7 +104,7 @@ async function AnalysisResultsPage() {
&& analysisResponseElements?.find((element) => element.analysis_element_original_id === analysisElement.analysis_id_original); && analysisResponseElements?.find((element) => element.analysis_element_original_id === analysisElement.analysis_id_original);
if (!results) { if (!results) {
return ( return (
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} /> <Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} isCancelled={analysisOrder.status === 'CANCELLED'}/>
); );
} }
return ( return (

View File

@@ -143,7 +143,7 @@ export async function processMontonioCallback(orderToken: string) {
console.error("Missing email or analysisPackageName", orderResult); console.error("Missing email or analysisPackageName", orderResult);
} }
sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
return { success: true, orderId }; return { success: true, orderId };
} catch (error) { } catch (error) {

View File

@@ -25,21 +25,28 @@ export default async function CartPage() {
const { productTypes } = await listProductTypes(); const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages'); const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
const analysisPackages = analysisPackagesType && cart?.items const synlabAnalysisType = productTypes.find(({ metadata }) => metadata?.handle === 'synlab-analysis');
? cart.items.filter((item) => item.product?.type_id === analysisPackagesType.id) const synlabAnalyses = analysisPackagesType && synlabAnalysisType && cart?.items
? cart.items.filter((item) => {
const productTypeId = item.product?.type_id;
if (!productTypeId) {
return false;
}
return [analysisPackagesType.id, synlabAnalysisType.id].includes(productTypeId);
})
: []; : [];
const otherItems = cart?.items?.filter((item) => item.product?.type_id !== analysisPackagesType?.id) ?? []; const ttoServiceItems = cart?.items?.filter((item) => !synlabAnalyses.some((analysis) => analysis.id === item.id)) ?? [];
const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1); const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
const item = otherItemsSorted[0]; const item = otherItemsSorted[0];
const hasItemsWithTimer = false as boolean; const isTimerShown = ttoServiceItems.length > 0 && !!item && !!item.updated_at;
return ( return (
<PageBody> <PageBody>
<PageHeader title={<Trans i18nKey="cart:title" />}> <PageHeader title={<Trans i18nKey="cart:title" />}>
{hasItemsWithTimer && item && item.updated_at && <CartTimer cartItem={item} />} {isTimerShown && <CartTimer cartItem={item} />}
</PageHeader> </PageHeader>
<Cart cart={cart} analysisPackages={analysisPackages} otherItems={otherItems} /> <Cart cart={cart} synlabAnalyses={synlabAnalyses} ttoServiceItems={ttoServiceItems} />
</PageBody> </PageBody>
); );
} }

View File

@@ -19,17 +19,13 @@ import {
} from '@kit/ui/select'; } from '@kit/ui/select';
import { updateCartPartnerLocation } from '../../_lib/server/update-cart-partner-location'; import { updateCartPartnerLocation } from '../../_lib/server/update-cart-partner-location';
import partnerLocations from './partner-locations.json';
const AnalysisLocationSchema = z.object({ const AnalysisLocationSchema = z.object({
locationId: z.string().min(1), locationId: z.string().min(1),
}); });
const MOCK_LOCATIONS: { id: string, name: string }[] = [ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: StoreCart, synlabAnalyses: StoreCartLineItem[] }) {
{ id: "synlab-tallinn-1", name: "SYNLAB - Tallinn" },
{ id: "synlab-tartu-1", name: "SYNLAB - Tartu" },
{ id: "synlab-parnu-1", name: "SYNLAB - Pärnu" },
]
export default function AnalysisLocation({ cart, analysisPackages }: { cart: StoreCart, analysisPackages: StoreCartLineItem[] }) {
const { t } = useTranslation('cart'); const { t } = useTranslation('cart');
const form = useForm<z.infer<typeof AnalysisLocationSchema>>({ const form = useForm<z.infer<typeof AnalysisLocationSchema>>({
@@ -39,12 +35,16 @@ export default function AnalysisLocation({ cart, analysisPackages }: { cart: Sto
resolver: zodResolver(AnalysisLocationSchema), resolver: zodResolver(AnalysisLocationSchema),
}); });
const getLocation = (locationId: string) => partnerLocations.find(({ name }) => name === locationId);
const selectedLocation = getLocation(form.watch('locationId'));
const onSubmit = async ({ locationId }: z.infer<typeof AnalysisLocationSchema>) => { const onSubmit = async ({ locationId }: z.infer<typeof AnalysisLocationSchema>) => {
const promise = updateCartPartnerLocation({ const promise = updateCartPartnerLocation({
cartId: cart.id, cartId: cart.id,
lineIds: analysisPackages.map(({ id }) => id), lineIds: synlabAnalyses.map(({ id }) => id),
partnerLocationId: locationId, partnerLocationId: locationId,
partnerLocationName: MOCK_LOCATIONS.find((location) => location.id === locationId)?.name ?? '', partnerLocationName: getLocation(locationId)?.name ?? '',
}); });
toast.promise(promise, { toast.promise(promise, {
@@ -55,7 +55,7 @@ export default function AnalysisLocation({ cart, analysisPackages }: { cart: Sto
} }
return ( return (
<div className="w-full bg-white flex flex-col txt-medium"> <div className="w-full bg-white flex flex-col txt-medium gap-y-2">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((data) => onSubmit(data))} onSubmit={form.handleSubmit((data) => onSubmit(data))}
@@ -78,18 +78,35 @@ export default function AnalysisLocation({ cart, analysisPackages }: { cart: Sto
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> {Object.entries(partnerLocations
<SelectLabel>{t('cart:locations.locationSelect')}</SelectLabel> .reduce((acc, curr) => ({
...acc,
{MOCK_LOCATIONS.map((location) => ( [curr.city]: [...((acc[curr.city] as typeof partnerLocations) ?? []), curr],
<SelectItem key={location.id} value={location.id}>{location.name}</SelectItem> }), {} as Record<string, typeof partnerLocations>))
.map(([city, locations]) => (
<SelectGroup key={city}>
<SelectLabel>{city}</SelectLabel>
{locations.map((location) => (
<SelectItem key={location.name} value={location.name}>{location.name}</SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</form> </form>
</Form> </Form>
{selectedLocation && (
<div className="flex flex-col gap-y-2 mb-4">
<p className="text-sm">
{selectedLocation.address}
</p>
<p className="text-sm">
{selectedLocation.hours}
</p>
</div>
)}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:locations.description'} /> <Trans i18nKey={'cart:locations.description'} />
</p> </p>

View File

@@ -22,12 +22,12 @@ const IS_DISCOUNT_SHOWN = false as boolean;
export default function Cart({ export default function Cart({
cart, cart,
analysisPackages, synlabAnalyses,
otherItems, ttoServiceItems,
}: { }: {
cart: StoreCart | null cart: StoreCart | null
analysisPackages: StoreCartLineItem[]; synlabAnalyses: StoreCartLineItem[];
otherItems: StoreCartLineItem[]; ttoServiceItems: StoreCartLineItem[];
}) { }) {
const { i18n: { language } } = useTranslation(); const { i18n: { language } } = useTranslation();
@@ -68,13 +68,13 @@ export default function Cart({
} }
const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0; const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0;
const isLocationsShown = analysisPackages.length > 0; const isLocationsShown = synlabAnalyses.length > 0;
return ( return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4"> <div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
<div className="flex flex-col bg-white gap-y-6"> <div className="flex flex-col bg-white gap-y-6">
<CartItems cart={cart} items={analysisPackages} productColumnLabelKey="cart:items.analysisPackages.productColumnLabel" /> <CartItems cart={cart} items={synlabAnalyses} productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel" />
<CartItems cart={cart} items={otherItems} productColumnLabelKey="cart:items.services.productColumnLabel" /> <CartItems cart={cart} items={ttoServiceItems} productColumnLabelKey="cart:items.ttoServices.productColumnLabel" />
</div> </div>
{hasCartItems && ( {hasCartItems && (
<div className="flex justify-end gap-x-4 px-6 py-4"> <div className="flex justify-end gap-x-4 px-6 py-4">
@@ -121,7 +121,7 @@ export default function Cart({
</h5> </h5>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AnalysisLocation cart={{ ...cart }} analysisPackages={analysisPackages} /> <AnalysisLocation cart={{ ...cart }} synlabAnalyses={synlabAnalyses} />
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -0,0 +1,122 @@
[
{
"name": "SYNLAB Eesti Veerenni verevõtupunkt",
"address": "Veerenni 53a, VI korrus Tel: 17123",
"hours": "Verevõtt E-R 7.30-15.30 ja L 8.00-16.00 (lõunapaus 13.00-13.30)",
"city": "Tallinn"
},
{
"name": "SYNLAB Eesti kesklinna verevõtupunkt",
"address": "Pärnu mnt 15 (Kawe Plaza), I korrus Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-16.00 (lõunapaus 12.00-12.30)",
"city": "Tallinn"
},
{
"name": "SYNLAB Eesti Lasnamäe verevõtupunkt",
"address": "Linnamäe tee 3 (Lasnamäe Tervisemaja), II korrus Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-16.00 (lõunapaus 12.00-12.30)",
"city": "Tallinn"
},
{
"name": "SYNLAB Eesti Ülemiste verevõtupunkt",
"address": "Valukoja 7 (Ülemiste Tervisemaja), II korrus Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-16.00 (lõunapaus 12.00-12.30)",
"city": "Tallinn"
},
{
"name": "SYNLAB Eesti Sepapaja verevõtupunkt",
"address": "Sepapaja 12/1 (Ülemiste Tervisemaja 2, Karl Ernst von Baeri maja), III korrus Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-16.00 (lõunapaus 12.30-13.00)",
"city": "Tallinn"
},
{
"name": "SYNLAB Eesti Viimsi verevõtupunkt",
"address": "Ravi tee 4 (Viimsi Fertilitas) Tel: 17123",
"hours": "Verevõtt tööpäeviti 7.30-15.30 (lõunapaus 12.30-13.00)",
"city": "Viimsi"
},
{
"name": "SYNLAB Eesti Maardu verevõtupunkt",
"address": "Kallasmaa 4 (Maardu Tervisekeskus) Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-15.00 (lõunapaus 12.00-12.30)",
"city": "Maardu"
},
{
"name": "SYNLAB Eesti Tartu kliiniline labor",
"address": "Raatuse 21, II korrus Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-16.30",
"city": "Tartu"
},
{
"name": "SYNLAB Eesti Tasku verevõtupunkt",
"address": "Turu 2, IV korrus (Tasku Meditsiinikeskus) Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-15.30",
"city": "Tartu"
},
{
"name": "SYNLAB Eesti Tartu Tervisekeskuse verevõtupunkt",
"address": "Mõisavahe 34b, I korrus Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-15.00 (lõunapaus 12:30-13:00)",
"city": "Tartu"
},
{
"name": "SYNLAB Eesti Pärnu Tervis SPA verevõtupunkt",
"address": "Seedri 6 (Tervis SPA), kabinet 202 Tel: 17123",
"hours": "Verevõtt tööpäeviti E-N 8.00-15.00 R 8.00-13.00",
"city": "Pärnu"
},
{
"name": "SYNLAB Eesti Suur-Sepa verevõtupunkt",
"address": "Suur-Sepa 14, kabinet 102 Tel: 17123",
"hours": "Verevõtt tööpäeviti E-N 8.00-16.00 (lõunapaus 12.30-13.00) ja R 8.00-14.00 (lõunapaus 12.30-13.00)",
"city": "Pärnu"
},
{
"name": "SYNLAB Eesti Narva verevõtupunkt",
"address": "Fama 10/2, kabinet 14 Tel: 17123",
"hours": "Verevõtt tööpäeviti 7.30-12.00",
"city": "Narva"
},
{
"name": "SYNLAB Eesti Sillamäe verevõtupunkt",
"address": "Kajaka 9, IV korrus, kabinet 404 Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-11.30",
"city": "Sillamäe"
},
{
"name": "SYNLAB Eesti Jõhvi verevõtupunkt",
"address": "Jaama 34, I korrus, kabinet 15 Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-13.00",
"city": "Jõhvi"
},
{
"name": "SYNLAB Eesti Viljandi verevõtupunkt",
"address": "Tallinna 19, II korrus, kabinet 210 Tel: 17123",
"hours": "Verevõtt tööpäeviti E-N 8.00-15.00 ja R 8.00-12.00",
"city": "Viljandi"
},
{
"name": "SYNLAB Eesti Võru labor",
"address": "Tartu tn 9 Tel: 17123",
"hours": "Verevõtt tööpäeviti E-N 8.00-15.30 R 8.00-13.00",
"city": "Võru"
},
{
"name": "SYNLAB Eesti Elva labor",
"address": "Supelranna 21, kabinet 133 Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-14.00",
"city": "Elva"
},
{
"name": "SYNLAB Eesti Põltsamaa labor",
"address": "Lossi 49 Tel: 17123",
"hours": "Verevõtt tööpäeviti E-N 8.00-15.00 (lõunapaus 12:00-12:30) ja R 8.00-12.00",
"city": "Põltsamaa"
},
{
"name": "SYNLAB Eesti Otepää labor",
"address": "Tartu mnt 2 Tel: 17123",
"hours": "Verevõtt tööpäeviti 8.00-12.00",
"city": "Otepää"
}
]

View File

@@ -22,8 +22,14 @@ import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { PackageHeader } from '@kit/shared/components/package-header'; import { PackageHeader } from '@kit/shared/components/package-header';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip'; import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { StoreProduct } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
import { withI18n } from '@/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export type AnalysisPackageElement = Pick<StoreProduct, 'title' | 'id' | 'description'> & {
isIncludedInStandard: boolean;
isIncludedInStandardPlus: boolean;
isIncludedInPremium: boolean;
};
const CheckWithBackground = () => { const CheckWithBackground = () => {
return ( return (
@@ -33,15 +39,15 @@ const CheckWithBackground = () => {
); );
}; };
const PackageTableHead = async ({ product, nrOfAnalyses }: { product: StoreProduct, nrOfAnalyses: number }) => { const PackageTableHead = async ({ product }: { product: AnalysisPackageWithVariant }) => {
const { t, language } = await createI18nServerInstance(); const { t, language } = await createI18nServerInstance();
const variant = product.variants?.[0];
const titleKey = product.title; const { title, price, nrOfAnalyses } = product;
const price = variant?.calculated_price?.calculated_amount ?? 0;
return ( return (
<TableHead className="py-2"> <TableHead className="py-2">
<PackageHeader <PackageHeader
title={t(titleKey)} title={t(title)}
tagColor='bg-cyan' tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })} analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language} language={language}
@@ -56,24 +62,20 @@ const ComparePackagesModal = async ({
analysisPackageElements, analysisPackageElements,
triggerElement, triggerElement,
}: { }: {
analysisPackages: StoreProduct[]; analysisPackages: AnalysisPackageWithVariant[];
analysisPackageElements: StoreProduct[]; analysisPackageElements: AnalysisPackageElement[];
triggerElement: JSX.Element; triggerElement: JSX.Element;
}) => { }) => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
const standardPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard')!; const standardPackage = analysisPackages.find(({ isStandard }) => isStandard);
const standardPlusPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard-plus')!; const standardPlusPackage = analysisPackages.find(({ isStandardPlus }) => isStandardPlus);
const premiumPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'premium')!; const premiumPackage = analysisPackages.find(({ isPremium }) => isPremium);
if (!standardPackage || !standardPlusPackage || !premiumPackage) { if (!standardPackage || !standardPlusPackage || !premiumPackage) {
return null; return null;
} }
const standardPackageAnalyses = getAnalysisElementMedusaProductIds([standardPackage]);
const standardPlusPackageAnalyses = getAnalysisElementMedusaProductIds([standardPlusPackage]);
const premiumPackageAnalyses = getAnalysisElementMedusaProductIds([premiumPackage]);
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild>{triggerElement}</DialogTrigger> <DialogTrigger asChild>{triggerElement}</DialogTrigger>
@@ -103,9 +105,9 @@ const ComparePackagesModal = async ({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
<PackageTableHead product={standardPackage} nrOfAnalyses={standardPackageAnalyses.length} /> <PackageTableHead product={standardPackage} />
<PackageTableHead product={standardPlusPackage} nrOfAnalyses={standardPlusPackageAnalyses.length} /> <PackageTableHead product={standardPlusPackage} />
<PackageTableHead product={premiumPackage} nrOfAnalyses={premiumPackageAnalyses.length} /> <PackageTableHead product={premiumPackage} />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -115,29 +117,29 @@ const ComparePackagesModal = async ({
title, title,
id, id,
description, description,
isIncludedInStandard,
isIncludedInStandardPlus,
isIncludedInPremium,
}, },
index,
) => { ) => {
if (!title) { if (!title) {
return null; return null;
} }
const includedInStandard = standardPackageAnalyses.includes(id);
const includedInStandardPlus = standardPlusPackageAnalyses.includes(id);
const includedInPremium = premiumPackageAnalyses.includes(id);
return ( return (
<TableRow key={index}> <TableRow key={id}>
<TableCell className="py-6"> <TableCell className="py-6">
{title}{' '} {title}{' '}
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)} {description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
</TableCell> </TableCell>
<TableCell align="center" className="py-6"> <TableCell align="center" className="py-6">
{includedInStandard && <CheckWithBackground />} {isIncludedInStandard && <CheckWithBackground />}
</TableCell> </TableCell>
<TableCell align="center" className="py-6"> <TableCell align="center" className="py-6">
{(includedInStandard || includedInStandardPlus) && <CheckWithBackground />} {(isIncludedInStandard || isIncludedInStandardPlus) && <CheckWithBackground />}
</TableCell> </TableCell>
<TableCell align="center" className="py-6"> <TableCell align="center" className="py-6">
{(includedInStandard || includedInStandardPlus || includedInPremium) && <CheckWithBackground />} {(isIncludedInStandard || isIncludedInStandardPlus || isIncludedInPremium) && <CheckWithBackground />}
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@@ -9,30 +9,39 @@ import {
CardFooter, CardFooter,
CardDescription, CardDescription,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { StoreProduct, StoreProductVariant } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { useState } from 'react'; import { useState } from 'react';
import { handleAddToCart } from '~/lib/services/medusaCart.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip'; import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle'
> & {
isAvailable: boolean;
variant: { id: string };
};
export default function OrderAnalysesCards({ export default function OrderAnalysesCards({
analyses, analyses,
countryCode, countryCode,
}: { }: {
analyses: StoreProduct[]; analyses: OrderAnalysisCard[];
countryCode: string; countryCode: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const [isAddingToCart, setIsAddingToCart] = useState(false); const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => { const handleSelect = async (variantId: string) => {
if (!selectedVariant?.id || isAddingToCart) return null if (isAddingToCart) {
return null;
}
setIsAddingToCart(true); setIsAddingToCart(true);
try { try {
await handleAddToCart({ await handleAddToCart({
selectedVariant, selectedVariant: { id: variantId },
countryCode, countryCode,
}); });
setIsAddingToCart(false); setIsAddingToCart(false);
@@ -47,13 +56,11 @@ export default function OrderAnalysesCards({
<div className="grid grid-cols-3 gap-6 mt-4"> <div className="grid grid-cols-3 gap-6 mt-4">
{analyses.map(({ {analyses.map(({
title, title,
variants, variant,
description, description,
subtitle, subtitle,
status, isAvailable,
metadata,
}) => { }) => {
const isAvailable = status === 'published' && !!metadata?.analysisIdOriginal;
return ( return (
<Card <Card
key={title} key={title}
@@ -72,7 +79,7 @@ export default function OrderAnalysesCards({
size="icon" size="icon"
variant="outline" variant="outline"
className="px-2 text-black" className="px-2 text-black"
onClick={() => handleSelect(variants![0]!)} onClick={() => handleSelect(variant.id)}
> >
{isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />} {isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button> </Button>

View File

@@ -3,6 +3,7 @@ import { cache } from 'react';
import { listProductTypes } from "@lib/data/products"; import { listProductTypes } from "@lib/data/products";
import { listRegions } from '@lib/data/regions'; import { listRegions } from '@lib/data/regions';
import { getProductCategories } from '@lib/data/categories'; import { getProductCategories } from '@lib/data/categories';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>
@@ -34,7 +35,18 @@ async function analysesLoader() {
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis'); const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis');
return { return {
analyses: category?.products ?? [], analyses: category?.products?.map<OrderAnalysisCard>(({ title, description, subtitle, variants, status, metadata }) => {
const variant = variants![0]!;
return {
title,
description,
subtitle,
variant: {
id: variant.id,
},
isAvailable: status === 'published' && !!metadata?.analysisIdOriginal,
};
}) ?? [],
countryCode, countryCode,
} }
} }

View File

@@ -1,9 +1,13 @@
import { cache } from 'react'; import { cache } from 'react';
import Isikukood, { Gender } from 'isikukood';
import { listProductTypes, listProducts } from "@lib/data/products"; import { listProductTypes, listProducts } from "@lib/data/products";
import { listRegions } from '@lib/data/regions'; import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import type { StoreProduct } from '@medusajs/types'; import type { StoreProduct } from '@medusajs/types';
import { loadCurrentUserAccount } from './load-user-account';
import { AnalysisPackageWithVariant } from '~/components/select-analysis-package';
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>
@@ -19,26 +23,64 @@ async function productTypesLoader() {
} }
export const loadProductTypes = cache(productTypesLoader); export const loadProductTypes = cache(productTypesLoader);
async function analysisPackagesLoader() { function userSpecificVariantLoader({
const [countryCodes, productTypes] = await Promise.all([loadCountryCodes(), loadProductTypes()]); account,
const countryCode = countryCodes[0]!; }: {
account: AccountWithParams;
}) {
const { personal_code: personalCode } = account;
if (!personalCode) {
throw new Error('Personal code not found');
}
const parsed = new Isikukood(personalCode);
const ageRange = (() => {
const age = parsed.getAge();
if (age >= 18 && age <= 29) {
return '18-29';
}
if (age >= 30 && age <= 49) {
return '30-49';
}
if (age >= 50 && age <= 59) {
return '50-59';
}
if (age >= 60) {
return '60';
}
throw new Error('Age range not supported');
})();
const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F';
let analysisPackages: StoreProduct[] = []; return ({
let analysisPackageElements: StoreProduct[] = []; product,
}: {
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages'); product: StoreProduct;
if (!productType) { }) => {
return { analysisPackageElements, analysisPackages, countryCode }; const variants = product.variants;
if (!variants) {
return null;
} }
const analysisPackagesResponse = await listProducts({ const variant = variants.find((v) => v.options?.every((o) => [ageRange, gender].includes(o.value)));
countryCode, if (!variant) {
queryParams: { limit: 100, "type_id[0]": productType.id }, return null;
}); }
analysisPackages = analysisPackagesResponse.response.products; return variant;
}
}
async function analysisPackageElementsLoader({
analysisPackagesWithVariant,
countryCode,
}: {
analysisPackagesWithVariant: AnalysisPackageWithVariant[];
countryCode: string;
}) {
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackagesWithVariant);
if (analysisElementMedusaProductIds.length === 0) {
return [];
}
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackages);
if (analysisElementMedusaProductIds.length > 0) {
const { response: { products } } = await listProducts({ const { response: { products } } = await listProducts({
countryCode, countryCode,
queryParams: { queryParams: {
@@ -46,9 +88,88 @@ async function analysisPackagesLoader() {
limit: 100, limit: 100,
}, },
}); });
analysisPackageElements = products;
const standardPackage = analysisPackagesWithVariant.find(({ isStandard }) => isStandard);
const standardPlusPackage = analysisPackagesWithVariant.find(({ isStandardPlus }) => isStandardPlus);
const premiumPackage = analysisPackagesWithVariant.find(({ isPremium }) => isPremium);
if (!standardPackage || !standardPlusPackage || !premiumPackage) {
return [];
} }
return { analysisPackageElements, analysisPackages, countryCode }; const standardPackageAnalyses = getAnalysisElementMedusaProductIds([standardPackage]);
const standardPlusPackageAnalyses = getAnalysisElementMedusaProductIds([standardPlusPackage]);
const premiumPackageAnalyses = getAnalysisElementMedusaProductIds([premiumPackage]);
return products.map(({ id, title, description }) => ({
id,
title,
description,
isIncludedInStandard: standardPackageAnalyses.includes(id),
isIncludedInStandardPlus: standardPlusPackageAnalyses.includes(id),
isIncludedInPremium: premiumPackageAnalyses.includes(id),
}));
}
async function analysisPackagesWithVariantLoader({
account,
countryCode,
}: {
account: AccountWithParams;
countryCode: string;
}) {
const productTypes = await loadProductTypes();
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
if (!productType) {
return null;
}
const analysisPackagesResponse = await listProducts({
countryCode,
queryParams: { limit: 100, "type_id[0]": productType.id },
});
const getVariant = userSpecificVariantLoader({ account });
const analysisPackagesWithVariant = analysisPackagesResponse.response.products
.reduce((acc, product) => {
const variant = getVariant({ product });
if (!variant) {
return acc;
}
return [
...acc,
{
variantId: variant.id,
nrOfAnalyses: getAnalysisElementMedusaProductIds([product]).length,
price: variant.calculated_price?.calculated_amount ?? 0,
title: product.title,
subtitle: product.subtitle,
description: product.description,
metadata: product.metadata,
isStandard: product.metadata?.analysisPackageTier === 'standard',
isStandardPlus: product.metadata?.analysisPackageTier === 'standard-plus',
isPremium: product.metadata?.analysisPackageTier === 'premium',
},
];
}, [] as AnalysisPackageWithVariant[]);
return analysisPackagesWithVariant;
}
async function analysisPackagesLoader() {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
const countryCodes = await loadCountryCodes();
const countryCode = countryCodes[0]!;
const analysisPackagesWithVariant = await analysisPackagesWithVariantLoader({ account, countryCode });
if (!analysisPackagesWithVariant) {
return { analysisPackageElements: [], analysisPackages: [], countryCode };
}
const analysisPackageElements = await analysisPackageElementsLoader({ analysisPackagesWithVariant, countryCode });
return { analysisPackageElements, analysisPackages: analysisPackagesWithVariant, countryCode };
} }
export const loadAnalysisPackages = cache(analysisPackagesLoader); export const loadAnalysisPackages = cache(analysisPackagesLoader);

View File

@@ -1,11 +1,11 @@
'use server' 'use server'
import { createClient } from '@supabase/supabase-js';
import { RequestStatus } from '@/lib/types/audit'; import { RequestStatus } from '@/lib/types/audit';
import { ConnectedOnlineMethodName } from '@/lib/types/connected-online'; import { ConnectedOnlineMethodName } from '@/lib/types/connected-online';
import { ExternalApi } from '@/lib/types/external'; import { ExternalApi } from '@/lib/types/external';
import { MedipostAction } from '@/lib/types/medipost'; import { MedipostAction } from '@/lib/types/medipost';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
export default async function logRequestResult( export default async function logRequestResult(
/* personalCode: string, */ requestApi: keyof typeof ExternalApi, /* personalCode: string, */ requestApi: keyof typeof ExternalApi,
@@ -16,19 +16,7 @@ export default async function logRequestResult(
serviceId?: number, serviceId?: number,
serviceProviderId?: number, serviceProviderId?: number,
) { ) {
const supabaseServiceUser = createClient( const { error } = await getSupabaseServerClient()
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
},
);
const { error } = await supabaseServiceUser
.schema('audit') .schema('audit')
.from('request_entries') .from('request_entries')
.insert({ .insert({
@@ -46,3 +34,38 @@ export default async function logRequestResult(
throw new Error('Failed to insert log entry, error: ' + error.message); throw new Error('Failed to insert log entry, error: ' + error.message);
} }
} }
export async function logMedipostDispatch({
medusaOrderId,
isSuccess,
isMedipostError,
errorMessage,
}: {
medusaOrderId: string;
isSuccess: boolean;
isMedipostError: boolean;
errorMessage?: string;
}) {
const { error } = await getSupabaseServerAdminClient()
.schema('audit')
.from('medipost_dispatch')
.insert({
medusa_order_id: medusaOrderId,
is_success: isSuccess,
is_medipost_error: isMedipostError,
error_message: errorMessage,
});
if (error) {
throw new Error('Failed to insert log entry, error: ' + error.message);
}
}
export async function getMedipostDispatchTries(medusaOrderId: string) {
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.rpc('get_medipost_dispatch_tries', { p_medusa_order_id: medusaOrderId })
.throwOnError();
return data;
}

View File

@@ -44,6 +44,8 @@ import { StoreOrder } from '@medusajs/types';
import { listProducts } from '@lib/data/products'; import { listProducts } from '@lib/data/products';
import { listRegions } from '@lib/data/regions'; import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import { MedipostValidationError } from './medipost/MedipostValidationError';
import { logMedipostDispatch } from './audit.service';
const BASE_URL = process.env.MEDIPOST_URL!; const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!; const USER = process.env.MEDIPOST_USER!;
@@ -63,14 +65,14 @@ export async function validateMedipostResponse(response: string, { canHaveEmptyC
if (canHaveEmptyCode) { if (canHaveEmptyCode) {
if (code && code !== 0) { if (code && code !== 0) {
console.error("Bad response", response); console.error("Bad response", response);
throw new Error(`Medipost response is invalid`); throw new MedipostValidationError(response);
} }
return; return;
} }
if (typeof code !== 'number' || (code !== 0 && !canHaveEmptyCode)) { if (typeof code !== 'number' || (code !== 0 && !canHaveEmptyCode)) {
console.error("Bad response", response); console.error("Bad response", response);
throw new Error(`Medipost response is invalid`); throw new MedipostValidationError(response);
} }
} }
@@ -200,28 +202,60 @@ export async function readPrivateMessageResponse({
excludedMessageIds, excludedMessageIds,
}: { }: {
excludedMessageIds: string[]; excludedMessageIds: string[];
}) { }): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined }> {
let messageIdErrored: string | null = null; let messageId: string | null = null;
let messageIdProcessed: string | null = null; let hasAnalysisResponse = false;
let hasPartialAnalysisResponse = false;
let hasFullAnalysisResponse = false;
let medusaOrderId: string | undefined = undefined;
try { try {
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds }); const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
if (!privateMessage) { if (!privateMessage) {
throw new Error(`No private message found`); return {
messageId: null,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
};
} }
messageIdErrored = privateMessage.messageId; messageId = privateMessage.messageId;
if (!messageIdErrored) { if (!messageId) {
throw new Error(`No message id found`); return {
messageId: null,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
};
} }
const privateMessageContent = await getPrivateMessage( const privateMessageContent = await getPrivateMessage(
privateMessage.messageId, privateMessage.messageId,
); );
const messageResponse = privateMessageContent?.Saadetis?.Vastus; const messageResponse = privateMessageContent?.Saadetis?.Vastus;
const medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId; medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
if (!medusaOrderId || !medusaOrderId.toString().startsWith('order_')) {
return {
messageId,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
};
}
if (!messageResponse) { if (!messageResponse) {
throw new Error(`Private message response has no results yet for order=${medusaOrderId}`); return {
messageId,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId,
};
} }
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
@@ -236,17 +270,19 @@ export async function readPrivateMessageResponse({
if (status.isPartial) { if (status.isPartial) {
await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
messageIdProcessed = privateMessage.messageId; hasAnalysisResponse = true;
hasPartialAnalysisResponse = true;
} else if (status.isCompleted) { } else if (status.isCompleted) {
await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
await deletePrivateMessage(privateMessage.messageId); await deletePrivateMessage(privateMessage.messageId);
messageIdProcessed = privateMessage.messageId; hasAnalysisResponse = true;
hasFullAnalysisResponse = true;
} }
} catch (e) { } catch (e) {
console.warn(`Failed to process private message id=${messageIdErrored}, message=${(e as Error).message}`); console.warn(`Failed to process private message id=${messageId}, message=${(e as Error).message}`);
} }
return { messageIdErrored, messageIdProcessed }; return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId };
} }
async function saveAnalysisGroup( async function saveAnalysisGroup(
@@ -705,7 +741,23 @@ export async function sendOrderToMedipost({
comment: '', comment: '',
}); });
try {
await sendPrivateMessage(orderXml); await sendPrivateMessage(orderXml);
} catch (e) {
const isMedipostError = e instanceof MedipostValidationError;
await logMedipostDispatch({
medusaOrderId,
isSuccess: false,
isMedipostError,
errorMessage: isMedipostError ? e.response : undefined,
});
throw e;
}
await logMedipostDispatch({
medusaOrderId,
isSuccess: true,
isMedipostError: false,
});
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
} }

View File

@@ -0,0 +1,9 @@
export class MedipostValidationError extends Error {
response: string;
constructor(response: string) {
super('Invalid Medipost response');
this.name = 'MedipostValidationError';
this.response = response;
}
}

View File

@@ -31,7 +31,7 @@ export async function handleAddToCart({
selectedVariant, selectedVariant,
countryCode, countryCode,
}: { }: {
selectedVariant: StoreProductVariant selectedVariant: Pick<StoreProductVariant, 'id'>
countryCode: string countryCode: string
}) { }) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();

View File

@@ -99,7 +99,10 @@ export async function getOrder({
throw new Error('Either medusaOrderId or orderId must be provided'); throw new Error('Either medusaOrderId or orderId must be provided');
} }
const { data: order } = await query.single().throwOnError(); const { data: order, error } = await query.single();
if (error) {
throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or orderId=${orderId}, message=${error.message}, data=${JSON.stringify(order)}`);
}
return order; return order;
} }

View File

@@ -23,6 +23,7 @@ import { createAdminAccountsService } from './services/admin-accounts.service';
import { createAdminAuthUserService } from './services/admin-auth-user.service'; import { createAdminAuthUserService } from './services/admin-auth-user.service';
import { createCreateCompanyAccountService } from './services/admin-create-company-account.service'; import { createCreateCompanyAccountService } from './services/admin-create-company-account.service';
import { adminAction } from './utils/admin-action'; import { adminAction } from './utils/admin-action';
import { getAdminSdk } from './utils/medusa-sdk';
/** /**
* @name banUserAction * @name banUserAction
@@ -138,7 +139,24 @@ export const deleteAccountAction = adminAction(
logger.info({ accountId }, `Super Admin is deleting account...`); logger.info({ accountId }, `Super Admin is deleting account...`);
const { name: customerGroupName } = await service.getAccount(accountId);
try {
await service.deleteAccount(accountId); await service.deleteAccount(accountId);
} catch (e) {
logger.error({ accountId }, `Error deleting company account`);
throw e;
}
const medusa = getAdminSdk();
const { customer_groups } = await medusa.admin.customerGroup.list();
const customerGroup = customer_groups.find(({ name }) => name === customerGroupName);
if (customerGroup) {
try {
await medusa.admin.customerGroup.delete(customerGroup.id);
} catch (e) {
logger.error({ accountId }, `Error deleting Medusa customer group for company ${customerGroupName}`);
throw e;
}
}
logger.info( logger.info(
{ accountId }, { accountId },
@@ -267,6 +285,40 @@ export const createCompanyAccountAction = enhanceAction(
} }
logger.info(ctx, `Company account created`); logger.info(ctx, `Company account created`);
logger.info(ctx, `Creating Medusa customer group`);
const medusa = getAdminSdk();
const { customer_groups: existingCustomerGroups } = await medusa.admin.customerGroup.list();
const isExisting = existingCustomerGroups.find((group) => group.name === name);
if (isExisting) {
logger.info(ctx, `Customer group already exists`);
} else {
logger.info(ctx, `Creating Medusa customer group`);
const { data: account } = await client
.schema('medreport').from('accounts')
.select('medusa_account_id')
.eq('personal_code', ownerPersonalCode)
.single().throwOnError();
const medusaAccountId = account.medusa_account_id;
if (!medusaAccountId) {
logger.error(ctx, `User has no Medusa account ID`);
} else {
const { customer_group: { id: customerGroupId } } = await medusa.admin.customerGroup.create({ name });
const { customers } = await medusa.admin.customer.list({
id: medusaAccountId,
});
if (customers.length !== 1) {
logger.error(ctx, `Customer not found`);
} else {
const customerId = customers[0]!.id;
await medusa.admin.customer.batchCustomerGroups(customerId, {
add: [customerGroupId],
});
}
}
}
redirect(`/admin/accounts/${data.id}`); redirect(`/admin/accounts/${data.id}`);
}, },
{ {

View File

@@ -37,4 +37,15 @@ class AdminAccountsService {
throw error; throw error;
} }
} }
async getAccount(accountId: string) {
const { data } = await this.adminClient
.schema('medreport')
.from('accounts')
.select('*')
.eq('id', accountId)
.single().throwOnError();
return data;
}
} }

View File

@@ -0,0 +1,16 @@
import Medusa from "@medusajs/js-sdk"
export const getAdminSdk = () => {
const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!;
const medusaPublishableApiKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
const key = process.env.MEDUSA_SECRET_API_KEY!;
if (!medusaBackendUrl || !medusaPublishableApiKey) {
throw new Error('Medusa environment variables not set');
}
return new Medusa({
baseUrl: medusaBackendUrl,
debug: process.env.NODE_ENV === 'development',
apiKey: key,
});
}

View File

@@ -359,7 +359,7 @@ export async function getOtherResponses({
} }
export async function getAnalysisResultsForDoctor( export async function getAnalysisResultsForDoctor(
id: number, analysisResponseId: number,
): Promise<AnalysisResultDetails> { ): Promise<AnalysisResultDetails> {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
@@ -370,7 +370,7 @@ export async function getAnalysisResultsForDoctor(
`*, `*,
analysis_responses(user_id, analysis_order_id(id,medusa_order_id, analysis_element_ids))`, analysis_responses(user_id, analysis_order_id(id,medusa_order_id, analysis_element_ids))`,
) )
.eq('analysis_response_id', id); .eq('analysis_response_id', analysisResponseId);
if (error) { if (error) {
throw new Error('Something went wrong.'); throw new Error('Something went wrong.');

View File

@@ -276,6 +276,12 @@ export async function medusaLoginOrRegister(credentials: {
const customerCacheTag = await getCacheTag("customers"); const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag); revalidateTag(customerCacheTag);
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
}
return customer.id;
} catch (error) { } catch (error) {
console.error("Failed to login customer, attempting to register", error); console.error("Failed to login customer, attempting to register", error);
try { try {
@@ -302,6 +308,12 @@ export async function medusaLoginOrRegister(credentials: {
const customerCacheTag = await getCacheTag("customers"); const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag); revalidateTag(customerCacheTag);
await transferCart(); await transferCart();
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
}
return customer.id;
} catch (registerError) { } catch (registerError) {
throw medusaError(registerError); throw medusaError(registerError);
} }

View File

@@ -5,9 +5,10 @@ import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { StoreProduct, StoreProductVariant } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { Button } from '@medusajs/ui'; import { Button } from '@medusajs/ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
import { import {
Card, Card,
@@ -17,25 +18,24 @@ import {
CardHeader, CardHeader,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
import { getAnalysisElementMedusaProductIds } from '../../../../utils/medusa-product';
import { PackageHeader } from './package-header';
import { ButtonTooltip } from './ui/button-tooltip'; import { ButtonTooltip } from './ui/button-tooltip';
import { PackageHeader } from './package-header';
export interface IAnalysisPackage { export type AnalysisPackageWithVariant = Pick<StoreProduct, 'title' | 'description' | 'subtitle' | 'metadata'> & {
titleKey: string; variantId: string;
nrOfAnalyses: number;
price: number; price: number;
tagColor: string; isStandard: boolean;
descriptionKey: string; isStandardPlus: boolean;
} isPremium: boolean;
};
export default function SelectAnalysisPackage({ export default function SelectAnalysisPackage({
analysisPackage, analysisPackage,
countryCode, countryCode,
}: { }: {
analysisPackage: StoreProduct; analysisPackage: AnalysisPackageWithVariant;
countryCode: string; countryCode: string,
}) { }) {
const router = useRouter(); const router = useRouter();
const { const {
@@ -44,35 +44,21 @@ export default function SelectAnalysisPackage({
} = useTranslation(); } = useTranslation();
const [isAddingToCart, setIsAddingToCart] = useState(false); const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => {
if (!selectedVariant?.id) return null;
const { nrOfAnalyses, variantId, title, subtitle = '', description = '', price } = analysisPackage;
const handleSelect = async () => {
setIsAddingToCart(true); setIsAddingToCart(true);
await handleAddToCart({ await handleAddToCart({
selectedVariant, selectedVariant: { id: variantId },
countryCode, countryCode,
}); });
setIsAddingToCart(false); setIsAddingToCart(false);
router.push('/home/cart'); router.push('/home/cart');
}; };
const titleKey = analysisPackage.title;
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds([
analysisPackage,
]);
const nrOfAnalyses = analysisElementMedusaProductIds.length;
const description = analysisPackage.description ?? '';
const subtitle = analysisPackage.subtitle ?? '';
const variant = analysisPackage.variants?.[0];
if (!variant) {
return null;
}
const price = variant.calculated_price?.calculated_amount ?? 0;
return ( return (
<Card key={titleKey}> <Card key={title}>
<CardHeader className="relative"> <CardHeader className="relative">
{description && ( {description && (
<ButtonTooltip <ButtonTooltip
@@ -90,8 +76,8 @@ export default function SelectAnalysisPackage({
</CardHeader> </CardHeader>
<CardContent className="space-y-1 text-center"> <CardContent className="space-y-1 text-center">
<PackageHeader <PackageHeader
title={t(titleKey)} title={title}
tagColor="bg-cyan" tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })} analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language} language={language}
price={price} price={price}
@@ -99,14 +85,8 @@ export default function SelectAnalysisPackage({
<CardDescription>{subtitle}</CardDescription> <CardDescription>{subtitle}</CardDescription>
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button <Button className="w-full text-[10px] sm:text-sm" onClick={handleSelect} isLoading={isAddingToCart}>
className="w-full text-[10px] sm:text-sm" {!isAddingToCart && <Trans i18nKey='order-analysis-package:selectThisPackage' />}
onClick={() => handleSelect(variant)}
isLoading={isAddingToCart}
>
{!isAddingToCart && (
<Trans i18nKey="order-analysis-package:selectThisPackage" />
)}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -1,14 +1,19 @@
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { StoreProduct } from '@medusajs/types';
import SelectAnalysisPackage from './select-analysis-package'; import SelectAnalysisPackage, { AnalysisPackageWithVariant } from './select-analysis-package';
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) { export default function SelectAnalysisPackages({
analysisPackages,
countryCode,
}: {
analysisPackages: AnalysisPackageWithVariant[];
countryCode: string;
}) {
return ( return (
<div className="grid grid-cols-3 gap-6"> <div className="grid grid-cols-3 gap-6">
{analysisPackages.length > 0 ? analysisPackages.map( {analysisPackages.length > 0 ? analysisPackages.map(
(product) => ( (analysisPackage) => (
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} /> <SelectAnalysisPackage key={analysisPackage.title} analysisPackage={analysisPackage} countryCode={countryCode} />
)) : ( )) : (
<h4> <h4>
<Trans i18nKey="order-analysis-package:noPackagesAvailable" /> <Trans i18nKey="order-analysis-package:noPackagesAvailable" />

View File

@@ -201,6 +201,28 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
medusa_action: {
Row: {
id: number
medusa_user_id: string
user_email: string
action: string
page: string
created_at: string
}
Insert: {
medusa_user_id: string
user_email: string
action: string
page: string
}
Update: {
medusa_user_id?: string
user_email?: string
action?: string
page?: string
}
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
@@ -314,6 +336,7 @@ export type Database = {
primary_owner_user_id: string primary_owner_user_id: string
public_data: Json public_data: Json
slug: string | null slug: string | null
medusa_account_id: string | null
updated_at: string | null updated_at: string | null
updated_by: string | null updated_by: string | null
} }
@@ -335,6 +358,7 @@ export type Database = {
primary_owner_user_id?: string primary_owner_user_id?: string
public_data?: Json public_data?: Json
slug?: string | null slug?: string | null
medusa_account_id?: string | null
updated_at?: string | null updated_at?: string | null
updated_by?: string | null updated_by?: string | null
} }
@@ -356,6 +380,7 @@ export type Database = {
primary_owner_user_id?: string primary_owner_user_id?: string
public_data?: Json public_data?: Json
slug?: string | null slug?: string | null
medusa_account_id?: string | null
updated_at?: string | null updated_at?: string | null
updated_by?: string | null updated_by?: string | null
} }
@@ -2032,6 +2057,21 @@ export type Database = {
} }
Returns: Json Returns: Json
} }
medipost_retry_dispatch: {
Args: {
order_id: string
}
Returns: {
success: boolean
error: string | null
}
}
get_medipost_dispatch_tries: {
Args: {
p_medusa_order_id: string
}
Returns: number
}
} }
Enums: { Enums: {
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED" analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"

View File

@@ -20,15 +20,20 @@ export function useSignInWithEmailPassword() {
const identities = user?.identities ?? []; const identities = user?.identities ?? [];
if (identities.length === 0) { if (identities.length === 0) {
throw new Error('User already registered'); throw new Error('Invalid user');
} }
if ('email' in credentials) { if ('email' in credentials) {
try { try {
await medusaLoginOrRegister({ const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email, email: credentials.email,
password: credentials.password, password: credentials.password,
}); });
await client
.schema('medreport').from('accounts')
.update({ medusa_account_id: medusaAccountId })
.eq('primary_owner_user_id', user.id)
.eq('is_personal_account', true);
} catch (error) { } catch (error) {
await client.auth.signOut(); await client.auth.signOut();
throw error; throw error;

View File

@@ -40,10 +40,15 @@ export function useSignUpWithEmailAndPassword() {
if ('email' in credentials) { if ('email' in credentials) {
try { try {
await medusaLoginOrRegister({ const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email, email: credentials.email,
password: credentials.password, password: credentials.password,
}); });
await client
.schema('medreport').from('accounts')
.update({ medusa_account_id: medusaAccountId })
.eq('primary_owner_user_id', user!.id)
.eq('is_personal_account', true);
} catch (error) { } catch (error) {
await client.auth.signOut(); await client.auth.signOut();
throw error; throw error;

View File

@@ -30,10 +30,10 @@
"placeholder": "Enter promotion code" "placeholder": "Enter promotion code"
}, },
"items": { "items": {
"analysisPackages": { "synlabAnalyses": {
"productColumnLabel": "Package name" "productColumnLabel": "Analysis name"
}, },
"services": { "ttoServices": {
"productColumnLabel": "Service name" "productColumnLabel": "Service name"
}, },
"delete": { "delete": {

View File

@@ -12,8 +12,7 @@
"ON_HOLD": "Waiting for analysis results", "ON_HOLD": "Waiting for analysis results",
"PROCESSING": "In progress", "PROCESSING": "In progress",
"PARTIAL_ANALYSIS_RESPONSE": "Partial analysis response", "PARTIAL_ANALYSIS_RESPONSE": "Partial analysis response",
"FULL_ANALYSIS_RESPONSE": "All analysis responses", "FULL_ANALYSIS_RESPONSE": "All analysis responses received, waiting for doctor response",
"WAITING_FOR_DOCTOR_RESPONSE": "Waiting for doctor response",
"COMPLETED": "Completed", "COMPLETED": "Completed",
"REJECTED": "Rejected", "REJECTED": "Rejected",
"CANCELLED": "Cancelled" "CANCELLED": "Cancelled"

View File

@@ -31,10 +31,10 @@
"placeholder": "Sisesta promo kood" "placeholder": "Sisesta promo kood"
}, },
"items": { "items": {
"analysisPackages": { "synlabAnalyses": {
"productColumnLabel": "Paketi nimi" "productColumnLabel": "Analüüsi nimi"
}, },
"services": { "ttoServices": {
"productColumnLabel": "Teenuse nimi" "productColumnLabel": "Teenuse nimi"
}, },
"delete": { "delete": {

View File

@@ -12,9 +12,8 @@
"ON_HOLD": "Makstud", "ON_HOLD": "Makstud",
"PROCESSING": "Synlabile edastatud", "PROCESSING": "Synlabile edastatud",
"PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused", "PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused",
"FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes", "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes, ootab arsti kokkuvõtet",
"WAITING_FOR_DOCTOR_RESPONSE": "Ootab arsti kokkuvõtet", "COMPLETED": "Kinnitatud",
"COMPLETED": "Lõplikud tulemused",
"REJECTED": "Tagastatud", "REJECTED": "Tagastatud",
"CANCELLED": "Tühistatud" "CANCELLED": "Tühistatud"
} }

View File

@@ -0,0 +1 @@
ALTER TABLE medreport.accounts ADD COLUMN medusa_account_id TEXT;

View File

@@ -0,0 +1,5 @@
-- user_id+account_id primary key -> separate id column for audit
ALTER TABLE medreport.accounts_memberships DROP CONSTRAINT accounts_memberships_pkey;
ALTER TABLE medreport.accounts_memberships ADD COLUMN id UUID DEFAULT gen_random_uuid();
ALTER TABLE medreport.accounts_memberships ADD CONSTRAINT accounts_memberships_pkey PRIMARY KEY (id);
ALTER TABLE medreport.accounts_memberships ADD CONSTRAINT unique_user_account UNIQUE (user_id, account_id);

View File

@@ -0,0 +1,19 @@
create table "audit"."medusa_action" (
"id" bigint generated by default as identity not null,
"medusa_user_id" text not null,
"user_email" text not null,
"action" text not null,
"page" text,
"created_at" timestamp with time zone not null default now()
);
grant usage on schema audit to authenticated;
grant select, insert, update, delete on table audit.medusa_action to authenticated;
alter table "audit"."medusa_action" enable row level security;
create policy "service_role_select" on "audit"."medusa_action" for select to service_role using (true);
create policy "service_role_insert" on "audit"."medusa_action" for insert to service_role with check (true);
create policy "service_role_update" on "audit"."medusa_action" for update to service_role using (true);
create policy "service_role_delete" on "audit"."medusa_action" for delete to service_role using (true);
grant select, insert, update, delete on table audit.medusa_action to service_role;

View File

@@ -0,0 +1,36 @@
CREATE TABLE "audit"."medipost_dispatch" (
"id" bigint generated by default as identity not null,
"medusa_order_id" text not null,
"is_medipost_error" boolean not null,
"is_success" boolean not null,
"error_message" text,
"created_at" timestamp with time zone not null default now(),
"changed_by" uuid default auth.uid()
);
grant usage on schema audit to authenticated;
grant select, insert, update, delete on table audit.medipost_dispatch to authenticated;
grant usage on schema medreport to service_role;
alter table "audit"."medipost_dispatch" enable row level security;
create policy "service_role_select" on "audit"."medipost_dispatch" for select to service_role using (true);
create policy "service_role_insert" on "audit"."medipost_dispatch" for insert to service_role with check (true);
create policy "service_role_update" on "audit"."medipost_dispatch" for update to service_role using (true);
create policy "service_role_delete" on "audit"."medipost_dispatch" for delete to service_role using (true);
CREATE OR REPLACE FUNCTION medreport.get_medipost_dispatch_tries(p_medusa_order_id text)
returns integer
language plpgsql
security definer
as $function$
declare
tries integer;
begin
select count(*) from audit.medipost_dispatch m where m.medusa_order_id = p_medusa_order_id and m.created_at > now() - interval '1 day' and m.is_success = false into tries;
return tries;
end;
$function$;
grant execute on function medreport.get_medipost_dispatch_tries(text) to service_role;
grant select, insert, update, delete on table audit.medipost_dispatch to service_role;

View File

@@ -0,0 +1,42 @@
create extension if not exists pg_net;
create or replace function medreport.medipost_retry_dispatch(
order_id text
)
returns jsonb
language plpgsql
as $function$
declare
response_result record;
begin
select into response_result
net.http_post(
url := 'https://test.medreport.ee/api/job/medipost-retry-dispatch',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
),
body := jsonb_build_object(
'medusaOrderId', order_id
)::text
) as request_id;
return jsonb_build_object(
'success', true
);
exception
when others then
return jsonb_build_object(
'success', false
);
end;
$function$;
grant execute on function medreport.medipost_retry_dispatch(text) to service_role;
comment on function medreport.medipost_retry_dispatch(text) is
'Manually trigger a medipost retry dispatch for a specific order ID.
Parameters:
- order_id: The medusa order ID to retry dispatch for
Returns: JSONB with success status and request details';

View File

@@ -0,0 +1,25 @@
CREATE OR REPLACE FUNCTION medreport.get_order_possible_actions(p_medusa_order_id text)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
order_status text;
is_queued boolean;
BEGIN
-- Get the analysis order status
SELECT status INTO order_status
FROM medreport.analysis_orders
WHERE medusa_order_id = p_medusa_order_id;
-- Check if status is QUEUED
is_queued := (order_status = 'QUEUED');
-- Return JSON object with actions and their allowed status
RETURN jsonb_build_object(
'retry_dispatch', is_queued,
'mark_as_not_received_by_synlab', is_queued
);
END;
$$;
grant execute on function medreport.get_order_possible_actions(text) to service_role;

View File

@@ -1,4 +1,8 @@
export const getAnalysisElementMedusaProductIds = (products: ({ metadata?: { analysisElementMedusaProductIds?: string } | null } | null)[]) => { export const getAnalysisElementMedusaProductIds = (products: ({
metadata?: {
analysisElementMedusaProductIds?: string;
} | null;
} | null)[]) => {
if (!products) { if (!products) {
return []; return [];
} }