Merge branch 'main' into MED-57

This commit is contained in:
Danel Kungla
2025-08-18 15:15:27 +03:00
49 changed files with 838 additions and 332 deletions

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { getAnalysisOrdersAdmin } from "~/lib/services/order.service";
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from "~/lib/services/account.service";
import { getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
export async function POST(request: NextRequest) {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
const analysisOrders = await getAnalysisOrdersAdmin({ orderStatus: 'QUEUED' });
console.error(`Sending test responses for ${analysisOrders.length} analysis orders`);
for (const medreportOrder of analysisOrders) {
const medusaOrderId = medreportOrder.medusa_order_id;
const medusaOrder = await retrieveOrder(medusaOrderId)
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
const idsToSend = orderedAnalysisElementsIds;
const messageXml = await composeOrderTestResponseXML({
person: {
idCode: account.personal_code!,
firstName: account.name ?? '',
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
});
console.info("SEND XML", messageXml);
try {
await sendPrivateMessageTestResponse({ messageXml });
} catch (error) {
console.error("Error sending private message test response: ", error);
}
}
return NextResponse.json({ success: true });
}

View File

@@ -1,8 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { sendOrderToMedipost } from "~/lib/services/medipost.service";
export const POST = async (request: NextRequest) => {
const { medusaOrderId } = (await request.json()) as { medusaOrderId: string };
await sendOrderToMedipost({ medusaOrderId });
return NextResponse.json({ success: true });
};

View File

@@ -11,7 +11,7 @@ export async function POST(request: Request) {
// return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 }); // return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
// } // }
const { medusaOrderId } = await request.json(); const { medusaOrderId, maxItems = null } = await request.json();
const medusaOrder = await retrieveOrder(medusaOrderId) const medusaOrder = await retrieveOrder(medusaOrderId)
const medreportOrder = await getOrder({ medusaOrderId }); const medreportOrder = await getOrder({ medusaOrderId });
@@ -19,7 +19,8 @@ export async function POST(request: Request) {
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder }); const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`); console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} (${maxItems ?? 'all'}) ordered analysis elements`);
const idsToSend = typeof maxItems === 'number' ? orderedAnalysisElementsIds.slice(0, maxItems) : orderedAnalysisElementsIds;
const messageXml = await composeOrderTestResponseXML({ const messageXml = await composeOrderTestResponseXML({
person: { person: {
idCode: account.personal_code!, idCode: account.personal_code!,
@@ -27,7 +28,7 @@ export async function POST(request: Request) {
lastName: account.last_name ?? '', lastName: account.last_name ?? '',
phone: account.phone ?? '', phone: account.phone ?? '',
}, },
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId), orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [], orderedAnalysesIds: [],
orderId: medusaOrderId, orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at), orderCreatedAt: new Date(medreportOrder.created_at),

View File

@@ -1,8 +1,9 @@
import React from 'react'; import React, { useMemo } from 'react';
import { ArrowDown } from 'lucide-react'; import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
export enum AnalysisResultLevel { export enum AnalysisResultLevel {
VERY_LOW = 0, VERY_LOW = 0,
@@ -17,11 +18,13 @@ const Level = ({
color, color,
isFirst = false, isFirst = false,
isLast = false, isLast = false,
arrowLocation,
}: { }: {
isActive?: boolean; isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200'; color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
arrowLocation?: number;
}) => { }) => {
return ( return (
<div <div
@@ -32,7 +35,10 @@ const Level = ({
})} })}
> >
{isActive && ( {isActive && (
<div className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"> <div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
style={{ left: `${arrowLocation}%` }}
>
<ArrowDown strokeWidth={2} /> <ArrowDown strokeWidth={2} />
</div> </div>
)} )}
@@ -52,11 +58,33 @@ const AnalysisLevelBar = ({
normLowerIncluded = true, normLowerIncluded = true,
normUpperIncluded = true, normUpperIncluded = true,
level, level,
results,
}: { }: {
normLowerIncluded?: boolean; normLowerIncluded?: boolean;
normUpperIncluded?: boolean; normUpperIncluded?: boolean;
level: AnalysisResultLevel; level: AnalysisResultLevel;
results: UserAnalysisElement;
}) => { }) => {
const { norm_lower: lower, norm_upper: upper, response_value: value } = results;
const arrowLocation = useMemo(() => {
if (value < lower!) {
return 0;
}
if (normLowerIncluded || normUpperIncluded) {
return 50;
}
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
if (calculated > 100) {
return 100;
}
return calculated;
}, [value, upper, lower]);
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 && (
@@ -73,8 +101,9 @@ const AnalysisLevelBar = ({
<Level <Level
isFirst={!normLowerIncluded} isFirst={!normLowerIncluded}
isLast={!normUpperIncluded} isLast={!normUpperIncluded}
isActive={level === AnalysisResultLevel.NORMAL} color={level === AnalysisResultLevel.NORMAL ? "success" : "warning"}
color="success" isActive
arrowLocation={arrowLocation}
/> />
{normUpperIncluded && ( {normUpperIncluded && (

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -39,11 +39,12 @@ const Analysis = ({
const normUpper = results?.norm_upper || 0; const normUpper = results?.norm_upper || 0;
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const isUnderNorm = value < normLower; const analysisResultLevel = useMemo(() => {
const getAnalysisResultLevel = () => {
if (!results) { if (!results) {
return null; return null;
} }
const isUnderNorm = value < normLower;
if (isUnderNorm) { if (isUnderNorm) {
switch (status) { switch (status) {
case AnalysisStatus.MEDIUM: case AnalysisStatus.MEDIUM:
@@ -60,7 +61,7 @@ const Analysis = ({
default: default:
return AnalysisResultLevel.NORMAL; return AnalysisResultLevel.NORMAL;
} }
}; }, [results, value, normLower]);
return ( return (
<div className="border-border flex flex-col items-center justify-between gap-2 rounded-lg border px-5 px-12 py-3 sm:h-[65px] sm:flex-row sm:gap-0"> <div className="border-border flex flex-col items-center justify-between gap-2 rounded-lg border px-5 px-12 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
@@ -99,9 +100,10 @@ const Analysis = ({
</div> </div>
</div> </div>
<AnalysisLevelBar <AnalysisLevelBar
results={results}
normLowerIncluded={normLowerIncluded} normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded} normUpperIncluded={normUpperIncluded}
level={getAnalysisResultLevel()!} level={analysisResultLevel!}
/> />
</> </>
) : ( ) : (

View File

@@ -15,10 +15,11 @@ import {
PAGE_VIEW_ACTION, PAGE_VIEW_ACTION,
createPageViewLog, createPageViewLog,
} from '~/lib/services/audit/pageView.service'; } from '~/lib/services/audit/pageView.service';
import { getAnalysisOrders } from '~/lib/services/order.service'; import { AnalysisOrder, getAnalysisOrders } from '~/lib/services/order.service';
import { ButtonTooltip } from '~/components/ui/button-tooltip';
import { loadUserAnalysis } from '../../_lib/server/load-user-analysis';
import Analysis from './_components/analysis'; import Analysis from './_components/analysis';
import { loadUserAnalysis } from '../../_lib/server/load-user-analysis';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -51,43 +52,16 @@ async function AnalysisResultsPage() {
action: PAGE_VIEW_ACTION.VIEW_ANALYSIS_RESULTS, action: PAGE_VIEW_ACTION.VIEW_ANALYSIS_RESULTS,
}); });
const analysisElementIds = [ const getAnalysisElementIds = (analysisOrders: AnalysisOrder[]) => [
...new Set( ...new Set(analysisOrders?.flatMap((order) => order.analysis_element_ids).filter(Boolean) as number[]),
analysisOrders
?.flatMap((order) => order.analysis_element_ids)
.filter(Boolean) as number[],
),
]; ];
const analysisElements = await getAnalysisElements({
ids: analysisElementIds,
});
const analysisElementsWithResults =
analysisResponseElements
?.sort((a, b) => {
if (!a.response_time || !b.response_time) {
return 0;
}
return (
new Date(b.response_time).getTime() -
new Date(a.response_time).getTime()
);
})
.map((results) => ({ results })) ?? [];
const analysisElementsWithoutResults = analysisElements.filter(
(element) =>
!analysisElementsWithResults?.some(
({ results }) =>
results.analysis_element_original_id === element.analysis_id_original,
),
);
const hasNoAnalysisElements = const analysisElementIds = getAnalysisElementIds(analysisOrders);
analysisElementsWithResults.length === 0 && const analysisElements = await getAnalysisElements({ ids: analysisElementIds });
analysisElementsWithoutResults.length === 0;
return ( return (
<PageBody> <PageBody className="gap-4">
<div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0"> <div className="mt-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0">
<div> <div>
<h4> <h4>
<Trans i18nKey="analysis-results:pageTitle" /> <Trans i18nKey="analysis-results:pageTitle" />
@@ -106,33 +80,46 @@ async function AnalysisResultsPage() {
</Link> </Link>
</Button> </Button>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-8">
{analysisElementsWithResults.map(({ results }) => { {analysisOrders.length > 0 && analysisElements.length > 0 ? analysisOrders.map((analysisOrder) => {
const analysisElement = analysisElements.find( const analysisResponse = analysisResponses?.find((response) => response.analysis_order_id === analysisOrder.id);
(element) => const analysisElementIds = getAnalysisElementIds([analysisOrder]);
element.analysis_id_original === const analysisElementsForOrder = analysisElements.filter((element) => analysisElementIds.includes(element.id));
results.analysis_element_original_id,
);
if (!analysisElement) {
return null;
}
return ( return (
<Analysis <div key={analysisOrder.id} className="flex flex-col gap-4">
key={results.id} <h4>
analysisElement={analysisElement} <Trans i18nKey="analysis-results:orderTitle" values={{ orderNumber: analysisOrder.medusa_order_id }} />
results={results} </h4>
/> <h5>
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
<ButtonTooltip
content={`${new Date(analysisOrder.created_at).toLocaleString()}`}
className="ml-6"
/>
</h5>
<div className="flex flex-col gap-2">
{analysisElementsForOrder.length > 0 ? analysisElementsForOrder.map((analysisElement) => {
const results = analysisResponse?.elements.some((element) => element.analysis_element_original_id === analysisElement.analysis_id_original)
&& analysisResponseElements?.find((element) => element.analysis_element_original_id === analysisElement.analysis_id_original);
if (!results) {
return (
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} />
);
}
return (
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} results={results} />
);
}) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" />
</div>
)}
</div>
</div>
); );
})} }) : (
{analysisElementsWithoutResults.map((element) => (
<Analysis
key={element.analysis_id_original}
analysisElement={element}
/>
))}
{hasNoAnalysisElements && (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" /> <Trans i18nKey="analysis-results:noAnalysisOrders" />
</div> </div>
)} )}
</div> </div>

View File

@@ -9,6 +9,9 @@ import { placeOrder, retrieveCart } from "@lib/data/cart";
import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
import { createOrder } from '~/lib/services/order.service'; import { createOrder } from '~/lib/services/order.service';
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
import { createNotificationsApi } from '@kit/notifications/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { AccountWithParams } from '@kit/accounts/api';
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
const MONTONIO_PAID_STATUS = 'PAID'; const MONTONIO_PAID_STATUS = 'PAID';
@@ -31,7 +34,22 @@ const env = () => z
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
}); });
const sendEmail = async ({ email, analysisPackageName, personName, partnerLocationName, language }: { email: string, analysisPackageName: string, personName: string, partnerLocationName: string, language: string }) => { const sendEmail = async ({
account,
email,
analysisPackageName,
personName,
partnerLocationName,
language,
}: {
account: AccountWithParams,
email: string,
analysisPackageName: string,
personName: string,
partnerLocationName: string,
language: string,
}) => {
const client = getSupabaseServerAdminClient();
try { try {
const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates'); const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers'); const { getMailer } = await import('@kit/mailers');
@@ -55,6 +73,11 @@ const sendEmail = async ({ email, analysisPackageName, personName, partnerLocati
.catch((error) => { .catch((error) => {
throw new Error(`Failed to send email, message=${error}`); throw new Error(`Failed to send email, message=${error}`);
}); });
await createNotificationsApi(client)
.createNotification({
account_id: account.id,
body: html,
});
} catch (error) { } catch (error) {
throw new Error(`Failed to send email, message=${error}`); throw new Error(`Failed to send email, message=${error}`);
} }
@@ -62,13 +85,13 @@ const sendEmail = async ({ email, analysisPackageName, personName, partnerLocati
export async function processMontonioCallback(orderToken: string) { export async function processMontonioCallback(orderToken: string) {
const { language } = await createI18nServerInstance(); const { language } = await createI18nServerInstance();
const secretKey = process.env.MONTONIO_SECRET_KEY as string; const secretKey = process.env.MONTONIO_SECRET_KEY as string;
const decoded = jwt.verify(orderToken, secretKey, { const decoded = jwt.verify(orderToken, secretKey, {
algorithms: ['HS256'], algorithms: ['HS256'],
}) as MontonioOrderToken; }) as MontonioOrderToken;
if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) { if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) {
throw new Error("Payment not successful"); throw new Error("Payment not successful");
} }
@@ -79,7 +102,7 @@ export async function processMontonioCallback(orderToken: string) {
} }
try { try {
const [,, cartId] = decoded.merchantReferenceDisplay.split(':'); const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
if (!cartId) { if (!cartId) {
throw new Error("Cart ID not found"); throw new Error("Cart ID not found");
} }
@@ -89,6 +112,7 @@ export async function processMontonioCallback(orderToken: string) {
throw new Error("Cart not found"); throw new Error("Cart not found");
} }
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder }); const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements }); const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
@@ -96,7 +120,7 @@ export async function processMontonioCallback(orderToken: string) {
const { productTypes } = await listProductTypes(); const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
const orderResult = { const orderResult = {
medusaOrderId: medusaOrder.id, medusaOrderId: medusaOrder.id,
email: medusaOrder.email, email: medusaOrder.email,
@@ -107,10 +131,10 @@ export async function processMontonioCallback(orderToken: string) {
const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult; const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult;
const personName = account.name; const personName = account.name;
if (email && analysisPackageName) { if (email && analysisPackageName) {
try { try {
await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language }); await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language });
} catch (error) { } catch (error) {
console.error("Failed to send email", error); console.error("Failed to send email", error);
} }
@@ -118,9 +142,9 @@ export async function processMontonioCallback(orderToken: string) {
// @TODO send email for separate analyses // @TODO send email for separate analyses
console.error("Missing email or analysisPackageName", orderResult); console.error("Missing email or analysisPackageName", orderResult);
} }
sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
return { success: true, orderId }; return { success: true, orderId };
} catch (error) { } catch (error) {
console.error("Failed to place order", error); console.error("Failed to place order", error);

View File

@@ -32,11 +32,12 @@ export default async function CartPage() {
const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1); const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
const item = otherItemsSorted[0]; const item = otherItemsSorted[0];
const hasItemsWithTimer = false as boolean;
return ( return (
<PageBody> <PageBody>
<PageHeader title={<Trans i18nKey="cart:title" />}> <PageHeader title={<Trans i18nKey="cart:title" />}>
{item && item.updated_at && <CartTimer cartItem={item} />} {hasItemsWithTimer && item && item.updated_at && <CartTimer cartItem={item} />}
</PageHeader> </PageHeader>
<Cart cart={cart} analysisPackages={analysisPackages} otherItems={otherItems} /> <Cart cart={cart} analysisPackages={analysisPackages} otherItems={otherItems} />
</PageBody> </PageBody>

View File

@@ -5,6 +5,8 @@ import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import { loadAnalyses } from '../../_lib/server/load-analyses'; import { loadAnalyses } from '../../_lib/server/load-analyses';
import OrderAnalysesCards from '../../_components/order-analyses-cards'; import OrderAnalysesCards from '../../_components/order-analyses-cards';
import { createPageViewLog, PAGE_VIEW_ACTION } from '~/lib/services/audit/pageView.service';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -15,8 +17,18 @@ export const generateMetadata = async () => {
}; };
async function OrderAnalysisPage() { async function OrderAnalysisPage() {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
const { analyses, countryCode } = await loadAnalyses(); const { analyses, countryCode } = await loadAnalyses();
await createPageViewLog({
accountId: account.id,
action: PAGE_VIEW_ACTION.VIEW_ORDER_ANALYSIS,
});
return ( return (
<> <>
<HomeLayoutPageHeader <HomeLayoutPageHeader

View File

@@ -7,10 +7,11 @@ import { PageBody } from '@kit/ui/makerkit/page';
import pathsConfig from '~/config/paths.config'; import pathsConfig from '~/config/paths.config';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import OrdersTable from '../../_components/orders/orders-table';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import type { IOrderLineItem } from '../../_components/orders/types';
import { getAnalysisOrders } from '~/lib/services/order.service'; import { getAnalysisOrders } from '~/lib/services/order.service';
import OrderBlock from '../../_components/orders/order-block';
import React from 'react';
import { Divider } from '@medusajs/ui';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -29,42 +30,7 @@ async function OrdersPage() {
redirect(pathsConfig.auth.signIn); redirect(pathsConfig.auth.signIn);
} }
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages'); const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages')!;
const analysisPackageOrders: IOrderLineItem[] = medusaOrders.flatMap(({ id, items, payment_status, fulfillment_status }) => items
?.filter((item) => item.product_type_id === analysisPackagesType?.id)
.map((item) => {
const localOrder = analysisOrders.find((order) => order.medusa_order_id === id);
if (!localOrder) {
return null;
}
return {
item,
medusaOrderId: id,
orderId: localOrder?.id,
orderStatus: localOrder.status,
analysis_element_ids: localOrder.analysis_element_ids,
}
})
.filter((order) => order !== null)
|| []);
const otherOrders: IOrderLineItem[] = medusaOrders
.filter(({ items }) => items?.some((item) => item.product_type_id !== analysisPackagesType?.id))
.flatMap(({ id, items, payment_status, fulfillment_status }) => items
?.map((item) => {
const analysisOrder = analysisOrders.find((order) => order.medusa_order_id === id);
if (!analysisOrder) {
return null;
}
return {
item,
medusaOrderId: id,
orderId: analysisOrder.id,
orderStatus: analysisOrder.status,
}
})
.filter((order) => order !== null)
|| []);
return ( return (
<> <>
@@ -73,8 +39,27 @@ async function OrdersPage() {
description={<Trans i18nKey={'orders:description'} />} description={<Trans i18nKey={'orders:description'} />}
/> />
<PageBody> <PageBody>
<OrdersTable orderItems={analysisPackageOrders} title="orders:table.analysisPackage" /> {analysisOrders.map((analysisOrder) => {
<OrdersTable orderItems={otherOrders} title="orders:table.otherOrders" /> const medusaOrder = medusaOrders.find(({ id }) => id === analysisOrder.medusa_order_id);
if (!medusaOrder) {
return null;
}
const medusaOrderItems = medusaOrder.items || [];
const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter((item) => item.product_type_id === analysisPackagesType?.id);
const medusaOrderItemsOther = medusaOrderItems.filter((item) => item.product_type_id !== analysisPackagesType?.id);
return (
<React.Fragment key={analysisOrder.id}>
<Divider className="my-6" />
<OrderBlock
analysisOrder={analysisOrder}
itemsAnalysisPackage={medusaOrderItemsAnalysisPackages}
itemsOther={medusaOrderItemsOther}
/>
</React.Fragment>
)
})}
</PageBody> </PageBody>
</> </>
); );

View File

@@ -8,6 +8,7 @@ import { toTitleCase } from '@/lib/utils';
import Dashboard from '../_components/dashboard'; import Dashboard from '../_components/dashboard';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
import DashboardCards from '../_components/dashboard-cards';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -26,6 +27,7 @@ async function UserHomePage() {
return ( return (
<> <>
<DashboardCards />
<PageHeader title={ <PageHeader title={
<> <>
<Trans i18nKey={'common:welcome'} /> <Trans i18nKey={'common:welcome'} />

View File

@@ -5,8 +5,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from 'sonner'; import { toast } from 'sonner';
import { deleteLineItem } from "@lib/data/cart";
import { Spinner } from "@medusajs/icons"; import { Spinner } from "@medusajs/icons";
import { handleDeleteCartItem } from "~/lib/services/medusaCart.service";
const CartItemDelete = ({ const CartItemDelete = ({
id, id,
@@ -22,7 +22,7 @@ const CartItemDelete = ({
setIsDeleting(true); setIsDeleting(true);
const promise = async () => { const promise = async () => {
await deleteLineItem(id); await handleDeleteCartItem({ lineId: id });
}; };
toast.promise(promise, { toast.promise(promise, {

View File

@@ -0,0 +1,45 @@
import { Trans } from '@kit/ui/trans';
import {
Card,
CardHeader,
CardDescription,
CardFooter,
} from '@kit/ui/card';
import Link from 'next/link';
import { Button } from '@kit/ui/button';
import { ChevronRight, HeartPulse } from 'lucide-react';
export default function DashboardCards() {
return (
<div className='flex gap-4 lg:px-4'>
<Card
variant="gradient-success"
className="flex flex-col justify-between"
>
<CardHeader className="flex-row">
<div
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'}
>
<HeartPulse className="size-4 fill-green-500" />
</div>
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
<Link href='/home/order-analysis'>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
</div>
</CardHeader>
<CardFooter className="flex flex-col items-start gap-2">
<h5>
<Trans i18nKey='dashboard:heroCard.orderAnalysis.title' />
</h5>
<CardDescription className="text-primary">
<Trans i18nKey='dashboard:heroCard.orderAnalysis.description' />
</CardDescription>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -7,11 +7,14 @@ import {
Card, Card,
CardHeader, CardHeader,
CardFooter, CardFooter,
CardDescription,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { StoreProduct, StoreProductVariant } from '@medusajs/types'; import { StoreProduct, StoreProductVariant } 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 '~/components/ui/info-tooltip';
import { Trans } from '@kit/ui/trans';
export default function OrderAnalysesCards({ export default function OrderAnalysesCards({
analyses, analyses,
@@ -21,7 +24,7 @@ export default function OrderAnalysesCards({
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 (selectedVariant: StoreProductVariant) => {
if (!selectedVariant?.id || isAddingToCart) return null if (!selectedVariant?.id || isAddingToCart) return null
@@ -44,37 +47,62 @@ 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 variants,
}) => ( description,
<Card subtitle,
key={title} status,
variant="gradient-success" metadata,
className="flex flex-col justify-between" }) => {
> const isAvailable = status === 'published' && !!metadata?.analysisIdOriginal;
<CardHeader className="items-end-safe"> return (
<div className='flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'> <Card
<Button key={title}
size="icon" variant={isAvailable ? "gradient-success" : "gradient-warning"}
variant="outline" className="flex flex-col justify-between"
className="px-2 text-black" >
onClick={() => handleSelect(variants![0]!)} <CardHeader className="flex-row">
<div
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'}
> >
{isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />} <HeartPulse className="size-4 fill-green-500" />
</Button> </div>
</div> {isAvailable && (
</CardHeader> <div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
<CardFooter className="flex flex-col items-start gap-2"> <Button
<div size="icon"
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'} variant="outline"
> className="px-2 text-black"
<HeartPulse className="size-4 fill-green-500" /> onClick={() => handleSelect(variants![0]!)}
</div> >
<h5> {isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
{title} </Button>
</h5> </div>
</CardFooter> )}
</Card> </CardHeader>
))} <CardFooter className="flex flex-col items-start gap-2">
<h5>
{title}
{description && (
<>
{' '}
<InfoTooltip content={`${description}`} />
</>
)}
</h5>
{isAvailable && subtitle && (
<CardDescription>
{subtitle}
</CardDescription>
)}
{!isAvailable && (
<CardDescription>
<Trans i18nKey={'order-analysis:analysisNotAvailable'} />
</CardDescription>
)}
</CardFooter>
</Card>
);
})}
</div> </div>
); );
} }

View File

@@ -0,0 +1,36 @@
import { AnalysisOrder } from "~/lib/services/order.service";
import { Trans } from '@kit/ui/makerkit/trans';
import { StoreOrderLineItem } from "@medusajs/types";
import OrderItemsTable from "./order-items-table";
import Link from "next/link";
import { Eye } from "lucide-react";
export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsOther }: {
analysisOrder: AnalysisOrder,
itemsAnalysisPackage: StoreOrderLineItem[],
itemsOther: StoreOrderLineItem[],
}) {
return (
<div className="flex flex-col gap-4">
<h4>
<Trans i18nKey="analysis-results:orderTitle" values={{ orderNumber: analysisOrder.medusa_order_id }} />
</h4>
<div className="flex gap-2">
<h5>
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
</h5>
<Link href={`/home/order/${analysisOrder.id}`} className="flex items-center justify-between text-small-regular">
<button
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
>
<Eye />
</button>
</Link>
</div>
<div className="flex flex-col gap-4">
<OrderItemsTable items={itemsAnalysisPackage} title="orders:table.analysisPackage" analysisOrder={analysisOrder} />
<OrderItemsTable items={itemsOther} title="orders:table.otherOrders" analysisOrder={analysisOrder} />
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { Trans } from '@kit/ui/trans';
import {
Table,
TableBody,
TableHead,
TableRow,
TableHeader,
TableCell,
} from '@kit/ui/table';
import { StoreOrderLineItem } from "@medusajs/types";
import { AnalysisOrder } from '~/lib/services/order.service';
import { formatDate } from 'date-fns';
import Link from 'next/link';
import { Eye } from 'lucide-react';
export default function OrderItemsTable({ items, title, analysisOrder }: {
items: StoreOrderLineItem[];
title: string;
analysisOrder: AnalysisOrder;
}) {
if (!items || items.length === 0) {
return null;
}
return (
<Table className="rounded-lg border border-separate">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6">
<Trans i18nKey={title} />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.createdAt" />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.status" />
</TableHead>
<TableHead className="px-6">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1)
.map((orderItem) => (
<TableRow className="w-full" key={orderItem.id}>
<TableCell className="text-left w-[100%] px-6">
<p className="txt-medium-plus text-ui-fg-base">
{orderItem.product_title}
</p>
</TableCell>
<TableCell className="px-6 whitespace-nowrap">
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell>
<TableCell className="px-6 min-w-[180px]">
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
</TableCell>
<TableCell className="text-right px-6">
<span className="flex gap-x-1 justify-end w-[30px]">
<Link href={`/home/analysis-results`} className="flex items-center justify-between text-small-regular">
<button
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
>
<Eye />
</button>
</Link>
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@@ -1,43 +0,0 @@
import {
TableCell,
TableRow,
} from '@kit/ui/table';
import { Eye } from "lucide-react";
import Link from "next/link";
import { formatDate } from "date-fns";
import { IOrderLineItem } from "./types";
import { Trans } from '@kit/ui/trans';
export default function OrdersItem({ orderItem }: {
orderItem: IOrderLineItem,
}) {
return (
<TableRow className="w-full">
<TableCell className="text-left w-[100%] px-6">
<p className="txt-medium-plus text-ui-fg-base">
{orderItem.item.product_title}
</p>
</TableCell>
<TableCell className="px-6 whitespace-nowrap">
{formatDate(orderItem.item.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell>
<TableCell className="px-6 whitespace-nowrap">
<Trans i18nKey={`orders:status.${orderItem.orderStatus}`} />
</TableCell>
<TableCell className="text-right px-6">
<span className="flex gap-x-1 justify-end w-[60px]">
<Link href={`/home/order/${orderItem.orderId}`} className="flex items-center justify-between text-small-regular">
<button
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
>
<Eye />
</button>
</Link>
</span>
</TableCell>
</TableRow>
)
}

View File

@@ -1,44 +0,0 @@
import { Trans } from '@kit/ui/trans';
import {
Table,
TableBody,
TableHead,
TableRow,
TableHeader,
} from '@kit/ui/table';
import OrdersItem from "./orders-item";
import { IOrderLineItem } from "./types";
export default function OrdersTable({ orderItems, title }: {
orderItems: IOrderLineItem[];
title: string;
}) {
if (!orderItems || orderItems.length === 0) {
return null;
}
return (
<Table className="rounded-lg border border-separate">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6">
<Trans i18nKey={title} />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.createdAt" />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.status" />
</TableHead>
<TableHead className="px-6">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orderItems
.sort((a, b) => (a.item.created_at ?? "") > (b.item.created_at ?? "") ? -1 : 1)
.map((orderItem) => (<OrdersItem key={orderItem.item.id} orderItem={orderItem} />))}
</TableBody>
</Table>
)
}

View File

@@ -1,8 +0,0 @@
import { StoreOrderLineItem } from "@medusajs/types";
export interface IOrderLineItem {
item: StoreOrderLineItem;
medusaOrderId: string;
orderId: number;
orderStatus: string;
}

View File

@@ -1,5 +1,6 @@
import type { User } from '@supabase/supabase-js'; import type { User } from '@supabase/supabase-js';
import { ApplicationRole } from '@kit/accounts/types/accounts';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -18,6 +19,7 @@ type AccountModel = {
label: string | null; label: string | null;
value: string | null; value: string | null;
image: string | null; image: string | null;
application_role: ApplicationRole | null;
}; };
export function TeamAccountLayoutSidebar(props: { export function TeamAccountLayoutSidebar(props: {

View File

@@ -1,7 +1,4 @@
import { import { useMemo } from 'react';
BorderedNavigationMenu,
BorderedNavigationMenuItem,
} from '@kit/ui/bordered-navigation-menu';
import { AppLogo } from '~/components/app-logo'; import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
@@ -10,18 +7,22 @@ import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.co
// local imports // local imports
import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader'; import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader';
import { TeamAccountNotifications } from './team-account-notifications'; import { TeamAccountNotifications } from './team-account-notifications';
import { useMemo } from 'react';
export function TeamAccountNavigationMenu(props: { export function TeamAccountNavigationMenu(props: {
workspace: TeamAccountWorkspace; workspace: TeamAccountWorkspace;
}) { }) {
const { account, user, accounts: rawAccounts } = props.workspace; const { account, user, accounts: rawAccounts } = props.workspace;
const accounts = useMemo(() => rawAccounts.map((account) => ({ const accounts = useMemo(
label: account.name, () =>
value: account.slug, rawAccounts.map((account) => ({
image: account.picture_url, label: account.name,
})),[rawAccounts]) value: account.slug,
image: account.picture_url,
application_role: account.application_role,
})),
[rawAccounts],
);
const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce< const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce<
Array<{ Array<{
@@ -48,7 +49,7 @@ export function TeamAccountNavigationMenu(props: {
<AppLogo /> <AppLogo />
</div> </div>
<div className={'flex items-center justify-end space-x-2.5 gap-2'}> <div className={'flex items-center justify-end gap-2 space-x-2.5'}>
<TeamAccountNotifications accountId={account.id} userId={user.id} /> <TeamAccountNotifications accountId={account.id} userId={user.id} />
<ProfileAccountDropdownContainer <ProfileAccountDropdownContainer
user={user} user={user}

View File

@@ -45,11 +45,14 @@ function SidebarLayout({
const data = use(loadTeamWorkspace(account)); const data = use(loadTeamWorkspace(account));
const state = use(getLayoutState(account)); const state = use(getLayoutState(account));
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ const accounts = data.accounts.map(
label: name, ({ name, slug, picture_url, application_role }) => ({
value: slug, label: name,
image: picture_url, value: slug,
})); image: picture_url,
application_role,
}),
);
return ( return (
<TeamAccountWorkspaceContextProvider value={data}> <TeamAccountWorkspaceContextProvider value={data}>
@@ -91,11 +94,14 @@ function HeaderLayout({
}>) { }>) {
const data = use(loadTeamWorkspace(account)); const data = use(loadTeamWorkspace(account));
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ const accounts = data.accounts.map(
label: name, ({ name, slug, picture_url, application_role }) => ({
value: slug, label: name,
image: picture_url, value: slug,
})); image: picture_url,
application_role,
}),
);
return ( return (
<TeamAccountWorkspaceContextProvider value={data}> <TeamAccountWorkspaceContextProvider value={data}>

View File

@@ -3,6 +3,7 @@
import type { User } from '@supabase/supabase-js'; import type { User } from '@supabase/supabase-js';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { ApplicationRole } from '@kit/accounts/types/accounts';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
@@ -28,13 +29,13 @@ export function ProfileAccountDropdownContainer(props: {
id: string | null; id: string | null;
name: string | null; name: string | null;
picture_url: string | null; picture_url: string | null;
application_role: string; application_role: ApplicationRole | null;
}; };
accounts: { accounts: {
label: string | null; label: string | null;
value: string | null; value: string | null;
image?: string | null; image?: string | null;
application_role: string; application_role: ApplicationRole | null;
}[]; }[];
}) { }) {
const signOut = useSignOut(); const signOut = useSignOut();

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { use, useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';

View File

@@ -3,6 +3,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
export enum PAGE_VIEW_ACTION { export enum PAGE_VIEW_ACTION {
VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS', VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS',
REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS', REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS',
VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS',
} }
export const createPageViewLog = async ({ export const createPageViewLog = async ({

View File

@@ -36,7 +36,7 @@ import { uniqBy } from 'lodash';
import { Tables } from '@kit/supabase/database'; import { Tables } from '@kit/supabase/database';
import { createAnalysisGroup } from './analysis-group.service'; import { createAnalysisGroup } from './analysis-group.service';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { getOrder, updateOrder } from './order.service'; import { getOrder, updateOrderStatus } from './order.service';
import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { getAnalyses } from './analyses.service'; import { getAnalyses } from './analyses.service';
import { getAccountAdmin } from './account.service'; import { getAccountAdmin } from './account.service';
@@ -218,24 +218,27 @@ export async function readPrivateMessageResponse({
privateMessage.messageId, privateMessage.messageId,
); );
const messageResponse = privateMessageContent?.Saadetis?.Vastus; const messageResponse = privateMessageContent?.Saadetis?.Vastus;
const medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
if (!messageResponse) { if (!messageResponse) {
throw new Error(`Private message response has no results yet`); throw new Error(`Private message response has no results yet for order=${medusaOrderId}`);
} }
console.info(`Private message content: ${JSON.stringify(privateMessageContent)}`);
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
try { try {
order = await getOrder({ medusaOrderId: messageResponse.ValisTellimuseId }); order = await getOrder({ medusaOrderId });
} catch (e) { } catch (e) {
await deletePrivateMessage(privateMessage.messageId); await deletePrivateMessage(privateMessage.messageId);
throw new Error(`Order not found by Medipost message ValisTellimuseId=${messageResponse.ValisTellimuseId}`); throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`);
} }
const status = await syncPrivateMessage({ messageResponse, order }); const status = await syncPrivateMessage({ messageResponse, order });
if (status === 'COMPLETED') { if (status.isPartial) {
await updateOrder({ orderId: order.id, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
messageIdProcessed = privateMessage.messageId;
} else if (status.isCompleted) {
await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
await deletePrivateMessage(privateMessage.messageId); await deletePrivateMessage(privateMessage.messageId);
messageIdProcessed = privateMessage.messageId; messageIdProcessed = privateMessage.messageId;
} }
@@ -559,11 +562,11 @@ function getLatestMessage({
); );
} }
export async function syncPrivateMessage({ async function syncPrivateMessage({
messageResponse, messageResponse,
order, order,
}: { }: {
messageResponse: MedipostOrderResponse['Saadetis']['Vastus']; messageResponse: NonNullable<MedipostOrderResponse['Saadetis']['Vastus']>;
order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
}) { }) {
const supabase = getSupabaseServerAdminClient() const supabase = getSupabaseServerAdminClient()
@@ -606,6 +609,9 @@ export async function syncPrivateMessage({
Tables<{ schema: 'medreport' }, 'analysis_response_elements'>, Tables<{ schema: 'medreport' }, 'analysis_response_elements'>,
'id' | 'created_at' | 'updated_at' 'id' | 'created_at' | 'updated_at'
>[] = []; >[] = [];
const analysisResponseId = analysisResponse[0]!.id;
for (const analysisGroup of analysisGroups) { for (const analysisGroup of analysisGroups) {
const groupItems = toArray( const groupItems = toArray(
analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'],
@@ -618,7 +624,7 @@ export async function syncPrivateMessage({
responses.push( responses.push(
...elementAnalysisResponses.map((response) => ({ ...elementAnalysisResponses.map((response) => ({
analysis_element_original_id: element.UuringId, analysis_element_original_id: element.UuringId,
analysis_response_id: analysisResponse[0]!.id, analysis_response_id: analysisResponseId,
norm_lower: response.NormAlum?.['#text'] ?? null, norm_lower: response.NormAlum?.['#text'] ?? null,
norm_lower_included: norm_lower_included:
response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah',
@@ -640,11 +646,11 @@ export async function syncPrivateMessage({
.schema('medreport') .schema('medreport')
.from('analysis_response_elements') .from('analysis_response_elements')
.delete() .delete()
.eq('analysis_response_id', analysisResponse[0].id); .eq('analysis_response_id', analysisResponseId);
if (deleteError) { if (deleteError) {
throw new Error( throw new Error(
`Failed to clean up response elements for response id ${analysisResponse[0].id}`, `Failed to clean up response elements for response id ${analysisResponseId}`,
); );
} }
@@ -655,12 +661,23 @@ export async function syncPrivateMessage({
if (elementInsertError) { if (elementInsertError) {
throw new Error( throw new Error(
`Failed to insert order response elements for response id ${analysisResponse[0].id}`, `Failed to insert order response elements for response id ${analysisResponseId}`,
); );
} }
console.info("status", AnalysisOrderStatus[messageResponse.TellimuseOlek], messageResponse.TellimuseOlek); const { data: allOrderResponseElements} = await supabase
return AnalysisOrderStatus[messageResponse.TellimuseOlek]; .schema('medreport')
.from('analysis_response_elements')
.select('*')
.eq('analysis_response_id', analysisResponseId)
.throwOnError();
const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0;
if (allOrderResponseElements.length !== expectedOrderResponseElements) {
return { isPartial: true };
}
const statusFromResponse = AnalysisOrderStatus[messageResponse.TellimuseOlek];
return { isCompleted: statusFromResponse === 'COMPLETED' };
} }
export async function sendOrderToMedipost({ export async function sendOrderToMedipost({
@@ -688,7 +705,7 @@ export async function sendOrderToMedipost({
}); });
await sendPrivateMessage(orderXml); await sendPrivateMessage(orderXml);
await updateOrder({ orderId: medreportOrder.id, orderStatus: 'PROCESSING' }); await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
} }
export async function getOrderedAnalysisElementsIds({ export async function getOrderedAnalysisElementsIds({
@@ -720,7 +737,7 @@ export async function getOrderedAnalysisElementsIds({
countryCode, countryCode,
queryParams: { limit: 100, id: orderedPackageIds }, queryParams: { limit: 100, id: orderedPackageIds },
}); });
console.info(`Order has ${orderedPackagesProducts.length} packages`); console.info(`Order has ${orderedPackagesProducts.length} packages = ${JSON.stringify(orderedPackageIds, null, 2)}`);
if (orderedPackagesProducts.length !== orderedPackageIds.length) { if (orderedPackagesProducts.length !== orderedPackageIds.length) {
throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`); throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`);
} }

View File

@@ -90,7 +90,7 @@ export async function composeOrderTestResponseXML({
// 1 Järjekorras, 2 Ootel, 3 - Töös, 4 Lõpetatud, // 1 Järjekorras, 2 Ootel, 3 - Töös, 4 Lõpetatud,
// 5 Tagasi lükatud, 6 Tühistatud. // 5 Tagasi lükatud, 6 Tühistatud.
const orderStatus = 4; const orderStatus = 4;
const orderNumber = 'TSU000001200'; const orderNumber = orderId;
const allAnalysisElementsForGroups = analysisElements?.filter((element) => { const allAnalysisElementsForGroups = analysisElements?.filter((element) => {
return analysisGroups.some((group) => group.id === element.analysis_groups.id); return analysisGroups.some((group) => group.id === element.analysis_groups.id);
@@ -153,7 +153,7 @@ export async function composeOrderTestResponseXML({
const lower = getRandomInt(0, 100); const lower = getRandomInt(0, 100);
const upper = getRandomInt(lower + 1, 500); const upper = getRandomInt(lower + 1, 500);
const result = getRandomInt(lower, upper); const result = getRandomInt(lower - Math.floor(lower * 0.1), upper + Math.floor(upper * 0.1));
addedIds.add(relatedAnalysisElement.id); addedIds.add(relatedAnalysisElement.id);
return (` return (`
<UuringuGrupp> <UuringuGrupp>
@@ -175,7 +175,7 @@ export async function composeOrderTestResponseXML({
<VastuseAeg>${formatDate(new Date(), 'yyyy-MM-dd HH:mm:ss')}</VastuseAeg> <VastuseAeg>${formatDate(new Date(), 'yyyy-MM-dd HH:mm:ss')}</VastuseAeg>
<NormYlem kaasaarvatud=\"EI\">${upper}</NormYlem> <NormYlem kaasaarvatud=\"EI\">${upper}</NormYlem>
<NormAlum kaasaarvatud=\"EI\">${lower}</NormAlum> <NormAlum kaasaarvatud=\"EI\">${lower}</NormAlum>
<NormiStaatus>0</NormiStaatus> <NormiStaatus>${result < lower ? 1 : (result > upper ? 1 : 0)}</NormiStaatus>
<ProoviJarjenumber>1</ProoviJarjenumber> <ProoviJarjenumber>1</ProoviJarjenumber>
</UuringuVastus> </UuringuVastus>
</UuringuElement> </UuringuElement>

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
import { getCartId } from '@lib/data/cookies';
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src'; import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
import { requireUserInServerComponent } from '../server/require-user-in-server-component'; import { requireUserInServerComponent } from '../server/require-user-in-server-component';
@@ -64,6 +65,36 @@ export async function handleAddToCart({
return cart; return cart;
} }
export async function handleDeleteCartItem({
lineId,
}: {
lineId: string;
}) {
await deleteLineItem(lineId);
const supabase = getSupabaseServerClient();
const cartId = await getCartId();
const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount()
if (!account) {
throw new Error('Account not found');
}
const { error } = await supabase
.schema('audit')
.from('cart_entries')
.insert({
variant_id: lineId,
operation: 'REMOVE_FROM_CART',
account_id: account.id,
cart_id: cartId!,
changed_by: user.id,
});
if (error) {
throw new Error('Error logging cart entry: ' + error.message);
}
}
export async function handleNavigateToPayment({ language, paymentSessionId }: { language: string, paymentSessionId: string }) { export async function handleNavigateToPayment({ language, paymentSessionId }: { language: string, paymentSessionId: string }) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent(); const user = await requireUserInServerComponent();

View File

@@ -56,6 +56,30 @@ export async function updateOrder({
.throwOnError(); .throwOnError();
} }
export async function updateOrderStatus({
orderId,
medusaOrderId,
orderStatus,
}: {
orderId?: number;
medusaOrderId?: string;
orderStatus: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
}) {
const orderIdParam = orderId;
const medusaOrderIdParam = medusaOrderId;
if (!orderIdParam && !medusaOrderIdParam) {
throw new Error('Either orderId or medusaOrderId must be provided');
}
await getSupabaseServerAdminClient()
.schema('medreport')
.rpc('update_analysis_order_status', {
order_id: orderIdParam ?? -1,
status_param: orderStatus,
medusa_order_id_param: medusaOrderIdParam ?? '',
})
.throwOnError();
}
export async function getOrder({ export async function getOrder({
medusaOrderId, medusaOrderId,
orderId, orderId,
@@ -84,13 +108,39 @@ export async function getAnalysisOrders({
}: { }: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status']; orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
} = {}) { } = {}) {
const query = getSupabaseServerClient() const client = getSupabaseServerClient();
const {
data: { user },
} = await client.auth.getUser();
if (!user) {
throw new Error('Unauthorized');
}
const query = client
.schema('medreport')
.from('analysis_orders')
.select('*')
.eq("user_id", user.id)
if (orderStatus) {
query.eq('status', orderStatus);
}
const orders = await query.order('created_at', { ascending: false }).throwOnError();
return orders.data;
}
export async function getAnalysisOrdersAdmin({
orderStatus,
}: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
} = {}) {
const query = getSupabaseServerAdminClient()
.schema('medreport') .schema('medreport')
.from('analysis_orders') .from('analysis_orders')
.select('*') .select('*')
if (orderStatus) { if (orderStatus) {
query.eq('status', orderStatus); query.eq('status', orderStatus);
} }
const orders = await query.throwOnError(); const orders = await query.order('created_at', { ascending: false }).throwOnError();
return orders.data; return orders.data;
} }

View File

@@ -202,7 +202,7 @@ export type MedipostOrderResponse = IMedipostResponseXMLBase & {
SaadetisId: string; SaadetisId: string;
Email: string; Email: string;
}; };
Vastus: { Vastus?: {
ValisTellimuseId: string; ValisTellimuseId: string;
Asutus: { Asutus: {
'@_tyyp': string; // TEOSTAJA '@_tyyp': string; // TEOSTAJA
@@ -246,6 +246,9 @@ export type MedipostOrderResponse = IMedipostResponseXMLBase & {
TellimuseOlek: keyof typeof AnalysisOrderStatus; TellimuseOlek: keyof typeof AnalysisOrderStatus;
UuringuGrupp?: ResponseUuringuGrupp | ResponseUuringuGrupp[]; UuringuGrupp?: ResponseUuringuGrupp | ResponseUuringuGrupp[];
}; };
Tellimus?: {
ValisTellimuseId: string;
}
}; };
}; };
@@ -258,7 +261,7 @@ export const AnalysisOrderStatus = {
6: 'CANCELLED', 6: 'CANCELLED',
} as const; } as const;
export const NormStatus: Record<number, string> = { export const NormStatus: Record<number, string> = {
1: 'NORMAL', 0: 'NORMAL',
2: 'WARNING', 1: 'WARNING',
3: 'REQUIRES_ATTENTION', 2: 'REQUIRES_ATTENTION',
} as const; } as const;

View File

@@ -18,3 +18,12 @@ export function toTitleCase(str?: string) {
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(), text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
); );
} }
export function sortByDate<T>(a: T[] | undefined, key: keyof T): T[] | undefined {
return a?.sort((a, b) => {
if (!a[key] || !b[key]) {
return 0;
}
return new Date(b[key] as string).getTime() - new Date(a[key] as string).getTime();
});
}

View File

@@ -1,9 +1,9 @@
{ {
"subject": "Your Synlab order has been placed - {{analysisPackageName}}", "subject": "Your Medreport order has been placed - {{analysisPackageName}}",
"previewText": "Your Synlab order has been placed - {{analysisPackageName}}", "previewText": "Your Medreport order has been placed - {{analysisPackageName}}",
"heading": "Your Synlab order has been placed - {{analysisPackageName}}", "heading": "Your Medreport order has been placed - {{analysisPackageName}}",
"hello": "Hello {{personName}},", "hello": "Hello {{personName}},",
"lines1": "The order for {{analysisPackageName}} analysis package has been sent to the lab. Please go to the lab to collect the sample: SYNLAB - {{partnerLocationName}}", "lines1": "The order for {{analysisPackageName}} analysis package has been sent to the lab. Please go to the lab to collect the sample: Synlab - {{partnerLocationName}}",
"lines2": "<i>If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point - <a href=\"https://medreport.ee/et/verevotupunktid\">view locations and opening hours</a>.</i>", "lines2": "<i>If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point - <a href=\"https://medreport.ee/et/verevotupunktid\">view locations and opening hours</a>.</i>",
"lines3": "It is recommended to collect the sample in the morning (before 12:00) and not to eat or drink (water can be drunk).", "lines3": "It is recommended to collect the sample in the morning (before 12:00) and not to eat or drink (water can be drunk).",
"lines4": "At the collection point, select the order from the queue: the order from the doctor.", "lines4": "At the collection point, select the order from the queue: the order from the doctor.",

View File

@@ -1,9 +1,9 @@
{ {
"subject": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}", "subject": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"previewText": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}", "previewText": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"heading": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}", "heading": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"hello": "Tere {{personName}},", "hello": "Tere {{personName}},",
"lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: SYNLAB - {{partnerLocationName}}", "lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: Synlab - {{partnerLocationName}}",
"lines2": "<i>Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href=\"https://medreport.ee/et/verevotupunktid\">vaata asukohti ja lahtiolekuaegasid</a>.</i>", "lines2": "<i>Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href=\"https://medreport.ee/et/verevotupunktid\">vaata asukohti ja lahtiolekuaegasid</a>.</i>",
"lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).", "lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
"lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.", "lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.",

View File

@@ -32,6 +32,7 @@ import { cn } from '@kit/ui/utils';
import { toTitleCase } from '~/lib/utils'; import { toTitleCase } from '~/lib/utils';
import { usePersonalAccountData } from '../hooks/use-personal-account-data'; import { usePersonalAccountData } from '../hooks/use-personal-account-data';
import { ApplicationRole, ApplicationRoleEnum } from '../types/accounts';
const PERSONAL_ACCOUNT_SLUG = 'personal'; const PERSONAL_ACCOUNT_SLUG = 'personal';
@@ -51,13 +52,13 @@ export function PersonalAccountDropdown({
id: string | null; id: string | null;
name: string | null; name: string | null;
picture_url: string | null; picture_url: string | null;
application_role: string; application_role: ApplicationRole | null;
}; };
accounts: { accounts: {
label: string | null; label: string | null;
value: string | null; value: string | null;
image?: string | null; image?: string | null;
application_role: string; application_role: ApplicationRole | null;
}[]; }[];
signOutRequested: () => unknown; signOutRequested: () => unknown;
@@ -97,13 +98,14 @@ export function PersonalAccountDropdown({
const isSuperAdmin = useMemo(() => { const isSuperAdmin = useMemo(() => {
const hasAdminRole = const hasAdminRole =
personalAccountData?.application_role === 'super_admin'; personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
return hasAdminRole && hasTotpFactor; return hasAdminRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]); }, [user, personalAccountData, hasTotpFactor]);
const isDoctor = useMemo(() => { const isDoctor = useMemo(() => {
const hasDoctorRole = personalAccountData?.application_role === 'doctor'; const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor; return hasDoctorRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]); }, [user, personalAccountData, hasTotpFactor]);

View File

@@ -1,7 +1,17 @@
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
export type UserAnalysisElement = Database['medreport']['Tables']['analysis_response_elements']['Row']; export type UserAnalysisElement =
export type UserAnalysisResponse = Database['medreport']['Tables']['analysis_responses']['Row'] & { Database['medreport']['Tables']['analysis_response_elements']['Row'];
elements: UserAnalysisElement[]; export type UserAnalysisResponse =
}; Database['medreport']['Tables']['analysis_responses']['Row'] & {
elements: UserAnalysisElement[];
};
export type UserAnalysis = UserAnalysisResponse[]; export type UserAnalysis = UserAnalysisResponse[];
export type ApplicationRole =
Database['medreport']['Tables']['accounts']['Row']['application_role'];
export enum ApplicationRoleEnum {
User = 'user',
Doctor = 'doctor',
SuperAdmin = 'super_admin',
}

View File

@@ -114,7 +114,8 @@ export class TeamAccountsApi {
role, role,
name, name,
slug, slug,
picture_url picture_url,
application_role
) )
`, `,
) )

View File

@@ -1563,6 +1563,9 @@ export type Database = {
Views: { Views: {
user_account_workspace: { user_account_workspace: {
Row: { Row: {
application_role:
| Database["medreport"]["Enums"]["application_role"]
| null
id: string | null id: string | null
name: string | null name: string | null
picture_url: string | null picture_url: string | null
@@ -1574,6 +1577,9 @@ export type Database = {
} }
user_accounts: { user_accounts: {
Row: { Row: {
application_role:
| Database["medreport"]["Enums"]["application_role"]
| null
id: string | null id: string | null
name: string | null name: string | null
picture_url: string | null picture_url: string | null
@@ -1634,7 +1640,9 @@ export type Database = {
Returns: Json Returns: Json
} }
create_team_account: { create_team_account: {
Args: { account_name: string; new_personal_code: string } Args:
| { account_name: string }
| { account_name: string; new_personal_code: string }
Returns: { Returns: {
application_role: Database["medreport"]["Enums"]["application_role"] application_role: Database["medreport"]["Enums"]["application_role"]
city: string | null city: string | null
@@ -1801,6 +1809,7 @@ export type Database = {
primary_owner_user_id: string primary_owner_user_id: string
subscription_status: Database["medreport"]["Enums"]["subscription_status"] subscription_status: Database["medreport"]["Enums"]["subscription_status"]
permissions: Database["medreport"]["Enums"]["app_permissions"][] permissions: Database["medreport"]["Enums"]["app_permissions"][]
application_role: Database["medreport"]["Enums"]["application_role"]
}[] }[]
} }
transfer_team_account_ownership: { transfer_team_account_ownership: {
@@ -1819,6 +1828,22 @@ export type Database = {
} }
Returns: undefined Returns: undefined
} }
update_analysis_order_status: {
Args: {
order_id: number
medusa_order_id_param: string
status_param: Database["medreport"]["Enums"]["analysis_order_status"]
}
Returns: {
analysis_element_ids: number[] | null
analysis_ids: number[] | null
created_at: string
id: number
medusa_order_id: string
status: Database["medreport"]["Enums"]["analysis_order_status"]
user_id: string
}
}
upsert_order: { upsert_order: {
Args: { Args: {
target_account_id: string target_account_id: string

View File

@@ -5,10 +5,12 @@
"orderNewAnalysis": "Order new analyses", "orderNewAnalysis": "Order new analyses",
"waitingForResults": "Waiting for results", "waitingForResults": "Waiting for results",
"noAnalysisElements": "No analysis orders found", "noAnalysisElements": "No analysis orders found",
"noAnalysisOrders": "No analysis orders found",
"analysisDate": "Analysis result date", "analysisDate": "Analysis result date",
"results": { "results": {
"range": { "range": {
"normal": "Normal range" "normal": "Normal range"
} }
} },
"orderTitle": "Order number {{orderNumber}}"
} }

View File

@@ -12,5 +12,11 @@
"cholesterol": "Cholesterol", "cholesterol": "Cholesterol",
"ldlCholesterol": "LDL Cholesterol", "ldlCholesterol": "LDL Cholesterol",
"smoking": "Smoking", "smoking": "Smoking",
"recommendedForYou": "Recommended for you" "recommendedForYou": "Recommended for you",
"heroCard": {
"orderAnalysis": {
"title": "Order analysis",
"description": "Select an analysis to get started"
}
}
} }

View File

@@ -1,4 +1,5 @@
{ {
"title": "Select analysis", "title": "Select analysis",
"description": "Select the analysis that suits your needs" "description": "Select the analysis that suits your needs",
"analysisNotAvailable": "Analysis is not available currently"
} }

View File

@@ -5,10 +5,12 @@
"orderNewAnalysis": "Telli uued analüüsid", "orderNewAnalysis": "Telli uued analüüsid",
"waitingForResults": "Tulemuse ootel", "waitingForResults": "Tulemuse ootel",
"noAnalysisElements": "Veel ei ole tellitud analüüse", "noAnalysisElements": "Veel ei ole tellitud analüüse",
"noAnalysisOrders": "Veel ei ole analüüside tellimusi",
"analysisDate": "Analüüsi vastuse kuupäev", "analysisDate": "Analüüsi vastuse kuupäev",
"results": { "results": {
"range": { "range": {
"normal": "Normaalne vahemik" "normal": "Normaalne vahemik"
} }
} },
"orderTitle": "Tellimus {{orderNumber}}"
} }

View File

@@ -12,5 +12,11 @@
"cholesterol": "Kolesterool", "cholesterol": "Kolesterool",
"ldlCholesterol": "LDL kolesterool", "ldlCholesterol": "LDL kolesterool",
"smoking": "Suitsetamine", "smoking": "Suitsetamine",
"recommendedForYou": "Soovitused sulle" "recommendedForYou": "Soovitused sulle",
"heroCard": {
"orderAnalysis": {
"title": "Telli analüüs",
"description": "Telli endale sobiv analüüs"
}
}
} }

View File

@@ -1,4 +1,5 @@
{ {
"title": "Vali analüüs", "title": "Vali analüüs",
"description": "Vali enda vajadustele sobiv analüüs" "description": "Vali enda vajadustele sobiv analüüs",
"analysisNotAvailable": "Analüüsi tellimine ei ole hetkel saadaval"
} }

View File

@@ -12,7 +12,7 @@ function send_medipost_test_response() {
curl -X POST "$HOSTNAME/api/order/medipost-test-response" \ curl -X POST "$HOSTNAME/api/order/medipost-test-response" \
--header "x-jobs-api-key: $JOBS_API_TOKEN" \ --header "x-jobs-api-key: $JOBS_API_TOKEN" \
--header 'Content-Type: application/json' \ --header 'Content-Type: application/json' \
--data '{ "medusaOrderId": "'$MEDUSA_ORDER_ID'" }' --data '{ "medusaOrderId": "'$MEDUSA_ORDER_ID'", "maxItems": 2 }'
} }
function sync_analysis_results() { function sync_analysis_results() {

View File

@@ -0,0 +1,19 @@
-- Enable required extensions for cron jobs and HTTP requests
create extension if not exists pg_cron;
create extension if not exists pg_net;
-- Schedule the test-medipost-responses job to run every 15 minutes
select
cron.schedule(
'send-test-medipost-responses-every-15-minutes', -- Unique job name
'*/15 * * * *', -- Cron schedule: every 15 minutes
$$
select
net.http_post(
url := 'https://test.medreport.ee/api/job/test-medipost-responses',
headers := jsonb_build_object(
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
)
) as request_id;
$$
);

View File

@@ -0,0 +1,19 @@
-- Enable required extensions for cron jobs and HTTP requests
create extension if not exists pg_cron;
create extension if not exists pg_net;
-- Schedule the sync-analysis-results job to run every 15 minutes
select
cron.schedule(
'sync-analysis-results-every-15-minutes', -- Unique job name
'*/15 * * * *', -- Cron schedule: every 15 minutes
$$
select
net.http_post(
url := 'https://test.medreport.ee/api/job/sync-analysis-results',
headers := jsonb_build_object(
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
)
) as request_id;
$$
);

View File

@@ -0,0 +1,33 @@
-- Function "medreport.update_analysis_order_status"
-- Update an analysis order status
create
or replace function medreport.update_analysis_order_status (
order_id bigint,
medusa_order_id_param text,
status_param medreport.analysis_order_status
) returns medreport.analysis_orders
set
search_path = '' as $$
declare
updated_order medreport.analysis_orders;
begin
update medreport.analysis_orders
set status = status_param
where (id = order_id OR medusa_order_id = medusa_order_id_param)
returning * into updated_order;
return updated_order;
end;
$$ language plpgsql;
grant
execute on function medreport.update_analysis_order_status (
bigint,
text,
medreport.analysis_order_status
) to service_role;
-- example:
-- select medreport.update_analysis_order_status(-1, 'order_01K1TQQHZGPXKDHAH81TDSNGXR', 'CANCELLED')

View File

@@ -0,0 +1,76 @@
CREATE OR REPLACE VIEW medreport.user_accounts AS
SELECT
account.id,
account.name,
account.picture_url,
account.slug,
membership.account_role AS role,
COALESCE(account.application_role, 'user') AS application_role
FROM medreport.accounts account
JOIN medreport.accounts_memberships membership ON (account.id = membership.account_id)
WHERE (
membership.user_id = (SELECT auth.uid())
AND account.is_personal_account = false
AND account.id IN (
SELECT accounts_memberships.account_id
FROM medreport.accounts_memberships
WHERE accounts_memberships.user_id = (SELECT auth.uid())
)
);
GRANT SELECT ON medreport.user_accounts TO authenticated, service_role;
DROP FUNCTION IF EXISTS medreport.team_account_workspace(text);
CREATE FUNCTION medreport.team_account_workspace(account_slug text)
RETURNS TABLE(id uuid, name character varying, picture_url character varying, slug text, role character varying, role_hierarchy_level integer, primary_owner_user_id uuid, subscription_status medreport.subscription_status, permissions medreport.app_permissions[], application_role medreport.application_role)
LANGUAGE plpgsql
SET search_path TO ''
AS $function$begin
return QUERY
select
accounts.id,
accounts.name,
accounts.picture_url,
accounts.slug,
accounts.application_role,
accounts_memberships.account_role,
roles.hierarchy_level,
accounts.primary_owner_user_id,
subscriptions.status,
array_agg(role_permissions.permission)
from
medreport.accounts
join medreport.accounts_memberships on accounts.id = accounts_memberships.account_id
left join medreport.subscriptions on accounts.id = subscriptions.account_id
join medreport.roles on accounts_memberships.account_role = roles.name
left join medreport.role_permissions on accounts_memberships.account_role = role_permissions.role
where
accounts.slug = account_slug
and medreport.accounts_memberships.user_id = (select auth.uid())
group by
accounts.id,
accounts_memberships.account_role,
subscriptions.status,
roles.hierarchy_level;
end;$function$;
GRANT EXECUTE ON FUNCTION medreport.team_account_workspace(text) TO authenticated, service_role;
create or replace view medreport.user_account_workspace as SELECT accounts.id,
accounts.name,
accounts.picture_url,
( SELECT subscriptions.status
FROM medreport.subscriptions
WHERE (subscriptions.account_id = accounts.id)
LIMIT 1) AS subscription_status,
accounts.application_role
FROM medreport.accounts
WHERE ((accounts.primary_owner_user_id = ( SELECT auth.uid() AS uid)) AND (accounts.is_personal_account = true))
LIMIT 1;
grant
select
on medreport.user_account_workspace to authenticated,
service_role;

View File

@@ -0,0 +1,2 @@
-- Increase the body column limit for notifications to support larger email content (up to 128KB)
ALTER TABLE medreport.notifications ALTER COLUMN body TYPE VARCHAR(131072);