diff --git a/app/api/job/test-medipost-responses/route.ts b/app/api/job/test-medipost-responses/route.ts new file mode 100644 index 0000000..2cf8fa7 --- /dev/null +++ b/app/api/job/test-medipost-responses/route.ts @@ -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 }); +} diff --git a/app/api/order/medipost-create/route.ts b/app/api/order/medipost-create/route.ts deleted file mode 100644 index 10b9ea1..0000000 --- a/app/api/order/medipost-create/route.ts +++ /dev/null @@ -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 }); -}; diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts index fb4d518..40cf4b2 100644 --- a/app/api/order/medipost-test-response/route.ts +++ b/app/api/order/medipost-test-response/route.ts @@ -11,7 +11,7 @@ export async function POST(request: Request) { // 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 medreportOrder = await getOrder({ medusaOrderId }); @@ -19,7 +19,8 @@ export async function POST(request: Request) { 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`); + 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({ person: { idCode: account.personal_code!, @@ -27,7 +28,7 @@ export async function POST(request: Request) { lastName: account.last_name ?? '', phone: account.phone ?? '', }, - orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId), + orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId), orderedAnalysesIds: [], orderId: medusaOrderId, orderCreatedAt: new Date(medreportOrder.created_at), diff --git a/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx b/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx index c092fe5..66ceeac 100644 --- a/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { ArrowDown } from 'lucide-react'; import { cn } from '@kit/ui/utils'; +import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; export enum AnalysisResultLevel { VERY_LOW = 0, @@ -17,11 +18,13 @@ const Level = ({ color, isFirst = false, isLast = false, + arrowLocation, }: { isActive?: boolean; color: 'destructive' | 'success' | 'warning' | 'gray-200'; isFirst?: boolean; isLast?: boolean; + arrowLocation?: number; }) => { return (
{isActive && ( -
+
)} @@ -52,11 +58,33 @@ const AnalysisLevelBar = ({ normLowerIncluded = true, normUpperIncluded = true, level, + results, }: { normLowerIncluded?: boolean; normUpperIncluded?: boolean; 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 (
{normLowerIncluded && ( @@ -73,8 +101,9 @@ const AnalysisLevelBar = ({ {normUpperIncluded && ( diff --git a/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx b/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx index 85d52e2..91af308 100644 --- a/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; import { format } from 'date-fns'; @@ -39,11 +39,12 @@ const Analysis = ({ const normUpper = results?.norm_upper || 0; const [showTooltip, setShowTooltip] = useState(false); - const isUnderNorm = value < normLower; - const getAnalysisResultLevel = () => { + const analysisResultLevel = useMemo(() => { if (!results) { return null; } + + const isUnderNorm = value < normLower; if (isUnderNorm) { switch (status) { case AnalysisStatus.MEDIUM: @@ -60,7 +61,7 @@ const Analysis = ({ default: return AnalysisResultLevel.NORMAL; } - }; + }, [results, value, normLower]); return (
@@ -99,9 +100,10 @@ const Analysis = ({
) : ( diff --git a/app/home/(user)/(dashboard)/analysis-results/page.tsx b/app/home/(user)/(dashboard)/analysis-results/page.tsx index f96f28d..27041df 100644 --- a/app/home/(user)/(dashboard)/analysis-results/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/page.tsx @@ -15,10 +15,11 @@ import { PAGE_VIEW_ACTION, createPageViewLog, } 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 { loadUserAnalysis } from '../../_lib/server/load-user-analysis'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); @@ -51,43 +52,16 @@ async function AnalysisResultsPage() { action: PAGE_VIEW_ACTION.VIEW_ANALYSIS_RESULTS, }); - const analysisElementIds = [ - ...new Set( - analysisOrders - ?.flatMap((order) => order.analysis_element_ids) - .filter(Boolean) as number[], - ), + const getAnalysisElementIds = (analysisOrders: AnalysisOrder[]) => [ + ...new Set(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 = - analysisElementsWithResults.length === 0 && - analysisElementsWithoutResults.length === 0; + const analysisElementIds = getAnalysisElementIds(analysisOrders); + const analysisElements = await getAnalysisElements({ ids: analysisElementIds }); return ( - -
+ +

@@ -106,33 +80,46 @@ async function AnalysisResultsPage() {

-
- {analysisElementsWithResults.map(({ results }) => { - const analysisElement = analysisElements.find( - (element) => - element.analysis_id_original === - results.analysis_element_original_id, - ); - if (!analysisElement) { - return null; - } +
+ {analysisOrders.length > 0 && analysisElements.length > 0 ? analysisOrders.map((analysisOrder) => { + const analysisResponse = analysisResponses?.find((response) => response.analysis_order_id === analysisOrder.id); + const analysisElementIds = getAnalysisElementIds([analysisOrder]); + const analysisElementsForOrder = analysisElements.filter((element) => analysisElementIds.includes(element.id)); return ( - +
+

+ +

+
+ + +
+
+ {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 ( + + ); + } + return ( + + ); + }) : ( +
+ +
+ )} +
+
); - })} - {analysisElementsWithoutResults.map((element) => ( - - ))} - {hasNoAnalysisElements && ( + }) : (
- +
)}
diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index bede603..5ee25aa 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -9,6 +9,9 @@ import { placeOrder, retrieveCart } from "@lib/data/cart"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; import { createOrder } from '~/lib/services/order.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 MONTONIO_PAID_STATUS = 'PAID'; @@ -31,7 +34,22 @@ const env = () => z 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 { const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates'); const { getMailer } = await import('@kit/mailers'); @@ -55,6 +73,11 @@ const sendEmail = async ({ email, analysisPackageName, personName, partnerLocati .catch((error) => { throw new Error(`Failed to send email, message=${error}`); }); + await createNotificationsApi(client) + .createNotification({ + account_id: account.id, + body: html, + }); } catch (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) { const { language } = await createI18nServerInstance(); - + const secretKey = process.env.MONTONIO_SECRET_KEY as string; const decoded = jwt.verify(orderToken, secretKey, { algorithms: ['HS256'], }) as MontonioOrderToken; - + if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) { throw new Error("Payment not successful"); } @@ -79,7 +102,7 @@ export async function processMontonioCallback(orderToken: string) { } try { - const [,, cartId] = decoded.merchantReferenceDisplay.split(':'); + const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); if (!cartId) { throw new Error("Cart ID not found"); } @@ -89,6 +112,7 @@ export async function processMontonioCallback(orderToken: string) { throw new Error("Cart not found"); } + const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder }); const orderId = await createOrder({ medusaOrder, orderedAnalysisElements }); @@ -96,7 +120,7 @@ export async function processMontonioCallback(orderToken: string) { const { productTypes } = await listProductTypes(); 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 orderResult = { medusaOrderId: medusaOrder.id, email: medusaOrder.email, @@ -107,10 +131,10 @@ export async function processMontonioCallback(orderToken: string) { const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult; const personName = account.name; - + if (email && analysisPackageName) { try { - await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language }); + await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language }); } catch (error) { console.error("Failed to send email", error); } @@ -118,9 +142,9 @@ export async function processMontonioCallback(orderToken: string) { // @TODO send email for separate analyses console.error("Missing email or analysisPackageName", orderResult); } - + sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); - + return { success: true, orderId }; } catch (error) { console.error("Failed to place order", error); diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 4e96c49..8eed4f6 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -32,11 +32,12 @@ export default async function CartPage() { const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1); const item = otherItemsSorted[0]; + const hasItemsWithTimer = false as boolean; return ( }> - {item && item.updated_at && } + {hasItemsWithTimer && item && item.updated_at && } diff --git a/app/home/(user)/(dashboard)/order-analysis/page.tsx b/app/home/(user)/(dashboard)/order-analysis/page.tsx index 942f78e..6358f18 100644 --- a/app/home/(user)/(dashboard)/order-analysis/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis/page.tsx @@ -5,6 +5,8 @@ import { Trans } from '@kit/ui/trans'; import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { loadAnalyses } from '../../_lib/server/load-analyses'; 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 () => { const { t } = await createI18nServerInstance(); @@ -15,8 +17,18 @@ export const generateMetadata = async () => { }; async function OrderAnalysisPage() { + const account = await loadCurrentUserAccount(); + if (!account) { + throw new Error('Account not found'); + } + const { analyses, countryCode } = await loadAnalyses(); + await createPageViewLog({ + accountId: account.id, + action: PAGE_VIEW_ACTION.VIEW_ORDER_ANALYSIS, + }); + return ( <> 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) - || []); + const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages')!; return ( <> @@ -73,8 +39,27 @@ async function OrdersPage() { description={} /> - - + {analysisOrders.map((analysisOrder) => { + 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 ( + + + + + ) + })} ); diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 2f8b287..14d8bff 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -8,6 +8,7 @@ import { toTitleCase } from '@/lib/utils'; import Dashboard from '../_components/dashboard'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; +import DashboardCards from '../_components/dashboard-cards'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); @@ -26,6 +27,7 @@ async function UserHomePage() { return ( <> + diff --git a/app/home/(user)/_components/cart/cart-item-delete.tsx b/app/home/(user)/_components/cart/cart-item-delete.tsx index 4c359cf..be67593 100644 --- a/app/home/(user)/_components/cart/cart-item-delete.tsx +++ b/app/home/(user)/_components/cart/cart-item-delete.tsx @@ -5,8 +5,8 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from 'sonner'; -import { deleteLineItem } from "@lib/data/cart"; import { Spinner } from "@medusajs/icons"; +import { handleDeleteCartItem } from "~/lib/services/medusaCart.service"; const CartItemDelete = ({ id, @@ -22,7 +22,7 @@ const CartItemDelete = ({ setIsDeleting(true); const promise = async () => { - await deleteLineItem(id); + await handleDeleteCartItem({ lineId: id }); }; toast.promise(promise, { diff --git a/app/home/(user)/_components/dashboard-cards.tsx b/app/home/(user)/_components/dashboard-cards.tsx new file mode 100644 index 0000000..a24e465 --- /dev/null +++ b/app/home/(user)/_components/dashboard-cards.tsx @@ -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 ( +
+ + +
+ +
+
+ + + +
+
+ +
+ +
+ + + +
+
+
+ ); +} diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx index 9a9d8d8..f7575bc 100644 --- a/app/home/(user)/_components/order-analyses-cards.tsx +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -7,11 +7,14 @@ import { Card, CardHeader, CardFooter, + CardDescription, } from '@kit/ui/card'; import { StoreProduct, StoreProductVariant } from '@medusajs/types'; import { useState } from 'react'; import { handleAddToCart } from '~/lib/services/medusaCart.service'; import { useRouter } from 'next/navigation'; +import { InfoTooltip } from '~/components/ui/info-tooltip'; +import { Trans } from '@kit/ui/trans'; export default function OrderAnalysesCards({ analyses, @@ -21,7 +24,7 @@ export default function OrderAnalysesCards({ countryCode: string; }) { const router = useRouter(); - + const [isAddingToCart, setIsAddingToCart] = useState(false); const handleSelect = async (selectedVariant: StoreProductVariant) => { if (!selectedVariant?.id || isAddingToCart) return null @@ -44,37 +47,62 @@ export default function OrderAnalysesCards({
{analyses.map(({ title, - variants - }) => ( - - -
- -
-
- -
- -
-
- {title} -
-
-
- ))} + +
+ {isAvailable && ( +
+ +
+ )} + + +
+ {title} + {description && ( + <> + {' '} + + + )} +
+ {isAvailable && subtitle && ( + + {subtitle} + + )} + {!isAvailable && ( + + + + )} +
+ + ); + })}
); } diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx new file mode 100644 index 0000000..077d761 --- /dev/null +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -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 ( +
+

+ +

+
+
+ +
+ + + +
+
+ + +
+
+ ) +} diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx new file mode 100644 index 0000000..48d502a --- /dev/null +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + {items + .sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1) + .map((orderItem) => ( + + +

+ {orderItem.product_title} +

+
+ + + {formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')} + + + + + + + + + + + + + +
+ ))} +
+
+ ) +} diff --git a/app/home/(user)/_components/orders/orders-item.tsx b/app/home/(user)/_components/orders/orders-item.tsx deleted file mode 100644 index ea8943d..0000000 --- a/app/home/(user)/_components/orders/orders-item.tsx +++ /dev/null @@ -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 ( - - -

- {orderItem.item.product_title} -

-
- - - {formatDate(orderItem.item.created_at, 'dd.MM.yyyy HH:mm')} - - - - - - - - - - - - - -
- ) -} diff --git a/app/home/(user)/_components/orders/orders-table.tsx b/app/home/(user)/_components/orders/orders-table.tsx deleted file mode 100644 index 18f1872..0000000 --- a/app/home/(user)/_components/orders/orders-table.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - - - - - - - - - {orderItems - .sort((a, b) => (a.item.created_at ?? "") > (b.item.created_at ?? "") ? -1 : 1) - .map((orderItem) => ())} - -
- ) -} diff --git a/app/home/(user)/_components/orders/types.ts b/app/home/(user)/_components/orders/types.ts deleted file mode 100644 index 2c7140e..0000000 --- a/app/home/(user)/_components/orders/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { StoreOrderLineItem } from "@medusajs/types"; - -export interface IOrderLineItem { - item: StoreOrderLineItem; - medusaOrderId: string; - orderId: number; - orderStatus: string; -} diff --git a/app/home/[account]/_components/team-account-layout-sidebar.tsx b/app/home/[account]/_components/team-account-layout-sidebar.tsx index 8f53ef4..4208b62 100644 --- a/app/home/[account]/_components/team-account-layout-sidebar.tsx +++ b/app/home/[account]/_components/team-account-layout-sidebar.tsx @@ -1,5 +1,6 @@ import type { User } from '@supabase/supabase-js'; +import { ApplicationRole } from '@kit/accounts/types/accounts'; import { Sidebar, SidebarContent, @@ -18,6 +19,7 @@ type AccountModel = { label: string | null; value: string | null; image: string | null; + application_role: ApplicationRole | null; }; export function TeamAccountLayoutSidebar(props: { diff --git a/app/home/[account]/_components/team-account-navigation-menu.tsx b/app/home/[account]/_components/team-account-navigation-menu.tsx index c09a19a..80c1bb9 100644 --- a/app/home/[account]/_components/team-account-navigation-menu.tsx +++ b/app/home/[account]/_components/team-account-navigation-menu.tsx @@ -1,7 +1,4 @@ -import { - BorderedNavigationMenu, - BorderedNavigationMenuItem, -} from '@kit/ui/bordered-navigation-menu'; +import { useMemo } from 'react'; import { AppLogo } from '~/components/app-logo'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; @@ -10,18 +7,22 @@ import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.co // local imports import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader'; import { TeamAccountNotifications } from './team-account-notifications'; -import { useMemo } from 'react'; export function TeamAccountNavigationMenu(props: { workspace: TeamAccountWorkspace; }) { const { account, user, accounts: rawAccounts } = props.workspace; - const accounts = useMemo(() => rawAccounts.map((account) => ({ - label: account.name, - value: account.slug, - image: account.picture_url, - })),[rawAccounts]) + const accounts = useMemo( + () => + rawAccounts.map((account) => ({ + label: account.name, + value: account.slug, + image: account.picture_url, + application_role: account.application_role, + })), + [rawAccounts], + ); const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce< Array<{ @@ -48,7 +49,7 @@ export function TeamAccountNavigationMenu(props: {
-
+
({ - label: name, - value: slug, - image: picture_url, - })); + const accounts = data.accounts.map( + ({ name, slug, picture_url, application_role }) => ({ + label: name, + value: slug, + image: picture_url, + application_role, + }), + ); return ( @@ -91,11 +94,14 @@ function HeaderLayout({ }>) { const data = use(loadTeamWorkspace(account)); - const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ - label: name, - value: slug, - image: picture_url, - })); + const accounts = data.accounts.map( + ({ name, slug, picture_url, application_role }) => ({ + label: name, + value: slug, + image: picture_url, + application_role, + }), + ); return ( diff --git a/components/personal-account-dropdown-container.tsx b/components/personal-account-dropdown-container.tsx index ce0d76b..fe68027 100644 --- a/components/personal-account-dropdown-container.tsx +++ b/components/personal-account-dropdown-container.tsx @@ -3,6 +3,7 @@ import type { User } from '@supabase/supabase-js'; 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 { useUser } from '@kit/supabase/hooks/use-user'; @@ -28,13 +29,13 @@ export function ProfileAccountDropdownContainer(props: { id: string | null; name: string | null; picture_url: string | null; - application_role: string; + application_role: ApplicationRole | null; }; accounts: { label: string | null; value: string | null; image?: string | null; - application_role: string; + application_role: ApplicationRole | null; }[]; }) { const signOut = useSignOut(); diff --git a/components/select-analysis-package.tsx b/components/select-analysis-package.tsx index b4863cf..d6b74d2 100644 --- a/components/select-analysis-package.tsx +++ b/components/select-analysis-package.tsx @@ -1,6 +1,6 @@ "use client"; -import { use, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; diff --git a/lib/services/audit/pageView.service.ts b/lib/services/audit/pageView.service.ts index ea5aaca..28fc458 100644 --- a/lib/services/audit/pageView.service.ts +++ b/lib/services/audit/pageView.service.ts @@ -3,6 +3,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; export enum PAGE_VIEW_ACTION { VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS', REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS', + VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS', } export const createPageViewLog = async ({ diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index e19e6db..8508663 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -36,7 +36,7 @@ import { uniqBy } from 'lodash'; import { Tables } from '@kit/supabase/database'; import { createAnalysisGroup } from './analysis-group.service'; 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 { getAnalyses } from './analyses.service'; import { getAccountAdmin } from './account.service'; @@ -218,24 +218,27 @@ export async function readPrivateMessageResponse({ privateMessage.messageId, ); const messageResponse = privateMessageContent?.Saadetis?.Vastus; + const medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId; 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'>; try { - order = await getOrder({ medusaOrderId: messageResponse.ValisTellimuseId }); + order = await getOrder({ medusaOrderId }); } catch (e) { 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 }); - if (status === 'COMPLETED') { - await updateOrder({ orderId: order.id, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); + if (status.isPartial) { + 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); messageIdProcessed = privateMessage.messageId; } @@ -559,11 +562,11 @@ function getLatestMessage({ ); } -export async function syncPrivateMessage({ +async function syncPrivateMessage({ messageResponse, order, }: { - messageResponse: MedipostOrderResponse['Saadetis']['Vastus']; + messageResponse: NonNullable; order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; }) { const supabase = getSupabaseServerAdminClient() @@ -606,6 +609,9 @@ export async function syncPrivateMessage({ Tables<{ schema: 'medreport' }, 'analysis_response_elements'>, 'id' | 'created_at' | 'updated_at' >[] = []; + + const analysisResponseId = analysisResponse[0]!.id; + for (const analysisGroup of analysisGroups) { const groupItems = toArray( analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], @@ -618,7 +624,7 @@ export async function syncPrivateMessage({ responses.push( ...elementAnalysisResponses.map((response) => ({ analysis_element_original_id: element.UuringId, - analysis_response_id: analysisResponse[0]!.id, + analysis_response_id: analysisResponseId, norm_lower: response.NormAlum?.['#text'] ?? null, norm_lower_included: response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', @@ -640,11 +646,11 @@ export async function syncPrivateMessage({ .schema('medreport') .from('analysis_response_elements') .delete() - .eq('analysis_response_id', analysisResponse[0].id); + .eq('analysis_response_id', analysisResponseId); if (deleteError) { 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) { 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); - return AnalysisOrderStatus[messageResponse.TellimuseOlek]; + const { data: allOrderResponseElements} = await supabase + .schema('medreport') + .from('analysis_response_elements') + .select('*') + .eq('analysis_response_id', analysisResponseId) + .throwOnError(); + const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0; + if (allOrderResponseElements.length !== expectedOrderResponseElements) { + return { isPartial: true }; + } + + const statusFromResponse = AnalysisOrderStatus[messageResponse.TellimuseOlek]; + return { isCompleted: statusFromResponse === 'COMPLETED' }; } export async function sendOrderToMedipost({ @@ -688,7 +705,7 @@ export async function sendOrderToMedipost({ }); await sendPrivateMessage(orderXml); - await updateOrder({ orderId: medreportOrder.id, orderStatus: 'PROCESSING' }); + await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); } export async function getOrderedAnalysisElementsIds({ @@ -720,7 +737,7 @@ export async function getOrderedAnalysisElementsIds({ countryCode, 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) { throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`); } diff --git a/lib/services/medipostTest.service.ts b/lib/services/medipostTest.service.ts index 192db4c..1a3b0d9 100644 --- a/lib/services/medipostTest.service.ts +++ b/lib/services/medipostTest.service.ts @@ -90,7 +90,7 @@ export async function composeOrderTestResponseXML({ // 1 – Järjekorras, 2 – Ootel, 3 - Töös, 4 – Lõpetatud, // 5 – Tagasi lükatud, 6 – Tühistatud. const orderStatus = 4; - const orderNumber = 'TSU000001200'; + const orderNumber = orderId; const allAnalysisElementsForGroups = analysisElements?.filter((element) => { return analysisGroups.some((group) => group.id === element.analysis_groups.id); @@ -153,7 +153,7 @@ export async function composeOrderTestResponseXML({ const lower = getRandomInt(0, 100); 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); return (` @@ -175,7 +175,7 @@ export async function composeOrderTestResponseXML({ ${formatDate(new Date(), 'yyyy-MM-dd HH:mm:ss')} ${upper} ${lower} - 0 + ${result < lower ? 1 : (result > upper ? 1 : 0)} 1 diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index 48446e3..a84c596 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; +import { getCartId } from '@lib/data/cookies'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src'; import { requireUserInServerComponent } from '../server/require-user-in-server-component'; @@ -64,6 +65,36 @@ export async function handleAddToCart({ 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 }) { const supabase = getSupabaseServerClient(); const user = await requireUserInServerComponent(); diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 998d564..998be03 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -56,6 +56,30 @@ export async function updateOrder({ .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({ medusaOrderId, orderId, @@ -84,13 +108,39 @@ export async function getAnalysisOrders({ }: { 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') .from('analysis_orders') .select('*') if (orderStatus) { query.eq('status', orderStatus); } - const orders = await query.throwOnError(); + const orders = await query.order('created_at', { ascending: false }).throwOnError(); return orders.data; } diff --git a/lib/types/medipost.ts b/lib/types/medipost.ts index 0794fa0..583fc33 100644 --- a/lib/types/medipost.ts +++ b/lib/types/medipost.ts @@ -202,7 +202,7 @@ export type MedipostOrderResponse = IMedipostResponseXMLBase & { SaadetisId: string; Email: string; }; - Vastus: { + Vastus?: { ValisTellimuseId: string; Asutus: { '@_tyyp': string; // TEOSTAJA @@ -246,6 +246,9 @@ export type MedipostOrderResponse = IMedipostResponseXMLBase & { TellimuseOlek: keyof typeof AnalysisOrderStatus; UuringuGrupp?: ResponseUuringuGrupp | ResponseUuringuGrupp[]; }; + Tellimus?: { + ValisTellimuseId: string; + } }; }; @@ -258,7 +261,7 @@ export const AnalysisOrderStatus = { 6: 'CANCELLED', } as const; export const NormStatus: Record = { - 1: 'NORMAL', - 2: 'WARNING', - 3: 'REQUIRES_ATTENTION', + 0: 'NORMAL', + 1: 'WARNING', + 2: 'REQUIRES_ATTENTION', } as const; diff --git a/lib/utils.ts b/lib/utils.ts index 1eb8901..a9c34c4 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -18,3 +18,12 @@ export function toTitleCase(str?: string) { text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(), ); } + +export function sortByDate(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(); + }); +} diff --git a/packages/email-templates/src/locales/en/synlab-email.json b/packages/email-templates/src/locales/en/synlab-email.json index 3270ecc..cde6548 100644 --- a/packages/email-templates/src/locales/en/synlab-email.json +++ b/packages/email-templates/src/locales/en/synlab-email.json @@ -1,9 +1,9 @@ { - "subject": "Your Synlab order has been placed - {{analysisPackageName}}", - "previewText": "Your Synlab order has been placed - {{analysisPackageName}}", - "heading": "Your Synlab order has been placed - {{analysisPackageName}}", + "subject": "Your Medreport order has been placed - {{analysisPackageName}}", + "previewText": "Your Medreport order has been placed - {{analysisPackageName}}", + "heading": "Your Medreport order has been placed - {{analysisPackageName}}", "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": "If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point - view locations and opening hours.", "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.", diff --git a/packages/email-templates/src/locales/et/synlab-email.json b/packages/email-templates/src/locales/et/synlab-email.json index fd11035..fa16c20 100644 --- a/packages/email-templates/src/locales/et/synlab-email.json +++ b/packages/email-templates/src/locales/et/synlab-email.json @@ -1,9 +1,9 @@ { - "subject": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}", - "previewText": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}", - "heading": "Teie SYNLAB tellimus on kinnitatud - {{analysisPackageName}}", + "subject": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}", + "previewText": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}", + "heading": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}", "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": "Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - vaata asukohti ja lahtiolekuaegasid.", "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: saatekirjad alt eriarsti saatekiri.", diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx index 9141ff1..8f21130 100644 --- a/packages/features/accounts/src/components/personal-account-dropdown.tsx +++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx @@ -32,6 +32,7 @@ import { cn } from '@kit/ui/utils'; import { toTitleCase } from '~/lib/utils'; import { usePersonalAccountData } from '../hooks/use-personal-account-data'; +import { ApplicationRole, ApplicationRoleEnum } from '../types/accounts'; const PERSONAL_ACCOUNT_SLUG = 'personal'; @@ -51,13 +52,13 @@ export function PersonalAccountDropdown({ id: string | null; name: string | null; picture_url: string | null; - application_role: string; + application_role: ApplicationRole | null; }; accounts: { label: string | null; value: string | null; image?: string | null; - application_role: string; + application_role: ApplicationRole | null; }[]; signOutRequested: () => unknown; @@ -97,13 +98,14 @@ export function PersonalAccountDropdown({ const isSuperAdmin = useMemo(() => { const hasAdminRole = - personalAccountData?.application_role === 'super_admin'; + personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin; return hasAdminRole && hasTotpFactor; }, [user, personalAccountData, hasTotpFactor]); const isDoctor = useMemo(() => { - const hasDoctorRole = personalAccountData?.application_role === 'doctor'; + const hasDoctorRole = + personalAccountData?.application_role === ApplicationRoleEnum.Doctor; return hasDoctorRole && hasTotpFactor; }, [user, personalAccountData, hasTotpFactor]); diff --git a/packages/features/accounts/src/types/accounts.ts b/packages/features/accounts/src/types/accounts.ts index bbc4ddb..eb9cf8e 100644 --- a/packages/features/accounts/src/types/accounts.ts +++ b/packages/features/accounts/src/types/accounts.ts @@ -1,7 +1,17 @@ import { Database } from '@kit/supabase/database'; -export type UserAnalysisElement = Database['medreport']['Tables']['analysis_response_elements']['Row']; -export type UserAnalysisResponse = Database['medreport']['Tables']['analysis_responses']['Row'] & { - elements: UserAnalysisElement[]; -}; +export type UserAnalysisElement = + Database['medreport']['Tables']['analysis_response_elements']['Row']; +export type UserAnalysisResponse = + Database['medreport']['Tables']['analysis_responses']['Row'] & { + elements: UserAnalysisElement[]; + }; export type UserAnalysis = UserAnalysisResponse[]; + +export type ApplicationRole = + Database['medreport']['Tables']['accounts']['Row']['application_role']; +export enum ApplicationRoleEnum { + User = 'user', + Doctor = 'doctor', + SuperAdmin = 'super_admin', +} \ No newline at end of file diff --git a/packages/features/team-accounts/src/server/api.ts b/packages/features/team-accounts/src/server/api.ts index 60339ff..a57b68d 100644 --- a/packages/features/team-accounts/src/server/api.ts +++ b/packages/features/team-accounts/src/server/api.ts @@ -114,7 +114,8 @@ export class TeamAccountsApi { role, name, slug, - picture_url + picture_url, + application_role ) `, ) diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 8398b9d..9c0ec2f 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -1563,6 +1563,9 @@ export type Database = { Views: { user_account_workspace: { Row: { + application_role: + | Database["medreport"]["Enums"]["application_role"] + | null id: string | null name: string | null picture_url: string | null @@ -1574,6 +1577,9 @@ export type Database = { } user_accounts: { Row: { + application_role: + | Database["medreport"]["Enums"]["application_role"] + | null id: string | null name: string | null picture_url: string | null @@ -1634,7 +1640,9 @@ export type Database = { Returns: Json } create_team_account: { - Args: { account_name: string; new_personal_code: string } + Args: + | { account_name: string } + | { account_name: string; new_personal_code: string } Returns: { application_role: Database["medreport"]["Enums"]["application_role"] city: string | null @@ -1801,6 +1809,7 @@ export type Database = { primary_owner_user_id: string subscription_status: Database["medreport"]["Enums"]["subscription_status"] permissions: Database["medreport"]["Enums"]["app_permissions"][] + application_role: Database["medreport"]["Enums"]["application_role"] }[] } transfer_team_account_ownership: { @@ -1819,6 +1828,22 @@ export type Database = { } 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: { Args: { target_account_id: string diff --git a/public/locales/en/analysis-results.json b/public/locales/en/analysis-results.json index ada8eac..9f6a491 100644 --- a/public/locales/en/analysis-results.json +++ b/public/locales/en/analysis-results.json @@ -5,10 +5,12 @@ "orderNewAnalysis": "Order new analyses", "waitingForResults": "Waiting for results", "noAnalysisElements": "No analysis orders found", + "noAnalysisOrders": "No analysis orders found", "analysisDate": "Analysis result date", "results": { "range": { "normal": "Normal range" } - } + }, + "orderTitle": "Order number {{orderNumber}}" } \ No newline at end of file diff --git a/public/locales/en/dashboard.json b/public/locales/en/dashboard.json index 3893ce4..8563680 100644 --- a/public/locales/en/dashboard.json +++ b/public/locales/en/dashboard.json @@ -12,5 +12,11 @@ "cholesterol": "Cholesterol", "ldlCholesterol": "LDL Cholesterol", "smoking": "Smoking", - "recommendedForYou": "Recommended for you" + "recommendedForYou": "Recommended for you", + "heroCard": { + "orderAnalysis": { + "title": "Order analysis", + "description": "Select an analysis to get started" + } + } } \ No newline at end of file diff --git a/public/locales/en/order-analysis.json b/public/locales/en/order-analysis.json index 3cc4ea9..6ab9af8 100644 --- a/public/locales/en/order-analysis.json +++ b/public/locales/en/order-analysis.json @@ -1,4 +1,5 @@ { "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" } \ No newline at end of file diff --git a/public/locales/et/analysis-results.json b/public/locales/et/analysis-results.json index 0acc140..6334d60 100644 --- a/public/locales/et/analysis-results.json +++ b/public/locales/et/analysis-results.json @@ -5,10 +5,12 @@ "orderNewAnalysis": "Telli uued analüüsid", "waitingForResults": "Tulemuse ootel", "noAnalysisElements": "Veel ei ole tellitud analüüse", + "noAnalysisOrders": "Veel ei ole analüüside tellimusi", "analysisDate": "Analüüsi vastuse kuupäev", "results": { "range": { "normal": "Normaalne vahemik" } - } + }, + "orderTitle": "Tellimus {{orderNumber}}" } \ No newline at end of file diff --git a/public/locales/et/dashboard.json b/public/locales/et/dashboard.json index 69df7e4..810c148 100644 --- a/public/locales/et/dashboard.json +++ b/public/locales/et/dashboard.json @@ -12,5 +12,11 @@ "cholesterol": "Kolesterool", "ldlCholesterol": "LDL kolesterool", "smoking": "Suitsetamine", - "recommendedForYou": "Soovitused sulle" + "recommendedForYou": "Soovitused sulle", + "heroCard": { + "orderAnalysis": { + "title": "Telli analüüs", + "description": "Telli endale sobiv analüüs" + } + } } \ No newline at end of file diff --git a/public/locales/et/order-analysis.json b/public/locales/et/order-analysis.json index f04be5e..42f790e 100644 --- a/public/locales/et/order-analysis.json +++ b/public/locales/et/order-analysis.json @@ -1,4 +1,5 @@ { "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" } \ No newline at end of file diff --git a/run-test-sync-local.sh b/run-test-sync-local.sh index 16d9645..509be1c 100644 --- a/run-test-sync-local.sh +++ b/run-test-sync-local.sh @@ -12,7 +12,7 @@ function send_medipost_test_response() { curl -X POST "$HOSTNAME/api/order/medipost-test-response" \ --header "x-jobs-api-key: $JOBS_API_TOKEN" \ --header 'Content-Type: application/json' \ - --data '{ "medusaOrderId": "'$MEDUSA_ORDER_ID'" }' + --data '{ "medusaOrderId": "'$MEDUSA_ORDER_ID'", "maxItems": 2 }' } function sync_analysis_results() { diff --git a/schedule-setup/setup_send_analysis_test_results_cron.sql b/schedule-setup/setup_send_analysis_test_results_cron.sql new file mode 100644 index 0000000..2562939 --- /dev/null +++ b/schedule-setup/setup_send_analysis_test_results_cron.sql @@ -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; + $$ + ); diff --git a/schedule-setup/setup_sync_analysis_results_cron.sql b/schedule-setup/setup_sync_analysis_results_cron.sql new file mode 100644 index 0000000..832f982 --- /dev/null +++ b/schedule-setup/setup_sync_analysis_results_cron.sql @@ -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; + $$ + ); diff --git a/supabase/migrations/20250813204850_update_analysis_order_status.sql b/supabase/migrations/20250813204850_update_analysis_order_status.sql new file mode 100644 index 0000000..c02419f --- /dev/null +++ b/supabase/migrations/20250813204850_update_analysis_order_status.sql @@ -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') diff --git a/supabase/migrations/20250814071257_update_accounts_view.sql b/supabase/migrations/20250814071257_update_accounts_view.sql new file mode 100644 index 0000000..0ad5bd9 --- /dev/null +++ b/supabase/migrations/20250814071257_update_accounts_view.sql @@ -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; diff --git a/supabase/migrations/20250818150655_notification_max_length_fix.sql b/supabase/migrations/20250818150655_notification_max_length_fix.sql new file mode 100644 index 0000000..e95d7d3 --- /dev/null +++ b/supabase/migrations/20250818150655_notification_max_length_fix.sql @@ -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);