30 Commits

Author SHA1 Message Date
2df366c14a feat(MED-121): fix variant age range check 2025-09-01 14:29:14 +03:00
Danel Kungla
6ce60eacc7 Add data.sql to .gitignore 2025-08-29 18:05:51 +03:00
danelkungla
6e76e75e85 Merge pull request #66 from MR-medreport/main
Main
2025-08-29 16:17:54 +03:00
71c3e2ef1e Merge pull request #64 from MR-medreport/MED-85
feat(MED-85-105-123): some testing feedback, other improvements
2025-08-29 11:52:55 +03:00
d83319a094 Merge branch 'main' into MED-85 2025-08-29 11:45:36 +03:00
Helena
505ef0d91b Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:28:15 +03:00
Helena
fce4355be8 Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:27:46 +03:00
Helena
da9658ad7a fix email button 2025-08-29 10:27:12 +03:00
danelkungla
e023d54a2a Merge pull request #63 from MR-medreport/main
update develop
2025-08-29 09:50:32 +03:00
danelkungla
bdaacbe78a MED-104: booking page
MED-104
2025-08-29 09:48:26 +03:00
Danel Kungla
5479f310d7 Merge branch 'main' into MED-104 2025-08-29 09:46:04 +03:00
815b877b5b Merge branch 'main' into MED-85 2025-08-28 16:12:46 +03:00
0c28f9681b feat(MED-86): fix status check for fake responses 2025-08-28 15:49:03 +03:00
70b85dc967 feat(MED-86): don't prettify results sync log since aws will split it up 2025-08-28 15:37:29 +03:00
71f5a25632 Merge branch 'main' into MED-85 2025-08-28 14:57:09 +03:00
d072226a5c feat(MED-105): organize env specific required migrations 2025-08-28 14:54:51 +03:00
da7f574234 feat(MED-86): user can see in Medusa BO if sending order to medipost succeeded 2025-08-28 14:42:07 +03:00
b3505c1627 feat(MED-100): show cart line item quantities total instead of items total count 2025-08-28 14:41:59 +03:00
Danel Kungla
ad28352fc8 MED-104: create booking view with categories 2025-08-28 14:11:54 +03:00
b931035c3b feat(MED-86): add db fn to show medipost dispatch error for order in Medusa 2025-08-28 13:36:07 +03:00
f723633646 feat(MED-123): don't redirect to cart on single analysis select 2025-08-28 13:31:29 +03:00
6e6ad13b52 feat(MED-123): show toast on adding analysis package to cart 2025-08-28 13:30:25 +03:00
Danel Kungla
31bc4b6cff initial commit 2025-08-28 13:15:39 +03:00
3ddc0a2716 feat(MED-123): show toast on adding analysis to cart 2025-08-28 12:44:07 +03:00
49eeaa1876 feat(MED-123): update translations 2025-08-28 12:40:33 +03:00
2ffad84100 feat(MED-105): log opening analysis results from orders view 2025-08-28 12:35:30 +03:00
b4985afdf0 feat(MED-85): add logging for medipost response error xml 2025-08-28 12:25:18 +03:00
a37c4cad9c feat(MED-85): add logging for medipost actions with xml and related order id 2025-08-28 11:56:07 +03:00
47ab39172e feat(MED-85): run sending fake medipost results in dev from Medusa BO 2025-08-28 10:30:43 +03:00
d760f86632 feat(MED-85): run force medipost results sync from Medusa BO 2025-08-28 09:54:32 +03:00
45 changed files with 685 additions and 210 deletions

1
.gitignore vendored
View File

@@ -43,3 +43,4 @@ yarn-error.log*
next-env.d.ts
dump.sql
data.sql

View File

@@ -71,19 +71,19 @@ export default async function syncConnectedOnline() {
return {
id: service.ID,
clinic_id: service.ClinicID,
code: service.Code,
description: service.Description || null,
display: service.Display,
duration: service.Duration,
has_free_codes: !!service.HasFreeCodes,
sync_id: service.SyncID,
name: service.Name,
description: service.Description || null,
price: service.Price,
requires_payment: !!service.RequiresPayment,
duration: service.Duration,
neto_duration: service.NetoDuration,
display: service.Display,
price_periods: service.PricePeriods || null,
online_hide_duration: service.OnlineHideDuration,
online_hide_price: service.OnlineHidePrice,
price: service.Price,
price_periods: service.PricePeriods || null,
requires_payment: !!service.RequiresPayment,
sync_id: service.SyncID,
code: service.Code,
has_free_codes: !!service.HasFreeCodes,
};
});

View File

@@ -41,8 +41,6 @@ export async function POST(request: NextRequest) {
orderCreatedAt: new Date(medreportOrder.created_at),
});
console.info("SEND XML", messageXml);
try {
await sendPrivateMessageTestResponse({ messageXml });
} catch (error) {

View File

@@ -3,7 +3,7 @@ import { getOrder } 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 { createMedipostActionLog, getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
export async function POST(request: Request) {
// const isDev = process.env.NODE_ENV === 'development';
@@ -35,6 +35,11 @@ export async function POST(request: Request) {
});
try {
await createMedipostActionLog({
action: 'send_fake_analysis_results_to_medipost',
xml: messageXml,
medusaOrderId,
});
await sendPrivateMessageTestResponse({ messageXml });
} catch (error) {
console.error("Error sending private message test response: ", error);

View File

@@ -0,0 +1,41 @@
import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header';
import { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('booking:title');
return {
title,
};
};
async function BookingHandlePage({ params }: { params: { handle: string } }) {
const handle = await params.handle;
const { category } = await loadCategory({ handle });
return (
<>
<AppBreadcrumbs
values={{
[handle]: category?.name || handle,
}}
/>
<HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />}
/>
<PageBody></PageBody>
</>
);
}
export default withI18n(BookingHandlePage);

View File

@@ -1,12 +1,16 @@
import { use } from 'react';
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import OrderCards from '../../_components/order-cards';
import ServiceCategories from '../../_components/service-categories';
import { loadTtoServices } from '../../_lib/server/load-tto-services';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
@@ -18,15 +22,30 @@ export const generateMetadata = async () => {
};
function BookingPage() {
const { heroCategories, ttoCategories } = use(loadTtoServices());
if (!heroCategories.length && !ttoCategories.length) {
return (
<>
<AppBreadcrumbs />
<h3 className="mt-8">
<Trans i18nKey="booking:noCategories" />
</h3>
</>
);
}
return (
<>
<AppBreadcrumbs />
<HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />}
/>
<PageBody>
<OrderCards />
<PageBody className="space-y-2">
<OrderCards heroCategories={heroCategories} />
<ServiceCategories categories={ttoCategories} />
</PageBody>
</>
);

View File

@@ -31,8 +31,8 @@ export async function HomeMenuNavigation(props: {
})
: 0;
const cartItemsCount = props.cart?.items?.length ?? 0;
const hasCartItems = cartItemsCount > 0;
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0;
return (
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
@@ -64,7 +64,7 @@ export async function HomeMenuNavigation(props: {
<ShoppingCart className="stroke-[1.5px]" />
<Trans
i18nKey="common:shoppingCartCount"
values={{ count: cartItemsCount }}
values={{ count: cartQuantityTotal }}
/>
</Button>
</Link>

View File

@@ -51,8 +51,8 @@ export function HomeMobileNavigation(props: {
}
});
const cartItemsCount = props.cart?.items?.length ?? 0;
const hasCartItems = cartItemsCount > 0;
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0;
return (
<DropdownMenu>
@@ -83,7 +83,7 @@ export function HomeMobileNavigation(props: {
path="/home/cart"
label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartItemsCount }}
labelOptions={{ count: cartQuantityTotal }}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -12,9 +12,9 @@ import {
import { StoreProduct } from '@medusajs/types';
import { useState } from 'react';
import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { useRouter } from 'next/navigation';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Trans } from '@kit/ui/trans';
import { toast } from '@kit/ui/sonner';
export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle'
@@ -30,8 +30,6 @@ export default function OrderAnalysesCards({
analyses: OrderAnalysisCard[];
countryCode: string;
}) {
const router = useRouter();
const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (variantId: string) => {
if (isAddingToCart) {
@@ -44,9 +42,10 @@ export default function OrderAnalysesCards({
selectedVariant: { id: variantId },
countryCode,
});
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
setIsAddingToCart(false);
router.push('/home/cart');
} catch (e) {
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
setIsAddingToCart(false);
console.error(e);
}

View File

@@ -1,74 +1,68 @@
"use client";
'use client';
import { ChevronRight, HeartPulse } from 'lucide-react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { pathsConfig } from '@/packages/shared/src/config';
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Card,
CardHeader,
CardDescription,
CardProps,
CardFooter,
CardHeader,
CardProps,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@/lib/utils';
const dummyCards = [
{
title: 'booking:analysisPackages.title',
description: 'booking:analysisPackages.description',
descriptionColor: 'text-primary',
icon: (
<Link href={'/home/order-analysis-package'}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
),
cardVariant: 'gradient-success' as CardProps['variant'],
iconBg: 'bg-warning',
},
];
import { ServiceCategory } from './service-categories';
export default function OrderCards() {
export default function OrderCards({
heroCategories,
}: {
heroCategories: ServiceCategory[];
}) {
return (
<div className="grid grid-cols-3 gap-6 mt-4">
{dummyCards.map(({
title,
description,
icon,
cardVariant,
descriptionColor,
iconBg,
}) => (
<div className="xs:grid-cols-3 mt-4 grid grid-cols-1 gap-2">
{heroCategories.map(({ name, description, color, handle }) => (
<Card
key={title}
variant={cardVariant}
key={name}
variant={`gradient-${color}` as CardProps['variant']}
className="flex flex-col justify-between"
>
<CardHeader className="items-end-safe">
<CardHeader className="relative flex flex-row justify-between">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
iconBg,
'flex size-8 items-center-safe justify-center-safe rounded-full',
`text-${color}`,
`bg-${color}/10`,
{
'bg-primary/10': color === 'success',
},
)}
>
{icon}
<ComponentInstanceIcon
className={cn('size-4', `fill-${color}`)}
/>
</div>
<div className="absolute top-2 right-2 flex size-8 items-center-safe justify-center-safe rounded-xl text-white">
<Link
href={pathsConfig.app.bookingHandle.replace('[handle]', handle)}
>
<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">
<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>
<h5>
<Trans i18nKey={title} />
</h5>
<CardDescription className={descriptionColor}>
<Trans i18nKey={description} />
</CardDescription>
<CardFooter className="mt-5 flex flex-col items-start gap-2">
<h5>{name}</h5>
<CardDescription>{description}</CardDescription>
</CardFooter>
</Card>
))}

View File

@@ -0,0 +1,18 @@
'use server';
import { createPageViewLog, PageViewAction } from "~/lib/services/audit/pageView.service";
import { loadCurrentUserAccount } from "../../_lib/server/load-user-account";
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_ANALYSIS_RESULTS_FROM_ORDER,
extraData: {
analysisOrderId,
},
});
}

View File

@@ -1,3 +1,5 @@
'use client';
import { Trans } from '@kit/ui/trans';
import {
Table,
@@ -10,18 +12,26 @@ import {
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';
import { useRouter } from 'next/navigation';
import { logAnalysisResultsNavigateAction } from './actions';
export default function OrderItemsTable({ items, title, analysisOrder }: {
items: StoreOrderLineItem[];
title: string;
analysisOrder: AnalysisOrder;
}) {
const router = useRouter();
if (!items || items.length === 0) {
return null;
}
const openAnalysisResults = async () => {
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
router.push(`/home/analysis-results`);
}
return (
<Table className="rounded-lg border border-separate">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
@@ -60,13 +70,12 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
<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>
<button
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer "
onClick={openAnalysisResults}
>
<Eye />
</button>
</span>
</TableCell>
</TableRow>

View File

@@ -0,0 +1,61 @@
'use client';
import React from 'react';
import { redirect } from 'next/navigation';
import { createPath, pathsConfig } from '@/packages/shared/src/config';
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
import { cn } from '@kit/ui/shadcn';
import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card';
export interface ServiceCategory {
name: string;
handle: string;
color: string;
description: string;
}
const ServiceCategories = ({
categories,
}: {
categories: ServiceCategory[];
}) => {
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{categories.map((category, index) => (
<Card
key={index}
className="flex cursor-pointer gap-2 p-4 shadow hover:shadow-md"
onClick={() => {
redirect(
pathsConfig.app.bookingHandle.replace(
'[handle]',
category.handle,
),
);
}}
>
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full',
`bg-${category.color}/10`,
`text-${category.color}`,
)}
>
<ComponentInstanceIcon />
</div>
<div>
<h5 className="mb-2 text-lg font-semibold">{category.name}</h5>
<CardDescription className="">
{category.description}
</CardDescription>
</div>
</Card>
))}
</div>
);
};
export default ServiceCategories;

View File

@@ -1,9 +1,11 @@
import { cache } from 'react';
import { listProductTypes } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
import { getProductCategories } from '@lib/data/categories';
import { listProductTypes } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
import { ServiceCategory } from '../../_components/service-categories';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -14,7 +16,9 @@ async function countryCodesLoader() {
export const loadCountryCodes = cache(countryCodesLoader);
async function productCategoriesLoader() {
const productCategories = await getProductCategories({ fields: "*products, *products.variants" });
const productCategories = await getProductCategories({
fields: '*products, *products.variants, is_active',
});
return productCategories.product_categories ?? [];
}
export const loadProductCategories = cache(productCategoriesLoader);
@@ -29,25 +33,34 @@ async function analysesLoader() {
const [countryCodes, productCategories] = await Promise.all([
loadCountryCodes(),
loadProductCategories(),
]);
]);
const countryCode = countryCodes[0]!;
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis');
const category = productCategories.find(
({ metadata }) => metadata?.page === 'order-analysis',
);
const serviceCategories = productCategories.filter(
({ parent_category }) => parent_category?.handle === 'tto-categories',
);
return {
analyses: category?.products?.map<OrderAnalysisCard>(({ title, description, subtitle, variants, status, metadata }) => {
const variant = variants![0]!;
return {
title,
description,
subtitle,
variant: {
id: variant.id,
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,
};
},
isAvailable: status === 'published' && !!metadata?.analysisIdOriginal,
};
}) ?? [],
) ?? [],
countryCode,
}
};
}
export const loadAnalyses = cache(analysesLoader);

View File

@@ -38,8 +38,11 @@ function userSpecificVariantLoader({
if (age >= 18 && age <= 29) {
return '18-29';
}
if (age >= 30 && age <= 49) {
return '30-49';
if (age >= 30 && age <= 39) {
return '30-39';
}
if (age >= 40 && age <= 49) {
return '40-49';
}
if (age >= 50 && age <= 59) {
return '50-59';

View File

@@ -0,0 +1,31 @@
import { cache } from 'react';
import { getProductCategories } from '@lib/data';
import { ServiceCategory } from '../../_components/service-categories';
async function categoryLoader({
handle,
}: {
handle: string;
}): Promise<{ category: ServiceCategory | null }> {
const response = await getProductCategories({
handle,
fields: '*products, is_active, metadata',
});
const category = response.product_categories[0];
return {
category: {
color:
typeof category?.metadata?.color === 'string'
? category?.metadata?.color
: 'primary',
description: category?.description || '',
handle: category?.handle || '',
name: category?.name || '',
},
};
}
export const loadCategory = cache(categoryLoader);

View File

@@ -0,0 +1,49 @@
import { cache } from 'react';
import { getProductCategories } from '@lib/data';
import { ServiceCategory } from '../../_components/service-categories';
async function ttoServicesLoader() {
const response = await getProductCategories({
fields: '*products, is_active, metadata',
});
const heroCategories = response.product_categories?.filter(
({ parent_category, is_active, metadata }) =>
parent_category?.handle === 'tto-categories' &&
is_active &&
metadata?.isHero,
);
const ttoCategories = response.product_categories?.filter(
({ parent_category, is_active, metadata }) =>
parent_category?.handle === 'tto-categories' &&
is_active &&
!metadata?.isHero,
);
return {
heroCategories:
heroCategories.map<ServiceCategory>(
({ name, handle, metadata, description }) => ({
name,
handle,
color:
typeof metadata?.color === 'string' ? metadata.color : 'primary',
description,
}),
) ?? [],
ttoCategories:
ttoCategories.map<ServiceCategory>(
({ name, handle, metadata, description }) => ({
name,
handle,
color:
typeof metadata?.color === 'string' ? metadata.color : 'primary',
description,
}),
) ?? [],
};
}
export const loadTtoServices = cache(ttoServicesLoader);

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
export enum PageViewAction {
VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS',
VIEW_ANALYSIS_RESULTS_FROM_ORDER = 'VIEW_ANALYSIS_RESULTS_FROM_ORDER',
REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS',
VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS',
VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD',
@@ -10,9 +11,11 @@ export enum PageViewAction {
export const createPageViewLog = async ({
accountId,
action,
extraData,
}: {
accountId: string;
action: PageViewAction;
extraData?: Record<string, any>;
}) => {
try {
const supabase = getSupabaseServerClient();
@@ -34,6 +37,7 @@ export const createPageViewLog = async ({
account_id: accountId,
action,
changed_by: user.id,
extra_data: extraData,
})
.throwOnError();
} catch (error) {

View File

@@ -180,7 +180,10 @@ export async function getPrivateMessage(messageId: string) {
await validateMedipostResponse(data, { canHaveEmptyCode: true });
return parseXML(data) as MedipostOrderResponse;
return {
message: parseXML(data) as MedipostOrderResponse,
xml: data as string,
};
}
export async function deletePrivateMessage(messageId: string) {
@@ -211,7 +214,9 @@ export async function readPrivateMessageResponse({
try {
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
if (!privateMessage) {
messageId = privateMessage?.messageId ?? null;
if (!privateMessage || !messageId) {
return {
messageId: null,
hasAnalysisResponse: false,
@@ -221,40 +226,28 @@ export async function readPrivateMessageResponse({
};
}
messageId = privateMessage.messageId;
if (!messageId) {
return {
messageId: null,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
};
}
const privateMessageContent = await getPrivateMessage(
const { message: privateMessageContent, xml: privateMessageXml } = await getPrivateMessage(
privateMessage.messageId,
);
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
if (!medusaOrderId || !medusaOrderId.toString().startsWith('order_')) {
return {
messageId,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
};
}
const hasInvalidOrderId = !medusaOrderId || !medusaOrderId.toString().startsWith('order_');
if (!messageResponse) {
if (hasInvalidOrderId || !messageResponse) {
await createMedipostActionLog({
action: 'sync_analysis_results_from_medipost',
xml: privateMessageXml,
hasAnalysisResults: false,
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
});
return {
messageId,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId,
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
};
}
@@ -745,12 +738,36 @@ export async function sendOrderToMedipost({
await sendPrivateMessage(orderXml);
} catch (e) {
const isMedipostError = e instanceof MedipostValidationError;
await logMedipostDispatch({
medusaOrderId,
isSuccess: false,
isMedipostError,
errorMessage: isMedipostError ? e.response : undefined,
});
if (isMedipostError) {
await logMedipostDispatch({
medusaOrderId,
isSuccess: false,
isMedipostError,
errorMessage: e.response,
});
await createMedipostActionLog({
action: 'send_order_to_medipost',
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
responseXml: e.response,
hasError: true,
});
} else {
await logMedipostDispatch({
medusaOrderId,
isSuccess: false,
isMedipostError,
});
await createMedipostActionLog({
action: 'send_order_to_medipost',
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
hasError: true,
});
}
throw e;
}
await logMedipostDispatch({
@@ -758,6 +775,12 @@ export async function sendOrderToMedipost({
isSuccess: true,
isMedipostError: false,
});
await createMedipostActionLog({
action: 'send_order_to_medipost',
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
});
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
}
@@ -825,3 +848,37 @@ export async function getOrderedAnalysisElementsIds({
return [...analysisPackageElements, ...orderedAnalysisElements];
}
export async function createMedipostActionLog({
action,
xml,
hasAnalysisResults = false,
medusaOrderId,
responseXml,
hasError = false,
}: {
action:
| 'send_order_to_medipost'
| 'sync_analysis_results_from_medipost'
| 'send_fake_analysis_results_to_medipost'
| 'send_analysis_results_to_medipost';
xml: string;
hasAnalysisResults?: boolean;
medusaOrderId?: string | null;
responseXml?: string | null;
hasError?: boolean;
}) {
await getSupabaseServerAdminClient()
.schema('medreport')
.from('medipost_actions')
.insert({
action,
xml,
has_analysis_results: hasAnalysisResults,
medusa_order_id: medusaOrderId,
response_xml: responseXml,
has_error: hasError,
})
.select('id')
.throwOnError();
}

View File

@@ -134,8 +134,10 @@ export async function getAnalysisOrders({
export async function getAnalysisOrdersAdmin({
orderStatus,
medusaOrderId,
}: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
medusaOrderId?: string | null;
} = {}) {
const query = getSupabaseServerAdminClient()
.schema('medreport')
@@ -144,6 +146,9 @@ export async function getAnalysisOrdersAdmin({
if (orderStatus) {
query.eq('status', orderStatus);
}
if (medusaOrderId) {
query.eq('medusa_order_id', medusaOrderId);
}
const orders = await query.order('created_at', { ascending: false }).throwOnError();
return orders.data;
}

View File

@@ -0,0 +1,16 @@
import { Button } from '@react-email/components';
export function EmailButton(
props: React.PropsWithChildren<{
href: string;
}>,
) {
return (
<Button
className="hover:bg-primary/90 inline-flex w-full items-center justify-center gap-1 rounded bg-[#16a249] py-3 text-center text-[16px] font-semibold whitespace-nowrap text-white no-underline shadow-xs transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
href={props.href}
>
{props.children}
</Button>
);
}

View File

@@ -1,9 +1,7 @@
import {
Body,
Button,
Head,
Html,
Link,
Preview,
Tailwind,
Text,
@@ -13,6 +11,7 @@ import {
import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content';
import { EmailButton } from '../components/email-button';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
@@ -72,11 +71,12 @@ export async function renderDoctorSummaryReceivedEmail({
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
</Text>
<Link
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
>
<Button> {t(`${namespace}:linkText`, { orderNr })}</Button>
</Link>
{t(`${namespace}:linkText`, { orderNr })}
</EmailButton>
<Text>
{t(`${namespace}:ifButtonDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}

View File

@@ -1,13 +1,13 @@
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
import { sdk } from "@lib/config";
import { HttpTypes } from "@medusajs/types";
import { getCacheOptions } from "./cookies";
export const listCategories = async (query?: Record<string, any>) => {
const next = {
...(await getCacheOptions("categories")),
}
};
const limit = query?.limit || 100
const limit = query?.limit || 100;
return sdk.client
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
@@ -23,8 +23,8 @@ export const listCategories = async (query?: Record<string, any>) => {
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories)
}
.then(({ product_categories }) => product_categories);
};
export const getCategoryByHandle = async (categoryHandle: string[]) => {
const { product_categories } = await getProductCategories({
@@ -32,7 +32,7 @@ export const getCategoryByHandle = async (categoryHandle: string[]) => {
limit: 1,
});
return product_categories[0];
}
};
export const getProductCategories = async ({
handle,
@@ -45,19 +45,18 @@ export const getProductCategories = async ({
} = {}) => {
const next = {
...(await getCacheOptions("categories")),
}
};
return sdk.client
.fetch<HttpTypes.StoreProductCategoryListResponse>(
`/store/product-categories`,
{
query: {
fields,
handle,
limit,
},
next,
//cache: "force-cache",
}
);
}
return sdk.client.fetch<HttpTypes.StoreProductCategoryListResponse>(
`/store/product-categories`,
{
query: {
fields,
handle,
limit,
},
next,
//cache: "force-cache",
}
);
};

View File

@@ -9,6 +9,7 @@ import { StoreProduct } from '@medusajs/types';
import { Button } from '@medusajs/ui';
import { useTranslation } from 'react-i18next';
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
import { toast } from '@kit/ui/sonner';
import {
Card,
@@ -49,12 +50,19 @@ export default function SelectAnalysisPackage({
const handleSelect = async () => {
setIsAddingToCart(true);
await handleAddToCart({
selectedVariant: { id: variantId },
countryCode,
});
setIsAddingToCart(false);
router.push('/home/cart');
try {
await handleAddToCart({
selectedVariant: { id: variantId },
countryCode,
});
setIsAddingToCart(false);
toast.success(<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />);
router.push('/home/cart');
} catch (e) {
toast.error(<Trans i18nKey={'order-analysis-package:analysisPackageAddToCartError'} />);
setIsAddingToCart(false);
console.error(e);
}
};
return (

View File

@@ -16,6 +16,7 @@ const PathsSchema = z.object({
home: z.string().min(1),
selectPackage: z.string().min(1),
booking: z.string().min(1),
bookingHandle: z.string().min(1),
myOrders: z.string().min(1),
analysisResults: z.string().min(1),
orderAnalysisPackage: z.string().min(1),
@@ -64,6 +65,7 @@ const pathsConfig = PathsSchema.parse({
joinTeam: '/join',
selectPackage: '/select-package',
booking: '/home/booking',
bookingHandle: '/home/booking/[handle]',
orderAnalysisPackage: '/home/order-analysis-package',
myOrders: '/home/order',
analysisResults: '/home/analysis-results',

View File

@@ -199,6 +199,7 @@ export type Database = {
changed_by: string
created_at: string
id: number
extra_data?: Json | null
}
Insert: {
account_id: string
@@ -206,6 +207,7 @@ export type Database = {
changed_by: string
created_at?: string
id?: number
extra_data?: Json | null
}
Update: {
account_id?: string
@@ -213,6 +215,7 @@ export type Database = {
changed_by?: string
created_at?: string
id?: number
extra_data?: Json | null
}
Relationships: []
}
@@ -1088,7 +1091,7 @@ export type Database = {
price: number
price_periods: string | null
requires_payment: boolean
sync_id: number
sync_id: string | null
updated_at: string | null
}
Insert: {
@@ -1107,7 +1110,7 @@ export type Database = {
price: number
price_periods?: string | null
requires_payment: boolean
sync_id: number
sync_id?: string | null
updated_at?: string | null
}
Update: {
@@ -1126,7 +1129,7 @@ export type Database = {
price?: number
price_periods?: string | null
requires_payment?: boolean
sync_id?: number
sync_id?: string | null
updated_at?: string | null
}
Relationships: [
@@ -1147,7 +1150,7 @@ export type Database = {
doctor_user_id: string | null
id: number
status: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at: string
updated_at: string | null
updated_by: string | null
user_id: string
value: string | null
@@ -1159,7 +1162,7 @@ export type Database = {
doctor_user_id?: string | null
id?: number
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at?: string
updated_at?: string | null
updated_by?: string | null
user_id: string
value?: string | null
@@ -1171,7 +1174,7 @@ export type Database = {
doctor_user_id?: string | null
id?: number
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at?: string
updated_at?: string | null
updated_by?: string | null
user_id?: string
value?: string | null
@@ -1254,6 +1257,34 @@ export type Database = {
},
]
}
medipost_actions: {
Row: {
id: string
action: string
xml: string
has_analysis_results: boolean
created_at: string
medusa_order_id: string
response_xml: string
has_error: boolean
}
Insert: {
action: string
xml: string
has_analysis_results: boolean
medusa_order_id: string
response_xml: string
has_error: boolean
}
Update: {
action?: string
xml?: string
has_analysis_results?: boolean
medusa_order_id?: string
response_xml?: string
has_error?: boolean
}
}
medreport_product_groups: {
Row: {
created_at: string
@@ -1850,9 +1881,7 @@ export type Database = {
Returns: Json
}
create_team_account: {
Args:
| { account_name: string }
| { account_name: string; new_personal_code: string }
Args: { account_name: string; new_personal_code: string }
Returns: {
application_role: Database["medreport"]["Enums"]["application_role"]
city: string | null
@@ -1921,6 +1950,15 @@ export type Database = {
account_id: string
}[]
}
get_latest_medipost_dispatch_state_for_order: {
Args: {
medusa_order_id: string
}
Returns: {
has_success: boolean
action_date: string
}
}
get_medipost_dispatch_tries: {
Args: { p_medusa_order_id: string }
Returns: number
@@ -2137,6 +2175,21 @@ export type Database = {
}
Returns: Json
}
sync_analysis_results: {
}
send_medipost_test_response_for_order: {
Args: {
medusa_order_id: string
}
}
order_has_medipost_dispatch_error: {
Args: {
medusa_order_id: string
}
Returns: {
success: boolean
}
}
}
Enums: {
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"

View File

@@ -1,7 +1,9 @@
{
"title": "Select analysis package",
"noPackagesAvailable": "No packages available",
"selectThisPackage": "Select this package",
"selectPackage": "Select package",
"comparePackages": "Compare packages"
"title": "Select analysis package",
"noPackagesAvailable": "No packages available",
"selectThisPackage": "Select this package",
"selectPackage": "Select package",
"comparePackages": "Compare packages",
"analysisPackageAddedToCart": "Analysis package added to cart",
"analysisPackageAddToCartError": "Adding analysis package to cart failed"
}

View File

@@ -1,5 +1,7 @@
{
"title": "Select analysis",
"description": "Select the analysis that suits your needs",
"analysisNotAvailable": "Analysis is not available currently"
"description": "All analysis results will appear within 1-3 days after the blood test.",
"analysisNotAvailable": "Analysis is not available currently",
"analysisAddedToCart": "Analysis added to cart",
"analysisAddToCartError": "Adding analysis to cart failed"
}

View File

@@ -1,8 +1,9 @@
{
"title": "Vali teenus",
"description": "Vali sobiv teenus või pakett vastavalt oma tervisemurele või -eesmärgile.",
"analysisPackages": {
"title": "Analüüside paketid",
"description": "Tutvu personaalsete analüüsi pakettidega ja telli"
}
}
"title": "Vali teenus",
"description": "Vali sobiv teenus või pakett vastavalt oma tervisemurele või -eesmärgile.",
"analysisPackages": {
"title": "Analüüside paketid",
"description": "Tutvu personaalsete analüüsi pakettidega ja telli"
},
"noCategories": "Teenuste loetelu ei leitud, proovi hiljem uuesti"
}

View File

@@ -80,7 +80,8 @@
"dashboard": "Ülevaade",
"settings": "Settings",
"profile": "Profile",
"application": "Application"
"application": "Application",
"pickTime": "Vali aeg"
},
"roles": {
"owner": {

View File

@@ -1,7 +1,9 @@
{
"title": "Vali analüüsi pakett",
"noPackagesAvailable": "Teenuste loetelu ei leitud, proovi hiljem uuesti",
"selectThisPackage": "Vali see pakett",
"selectPackage": "Vali pakett",
"comparePackages": "Võrdle pakette"
"title": "Vali analüüsi pakett",
"noPackagesAvailable": "Teenuste loetelu ei leitud, proovi hiljem uuesti",
"selectThisPackage": "Vali see pakett",
"selectPackage": "Vali pakett",
"comparePackages": "Võrdle pakette",
"analysisPackageAddedToCart": "Analüüsi pakett lisatud ostukorvi",
"analysisPackageAddToCartError": "Analüüsi paketi lisamine ostukorvi ebaõnnestus"
}

View File

@@ -1,5 +1,7 @@
{
"title": "Vali analüüs",
"description": "Vali enda vajadustele sobiv analüüs",
"analysisNotAvailable": "Analüüsi tellimine ei ole hetkel saadaval"
"description": "Kõikide analüüside tulemused ilmuvad 13 tööpäeva jooksul peale vere andmist.",
"analysisNotAvailable": "Analüüsi tellimine ei ole hetkel saadaval",
"analysisAddedToCart": "Analüüs lisatud ostukorvi",
"analysisAddToCartError": "Analüüsi lisamine ostukorvi ebaõnnestus"
}

View File

@@ -0,0 +1,3 @@
-- Enable required extensions for cron jobs and HTTP requests
create extension if not exists pg_cron;
create extension if not exists pg_net;

View File

@@ -1,5 +1,3 @@
create extension if not exists pg_net;
create or replace function medreport.medipost_retry_dispatch(
order_id text
)

View File

@@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION medreport.sync_analysis_results()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
select net.http_post(
url := 'https://test.medreport.ee/api/job/sync-analysis-results',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
)
) as request_id;
END;
$$;
grant execute on function medreport.sync_analysis_results() to service_role;

View File

@@ -0,0 +1,19 @@
CREATE OR REPLACE FUNCTION medreport.send_medipost_test_response_for_order(medusa_order_id text)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
select net.http_post(
url := 'https://test.medreport.ee/api/order/medipost-test-response',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
),
body := jsonb_build_object(
'medusaOrderId', medusa_order_id
)
) as request_id;
END;
$$;
grant execute on function medreport.send_medipost_test_response_for_order(text) to service_role;

View File

@@ -1,12 +1,8 @@
-- 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
'send-test-medipost-responses-every-15-minutes',
'*/15 * * * *',
$$
select
net.http_post(

View File

@@ -1,7 +1,3 @@
-- 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(

View File

@@ -0,0 +1,4 @@
Migrations that require env specific parameters.
- JOBS_API_TOKEN
- app deploy public or internal URL

View File

@@ -0,0 +1,2 @@
ALTER TABLE medreport.connected_online_services
ALTER COLUMN sync_id TYPE text USING sync_id::text;

View File

@@ -0,0 +1,16 @@
CREATE TABLE medreport.medipost_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action VARCHAR(255) NOT NULL,
xml VARCHAR(131072),
has_analysis_results BOOLEAN NOT NULL DEFAULT FALSE,
medusa_order_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
ALTER TABLE medreport.medipost_actions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_select" ON medreport.medipost_actions FOR SELECT TO service_role USING (true);
CREATE POLICY "service_role_insert" ON medreport.medipost_actions FOR INSERT TO service_role WITH CHECK (true);
CREATE POLICY "service_role_update" ON medreport.medipost_actions FOR UPDATE TO service_role USING (true);
CREATE POLICY "service_role_delete" ON medreport.medipost_actions FOR DELETE TO service_role USING (true);
grant select, insert, update, delete on table medreport.medipost_actions to service_role;

View File

@@ -0,0 +1,2 @@
ALTER TABLE medreport.medipost_actions ADD COLUMN response_xml VARCHAR(131072);
ALTER TABLE medreport.medipost_actions ADD COLUMN has_error BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1 @@
ALTER TABLE audit.page_views ADD COLUMN extra_data JSONB;

View File

@@ -0,0 +1,11 @@
CREATE OR REPLACE FUNCTION medreport.order_has_medipost_dispatch_error(medusa_order_id text)
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM medreport.medipost_actions
WHERE medusa_order_id = $1
AND action = 'send_order_to_medipost'
AND has_error = true
);
$$ LANGUAGE sql STABLE;
grant execute on function medreport.order_has_medipost_dispatch_error(text) to service_role;

View File

@@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION medreport.get_latest_medipost_dispatch_state_for_order(medusa_order_id text)
RETURNS TABLE(has_success boolean, action_date timestamp with time zone) AS $$
SELECT
CASE
WHEN ma.has_error = false THEN true
ELSE false
END as has_success,
ma.created_at as action_date
FROM medreport.medipost_actions ma
WHERE ma.medusa_order_id = $1
AND ma.action = 'send_order_to_medipost'
ORDER BY ma.created_at DESC
LIMIT 1;
$$ LANGUAGE sql STABLE;
grant execute on function medreport.get_latest_medipost_dispatch_state_for_order(text) to service_role;