Merge pull request #40 from MR-medreport/MED-131-v2

feat(MED-131): update analysis/package -> cart -> medipost flow, many fixes/improvements
This commit is contained in:
2025-08-04 16:32:30 +03:00
committed by GitHub
61 changed files with 2113 additions and 734 deletions

3
.env
View File

@@ -73,3 +73,6 @@ MONTONIO_API_URL=https://sandbox-stargate.montonio.com
# MEDUSA
MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
# JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197

View File

@@ -23,3 +23,5 @@ NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197

View File

@@ -100,3 +100,41 @@ To access admin pages follow these steps:
- [View emails](http://localhost:1080/#/)
- Mail server is running on `localhost:1025` without password
## Medipost flow
1. Customer adds analysis to cart in **B2B** storefront
2. Customer checks out from cart and is redirected to **Montonio**
3. Customer pays and is redirected back to **B2B** `GET B2B/home/cart/montonio-callback?order-token=$JWT`
- **Medusa** order is created and cart is emptied
- email is sent to customer
- B2B sends order XML as private message to Medipost.
When **Montonio** has confirmed payment, it will call **Medusa** webhook endpoint and **Medusa** will mark order payment as captured.
In background a job will call `POST B2B/api/job/sync-analysis-results` every n minutes and sync private messages with responses from **Medipost**.
In local dev environment, you can create a private message with analysis responses in **Medipost** system for a submitted order:
`POST B2B/api/order/medipost-test-response body={medusaOrderId:'input here'}`
After that run `POST /api/job/sync-analysis-results` and analysis results should be synced.
In local dev environment, you can import products from B2B to Medusa with this API:
- `POST /api/job/sync-analysis-groups-store`
- Syncs required data of `analyses`, `analysis_elements` data from **B2B** to **Medusa** and creates relevant products and categories.
If product or category already exists, then it is not recreated. Old entries are not deleted either currently.
## Jobs
Required headers:
- `x-jobs-api-key` in UUID format
Endpoints:
- `POST /api/job/sync-analysis-groups`
- Queries **Medipost** for public messages list and takes analysis info from the latest event.
Updates `analyses` and `analysis_elements` lists in **B2B**.
- `POST /api/job/sync-analysis-results`
- Queries **Medipost** for latest private message that has a response and updates order analysis results from lab results data.
- `POST /api/job/sync-connected-online`
- TODO

View File

@@ -2,7 +2,7 @@ import { cache } from 'react';
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getAccount } from '~/lib/services/account.service';
interface Params {
params: Promise<{
@@ -28,21 +28,4 @@ async function AccountPage(props: Params) {
export default AdminGuard(AccountPage);
const loadAccount = cache(accountLoader);
async function accountLoader(id: string) {
const client = getSupabaseServerClient();
const { data, error } = await client
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
.eq('id', id)
.single();
if (error) {
throw error;
}
return data;
}
const loadAccount = cache(getAccount);

View File

@@ -0,0 +1,8 @@
import { config } from 'dotenv';
export default function loadEnv() {
config({ path: `.env` });
if (['local', 'test', 'development', 'production'].includes(process.env.NODE_ENV!)) {
config({ path: `.env.${process.env.NODE_ENV}` });
}
}

View File

@@ -0,0 +1,241 @@
import Medusa from "@medusajs/js-sdk"
import type { AdminProductCategory } from "@medusajs/types";
import { listProductTypes } from "@lib/data/products";
import { getAnalysisElements } from "~/lib/services/analysis-element.service";
import { getAnalysisGroups } from "~/lib/services/analysis-group.service";
import { createMedusaSyncFailEntry, createMedusaSyncSuccessEntry } from "~/lib/services/analyses.service";
const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services';
const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis';
const BASE_ANALYSIS_PRODUCT_HANDLE = 'analysis-base';
const getAdminSdk = () => {
const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!;
const medusaPublishableApiKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
const key = process.env.MEDUSA_SECRET_API_KEY!;
if (!medusaBackendUrl || !medusaPublishableApiKey) {
throw new Error('Medusa environment variables not set');
}
return new Medusa({
baseUrl: medusaBackendUrl,
debug: process.env.NODE_ENV === 'development',
apiKey: key,
});
}
async function createProductCategories({
medusa,
}: {
medusa: Medusa;
}) {
const { product_categories: existingProductCategories } = await medusa.admin.productCategory.list();
const parentCategory = existingProductCategories.find(({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE);
if (!parentCategory) {
throw new Error('Parent category not found');
}
const analysisGroups = await getAnalysisGroups();
if (!analysisGroups) {
throw new Error('Analysis groups not found');
}
const createdCategories: AdminProductCategory[] = [];
for (const analysisGroup of analysisGroups) {
console.info(`Processing analysis group '${analysisGroup.name}'`);
const isExisting = existingProductCategories.find(({ name }) => name === analysisGroup.name);
const isNewlyCreated = createdCategories.find(({ name }) => name === analysisGroup.name);
if (isExisting || isNewlyCreated) {
console.info(`Analysis group '${analysisGroup.name}' already exists`);
continue;
}
const createResponse = await medusa.admin.productCategory.create({
name: analysisGroup.name,
handle: analysisGroup.name,
parent_category_id: parentCategory.id,
is_active: true,
metadata: {
analysisGroupOriginalId: analysisGroup.original_id,
analysisGroupId: analysisGroup.id,
},
});
console.info(`Successfully created category, id=${createResponse.product_category.id}`);
createdCategories.push(createResponse.product_category);
}
}
async function getChildProductCategories({
medusa,
}: {
medusa: Medusa;
}) {
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
const childCategories = allCategories.filter(({ parent_category_id }) => parent_category_id !== null);
return childCategories;
}
async function deleteProductCategories({
medusa,
categories,
}: {
medusa: Medusa;
categories: AdminProductCategory[];
}) {
for (const category of categories) {
await medusa.admin.productCategory.delete(category.id);
}
}
/**
* In case a reset is needed
*/
async function deleteProducts({
medusa,
}: {
medusa: Medusa;
}) {
const { products: existingProducts } = await medusa.admin.product.list({
fields: 'id,collection_id',
limit: 1000,
});
await Promise.all(existingProducts.filter((a) => !a.collection_id).map(({ id }) => medusa.admin.product.delete(id)));
}
async function getAnalysisPackagesType() {
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE);
if (!analysisPackagesType) {
throw new Error('Synlab analysis packages type not found');
}
return analysisPackagesType;
}
async function getProductDefaultFields({
medusa,
}: {
medusa: Medusa;
}) {
const baseProductsResponse = await medusa.admin.product.list({ handle: BASE_ANALYSIS_PRODUCT_HANDLE })
const baseProduct = baseProductsResponse.products[0];
if (!baseProduct) {
throw new Error('Base product not found');
}
const defaultSalesChannels = baseProduct.sales_channels;
if (!Array.isArray(defaultSalesChannels)) {
throw new Error('Base analysis product has no required sales channels');
}
const defaultProductOption = baseProduct.options;
if (!Array.isArray(defaultProductOption)) {
throw new Error('Base analysis product has no required options');
}
const defaultProductVariant = baseProduct.variants?.[0];
if (!defaultProductVariant) {
throw new Error('Base analysis product has no required variant');
}
return {
defaultSalesChannels,
defaultProductOption,
defaultProductVariant,
}
}
async function createProducts({
medusa,
}: {
medusa: Medusa;
}) {
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
const [
{ products: existingProducts },
analysisElements,
analysisPackagesType,
{
defaultSalesChannels,
defaultProductOption,
defaultProductVariant,
}
] = await Promise.all([
medusa.admin.product.list({
category_id: allCategories.map(({ id }) => id),
}),
getAnalysisElements({}),
getAnalysisPackagesType(),
getProductDefaultFields({ medusa }),
])
for (const analysisElement of analysisElements) {
const { analysis_id_original: originalId } = analysisElement;
const isExisting = existingProducts.find(({ metadata }) => metadata?.analysisIdOriginal === originalId);
if (isExisting) {
console.info(`Analysis element '${analysisElement.analysis_name_lab}' already exists`);
continue;
}
const { analysis_name_lab: name } = analysisElement;
if (!name) {
console.error(`Analysis element '${originalId}' has no name`);
continue;
}
const category = allCategories.find(({ metadata }) => metadata?.analysisGroupId === analysisElement.parent_analysis_group_id);
if (!category) {
console.error(`Category not found for analysis element '${name}'`);
continue;
}
await medusa.admin.product.create({
title: name,
handle: `analysis-element-${analysisElement.id}`,
categories: [{ id: category.id }],
options: defaultProductOption.map(({ id, title, values }) => ({
id,
title,
values: values?.map(({ value }) => value) ?? [],
})),
metadata: {
analysisIdOriginal: originalId,
},
is_giftcard: false,
discountable: false,
status: 'published',
sales_channels: defaultSalesChannels.map(({ id }) => ({ id })),
variants: [
{
title: defaultProductVariant.title!,
prices: defaultProductVariant.prices!,
manage_inventory: false,
},
],
type_id: analysisPackagesType.id,
});
}
}
export default async function syncAnalysisGroupsStore() {
const medusa = getAdminSdk();
try {
await createProductCategories({ medusa });
// const categories = await getChildProductCategories({ medusa });
// await deleteProductCategories({ medusa, categories });
// await deleteProducts({ medusa });
// return;
await createProducts({ medusa });
await createMedusaSyncSuccessEntry();
} catch (e) {
await createMedusaSyncFailEntry(JSON.stringify(e));
console.error(e);
throw new Error(
`Failed to sync analyses to Medusa, error: ${JSON.stringify(e)}`,
);
}
}

View File

@@ -0,0 +1,173 @@
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs';
import { createAnalysisGroup, getAnalysisGroups } from '~/lib/services/analysis-group.service';
import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost.types';
import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service';
import { getLastCheckedDate } from '~/lib/services/sync-entries.service';
import { createAnalysisElement } from '~/lib/services/analysis-element.service';
import { createCodes } from '~/lib/services/codes.service';
import { getLatestPublicMessageListItem } from '~/lib/services/medipost.service';
import type { ICode } from '~/lib/types/code';
function toArray<T>(input?: T | T[] | null): T[] {
if (!input) return [];
return Array.isArray(input) ? input : [input];
}
const WRITE_XML_TO_FILE = false as boolean;
export default async function syncAnalysisGroups() {
const baseUrl = process.env.MEDIPOST_URL;
const user = process.env.MEDIPOST_USER;
const password = process.env.MEDIPOST_PASSWORD;
const sender = process.env.MEDIPOST_MESSAGE_SENDER;
if (!baseUrl || !user || !password || !sender) {
throw new Error('Could not access all necessary environment variables');
}
try {
console.info('Getting latest public message id');
const lastCheckedDate = await getLastCheckedDate();
const latestMessage = await getLatestPublicMessageListItem();
if (!latestMessage) {
console.info('No new data received');
await createNoNewDataReceivedEntry();
return;
}
console.info('Getting public message with id: ', latestMessage.messageId);
const { data: publicMessageData } = await axios.get(baseUrl, {
params: {
Action: 'GetPublicMessage',
User: user,
Password: password,
MessageId: latestMessage.messageId,
},
headers: {
Accept: 'application/xml',
},
});
if (WRITE_XML_TO_FILE) {
fs.writeFileSync('public-messages-list-response.xml', publicMessageData);
}
const parser = new XMLParser({ ignoreAttributes: false });
const parsed: IMedipostPublicMessageDataParsed = parser.parse(publicMessageData);
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
throw new Error(
`Failed to get public message (id: ${latestMessage.messageId})`,
);
}
const existingAnalysisGroups = await getAnalysisGroups();
// SAVE PUBLIC MESSAGE DATA
const providers = toArray(parsed?.Saadetis?.Teenused.Teostaja);
const analysisGroups = providers.flatMap((provider) =>
toArray(provider.UuringuGrupp),
);
if (!parsed || !analysisGroups.length) {
console.info('No analysis groups data received');
await createNoDataReceivedEntry();
return;
}
const codes: ICode[] = [];
for (const analysisGroup of analysisGroups) {
const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId);
if (existingAnalysisGroup) {
console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`);
continue;
}
// SAVE ANALYSIS GROUP
const analysisGroupId = await createAnalysisGroup({
id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
});
const analysisGroupCodes = toArray(analysisGroup.Kood);
codes.push(
...analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId,
analysis_element_id: null,
analysis_id: null,
})),
);
const analysisGroupItems = toArray(analysisGroup.Uuring);
for (const item of analysisGroupItems) {
const analysisElement = item.UuringuElement;
const insertedAnalysisElementId = await createAnalysisElement({
analysisElement,
analysisGroupId,
materialGroups: toArray(item.MaterjalideGrupp),
});
if (analysisElement.Kood) {
const analysisElementCodes = toArray(analysisElement.Kood);
codes.push(
...analysisElementCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: insertedAnalysisElementId,
analysis_id: null,
})),
);
}
const analyses = analysisElement.UuringuElement;
if (analyses?.length) {
for (const analysis of analyses) {
const insertedAnalysisId = await createAnalysis(analysis, analysisGroupId);
if (analysis.Kood) {
const analysisCodes = toArray(analysis.Kood);
codes.push(
...analysisCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: null,
analysis_id: insertedAnalysisId,
})),
);
}
}
}
}
}
console.info('Inserting codes');
await createCodes(codes);
console.info('Inserting sync entry');
await createSyncSuccessEntry();
} catch (e) {
await createSyncFailEntry(JSON.stringify(e));
console.error(e);
throw new Error(
`Failed to sync public message data, error: ${JSON.stringify(e)}`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { readPrivateMessageResponse } from "~/lib/services/medipost.service";
export default async function syncAnalysisResults() {
console.info("Syncing analysis results");
let processedMessageIds: string[] = [];
const excludedMessageIds: string[] = [];
while (true) {
console.info("Fetching private messages");
const { messageIdErrored, messageIdProcessed } = await readPrivateMessageResponse({ excludedMessageIds });
if (messageIdProcessed) {
processedMessageIds.push(messageIdProcessed);
}
if (!messageIdErrored) {
console.info("No more messages to process");
break;
}
if (excludedMessageIds.includes(messageIdErrored)) {
console.info(`Message id=${messageIdErrored} has already been processed, stopping`);
break;
}
excludedMessageIds.push(messageIdErrored);
}
console.info(`Processed ${processedMessageIds.length} messages, ids: ${processedMessageIds.join(', ')}`);
}

View File

@@ -1,45 +1,29 @@
import { createClient as createCustomClient } from '@supabase/supabase-js';
import axios from 'axios';
import { config } from 'dotenv';
async function syncData() {
if (process.env.NODE_ENV === 'local') {
config({ path: `.env.${process.env.NODE_ENV}` });
}
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { ISearchLoadResponse } from '~/lib/types/connected-online';
export default async function syncConnectedOnline() {
const isProd = process.env.NODE_ENV === 'production';
const baseUrl = process.env.CONNECTED_ONLINE_URL;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!baseUrl || !supabaseUrl || !supabaseServiceRoleKey) {
if (!baseUrl) {
throw new Error('Could not access all necessary environment variables');
}
const supabase = createCustomClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
});
const supabase = getSupabaseServerAdminClient();
try {
const response = await axios.post(`${baseUrl}/Search_Load`, {
const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, {
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
param: "{'Value':'|et|-1'}", // get all available services in Estonian
});
const responseData: {
Value: any;
Data: any;
ErrorCode: number;
ErrorMessage: string;
} = JSON.parse(response.data.d);
const responseData: ISearchLoadResponse = JSON.parse(response.data.d);
if (responseData?.ErrorCode !== 0) {
throw new Error('Failed to get Connected Online data');
@@ -147,5 +131,3 @@ async function syncData() {
);
}
}
syncData();

View File

@@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
export default function validateApiKey(request: NextRequest) {
const envApiKey = process.env.JOBS_API_TOKEN;
const requestApiKey = request.headers.get('x-jobs-api-key');
if (requestApiKey !== envApiKey) {
throw new Error('Unauthorized');
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import syncAnalysisGroupsStore from "../handler/sync-analysis-groups-store";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncAnalysisGroupsStore();
console.info("Successfully synced analysis groups store");
return NextResponse.json({
message: 'Successfully synced analysis groups store',
}, { status: 200 });
} catch (e) {
console.error("Error syncing analysis groups store", e);
return NextResponse.json({
message: 'Failed to sync analysis groups store',
}, { status: 500 });
}
};

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import syncAnalysisGroups from "../handler/sync-analysis-groups";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncAnalysisGroups();
console.info("Successfully synced analysis groups");
return NextResponse.json({
message: 'Successfully synced analysis groups',
}, { status: 200 });
} catch (e) {
console.error("Error syncing analysis groups", e);
return NextResponse.json({
message: 'Failed to sync analysis groups',
}, { status: 500 });
}
};

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import syncAnalysisResults from "../handler/sync-analysis-results";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncAnalysisResults();
console.info("Successfully synced analysis results");
return NextResponse.json({
message: 'Successfully synced analysis results',
}, { status: 200 });
} catch (e) {
console.error("Error syncing analysis results", e);
return NextResponse.json({
message: 'Failed to sync analysis results',
}, { status: 500 });
}
};

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import syncConnectedOnline from "../handler/sync-connected-online";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncConnectedOnline();
console.info("Successfully synced connected-online");
return NextResponse.json({
message: 'Successfully synced connected-online',
}, { status: 200 });
} catch (e) {
console.error("Error syncing connected-online", e);
return NextResponse.json({
message: 'Failed to sync connected-online',
}, { status: 500 });
}
};

View File

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

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
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";
export async function POST(request: Request) {
const isDev = process.env.NODE_ENV === 'development';
if (!isDev) {
return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
}
const { medusaOrderId } = await request.json();
const medusaOrder = await retrieveOrder(medusaOrderId)
const medreportOrder = await getOrder({ 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 messageXml = await composeOrderTestResponseXML({
person: {
idCode: account.personal_code!,
firstName: account.name ?? '',
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
});
try {
await sendPrivateMessageTestResponse({ messageXml });
} catch (error) {
console.error("Error sending private message test response: ", error);
}
return NextResponse.json({ success: true });
}

View File

@@ -40,7 +40,7 @@ async function VerifyPage(props: Props) {
}
const nextPath = (await props.searchParams).next;
const redirectPath = nextPath ?? pathsConfig.app.home;
const redirectPath = !!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
return (
<MultiFactorChallengeContainer

View File

@@ -1,3 +1,4 @@
import { Fragment } from 'react';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '@/lib/i18n/with-i18n';
@@ -28,7 +29,11 @@ async function AnalysisResultsPage() {
<Trans i18nKey="account:analysisResults.pageTitle" />
</h4>
<p className="text-muted-foreground text-sm">
<Trans i18nKey="account:analysisResults.description" />
{analysisList && analysisList.length > 0 ? (
<Trans i18nKey="account:analysisResults.description" />
) : (
<Trans i18nKey="account:analysisResults.descriptionEmpty" />
)}
</p>
</div>
<Button>
@@ -36,20 +41,24 @@ async function AnalysisResultsPage() {
</Button>
</div>
<div className="flex flex-col gap-2">
{analysisList?.map((analysis, index) => (
<Analysis
key={index}
analysis={{
name: analysis.element.analysis_name || '',
status: analysis.element.norm_status as AnalysisStatus,
unit: analysis.element.unit || '',
value: analysis.element.response_value,
normLowerIncluded: !!analysis.element.norm_lower_included,
normUpperIncluded: !!analysis.element.norm_upper_included,
normLower: analysis.element.norm_lower || 0,
normUpper: analysis.element.norm_upper || 0,
}}
/>
{analysisList?.map((analysis) => (
<Fragment key={analysis.id}>
{analysis.elements.map((element) => (
<Analysis
key={element.id}
analysis={{
name: element.analysis_name || '',
status: element.norm_status as AnalysisStatus,
unit: element.unit || '',
value: element.response_value,
normLowerIncluded: !!element.norm_lower_included,
normUpperIncluded: !!element.norm_upper_included,
normLower: element.norm_lower || 0,
normUpper: element.norm_upper || 0,
}}
/>
))}
</Fragment>
))}
</div>
</PageBody>

View File

@@ -3,12 +3,22 @@ import { z } from "zod";
import { MontonioOrderToken } from "@/app/home/(user)/_components/cart/types";
import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-account";
import { listProductTypes } from "@lib/data/products";
import { placeOrder } from "@lib/data/cart";
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';
const emailSender = process.env.EMAIL_SENDER;
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
const MONTONIO_PAID_STATUS = 'PAID';
/**
* This is needed locally, because Montonio doesn't accept "localhost" redirect/notification URLs
*/
const LOCAL_MONTONIO_REDIRECT_FAKE_ORIGIN = 'webhook.site';
const env = z
.object({
emailSender: z
@@ -62,38 +72,50 @@ const handleOrderToken = async (orderToken: string) => {
const decoded = jwt.verify(orderToken, secretKey, {
algorithms: ['HS256'],
}) as MontonioOrderToken;
if (decoded.paymentStatus !== 'PAID') {
if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) {
return null;
}
try {
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
const [,, cartId] = decoded.merchantReferenceDisplay.split(':');
if (!cartId) {
throw new Error("Cart ID not found");
}
const cart = await retrieveCart(cartId);
if (!cart) {
throw new Error("Cart not found");
}
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: true });
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
await createOrder({ medusaOrder, orderedAnalysisElements });
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
const order = await placeOrder(cartId, { revalidateCacheTags: true });
const analysisPackageOrderItem = order.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
return {
email: order.email,
medusaOrderId: medusaOrder.id,
email: medusaOrder.email,
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
analysisPackageName: analysisPackageOrderItem?.title ?? '',
orderedAnalysisElements,
};
} catch (error) {
console.error("Failed to place order", error);
throw new Error(`Failed to place order, message=${error}`);
}
}
export async function GET(request: Request) {
const { language } = await createI18nServerInstance();
const baseUrl = new URL(env.siteUrl.replace("localhost", "webhook.site"));
const baseUrl = new URL(env.siteUrl.replace("localhost", LOCAL_MONTONIO_REDIRECT_FAKE_ORIGIN));
try {
const orderToken = new URL(request.url).searchParams.get('order-token');
if (!orderToken) {
throw new Error("Order token is missing");
}
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error("Account not found in context");
@@ -104,13 +126,19 @@ export async function GET(request: Request) {
throw new Error("Order result is missing");
}
const { email, partnerLocationName, analysisPackageName } = orderResult;
const { medusaOrderId, email, partnerLocationName, analysisPackageName, orderedAnalysisElements } = orderResult;
const personName = account.name;
if (email && analysisPackageName) {
await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language });
try {
await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language });
} catch (error) {
console.error("Failed to send email", error);
}
} else {
// @TODO send email for separate analyses
console.error("Missing email or analysisPackageName", orderResult);
}
sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements })
return Response.redirect(new URL('/home/order', baseUrl))
} catch (error) {
console.error("Failed to place order", error);

View File

@@ -0,0 +1 @@
export { GET } from "./[montonioId]/route";

View File

@@ -5,7 +5,7 @@ import { notFound } from 'next/navigation';
import { retrieveCart } from '@lib/data/cart';
import Cart from '../../_components/cart';
import { listProductTypes } from '@lib/data';
import { listProductTypes } from '@lib/data/products';
import CartTimer from '../../_components/cart/cart-timer';
import { Trans } from '@kit/ui/trans';

View File

@@ -20,7 +20,7 @@ export const generateMetadata = async () => {
};
async function OrderAnalysisPackagePage() {
const { analysisPackages, countryCode } = await loadAnalysisPackages();
const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages();
return (
<PageBody>
@@ -30,6 +30,7 @@ async function OrderAnalysisPackagePage() {
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
analysisPackageElements={analysisPackageElements}
triggerElement={
<Button variant="secondary" className="gap-2">
<Trans i18nKey={'marketing:comparePackages'} />

View File

@@ -3,6 +3,8 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody } from '@kit/ui/page';
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';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
@@ -13,6 +15,8 @@ export const generateMetadata = async () => {
};
async function OrderAnalysisPage() {
const { analyses, countryCode } = await loadAnalyses();
return (
<>
<HomeLayoutPageHeader
@@ -20,6 +24,7 @@ async function OrderAnalysisPage() {
description={<Trans i18nKey={'order-analysis:description'} />}
/>
<PageBody>
<OrderAnalysesCards analyses={analyses} countryCode={countryCode} />
</PageBody>
</>
);

View File

@@ -2,7 +2,8 @@ import { redirect } from 'next/navigation';
import { listOrders } from '~/medusa/lib/data/orders';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { listProductTypes, retrieveCustomer } from '@lib/data';
import { listProductTypes } from '@lib/data/products';
import { retrieveCustomer } from '@lib/data/customer';
import { PageBody } from '@kit/ui/makerkit/page';
import pathsConfig from '~/config/paths.config';
import { Trans } from '@kit/ui/trans';

View File

@@ -3,7 +3,6 @@
import { toast } from 'sonner';
import { useForm } from "react-hook-form";
import { z } from "zod";
import { updateLineItem } from "@lib/data/cart"
import { StoreCart, StoreCartLineItem } from "@medusajs/types"
import { Form } from "@kit/ui/form";
import { Trans } from '@kit/ui/trans';
@@ -18,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { updateCartPartnerLocation } from '../../_lib/server/update-cart-partner-location';
const AnalysisLocationSchema = z.object({
locationId: z.string().min(1),
@@ -40,16 +40,12 @@ export default function AnalysisLocation({ cart, analysisPackages }: { cart: Sto
});
const onSubmit = async ({ locationId }: z.infer<typeof AnalysisLocationSchema>) => {
const promise = Promise.all(analysisPackages.map(async ({ id, quantity }) => {
await updateLineItem({
lineId: id,
quantity,
metadata: {
partner_location_name: MOCK_LOCATIONS.find((location) => location.id === locationId)?.name ?? '',
partner_location_id: locationId,
},
});
}));
const promise = updateCartPartnerLocation({
cartId: cart.id,
lineIds: analysisPackages.map(({ id }) => id),
partnerLocationId: locationId,
partnerLocationName: MOCK_LOCATIONS.find((location) => location.id === locationId)?.name ?? '',
});
toast.promise(promise, {
success: t(`cart:items.analysisLocation.success`),

View File

@@ -23,79 +23,7 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import { PackageHeader } from '@/components/package-header';
import { InfoTooltip } from '@/components/ui/info-tooltip';
import { StoreProduct } from '@medusajs/types';
const dummyCards = [
{
titleKey: 'product:standard.label',
price: 40,
nrOfAnalyses: 4,
tagColor: 'bg-cyan',
},
{
titleKey: 'product:standardPlus.label',
price: 85,
nrOfAnalyses: 10,
tagColor: 'bg-warning',
},
{
titleKey: 'product:premium.label',
price: 140,
nrOfAnalyses: '12+',
tagColor: 'bg-purple',
},
];
const dummyRows = [
{
analysisNameKey: 'product:clinicalBloodDraw.label',
tooltipContentKey: 'product:clinicalBloodDraw.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:crp.label',
tooltipContentKey: 'product:crp.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:ferritin.label',
tooltipContentKey: 'product:ferritin.description',
includedInStandard: 0,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:vitaminD.label',
tooltipContentKey: 'product:vitaminD.description',
includedInStandard: 0,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:glucose.label',
tooltipContentKey: 'product:glucose.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:alat.label',
tooltipContentKey: 'product:alat.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
{
analysisNameKey: 'product:ast.label',
tooltipContentKey: 'product:ast.description',
includedInStandard: 1,
includedInStandardPlus: 1,
includedInPremium: 1,
},
];
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
const CheckWithBackground = () => {
return (
@@ -105,14 +33,46 @@ const CheckWithBackground = () => {
);
};
const PackageTableHead = async ({ product, nrOfAnalyses }: { product: StoreProduct, nrOfAnalyses: number }) => {
const { t, language } = await createI18nServerInstance();
const variant = product.variants?.[0];
const titleKey = product.title;
const price = variant?.calculated_price?.calculated_amount ?? 0;
return (
<TableHead className="py-2">
<PackageHeader
title={t(titleKey)}
tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language}
price={price}
/>
</TableHead>
)
}
const ComparePackagesModal = async ({
analysisPackages,
analysisPackageElements,
triggerElement,
}: {
analysisPackages: StoreProduct[];
analysisPackageElements: StoreProduct[];
triggerElement: JSX.Element;
}) => {
const { t, language } = await createI18nServerInstance();
const { t } = await createI18nServerInstance();
const standardPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard')!;
const standardPlusPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard-plus')!;
const premiumPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'premium')!;
if (!standardPackage || !standardPlusPackage || !premiumPackage) {
return null;
}
const standardPackageAnalyses = getAnalysisElementMedusaProductIds([standardPackage]);
const standardPlusPackageAnalyses = getAnalysisElementMedusaProductIds([standardPlusPackage]);
const premiumPackageAnalyses = getAnalysisElementMedusaProductIds([premiumPackage]);
return (
<Dialog>
@@ -138,64 +98,50 @@ const ComparePackagesModal = async ({
<p className="text-muted-foreground mx-auto w-3/5 text-sm">
{t('product:healthPackageComparison.description')}
</p>
<div className="rounded-md border">
<div className="rounded-md border max-h-[80vh] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
{analysisPackages.map(
(product) => {
const variant = product.variants?.[0];
const titleKey = product.title;
const price = variant?.calculated_price?.calculated_amount ?? 0;
return (
<TableHead key={titleKey} className="py-2">
<PackageHeader
title={t(titleKey)}
tagColor='bg-cyan'
analysesNr={t('product:nrOfAnalyses', {
nr: product?.metadata?.nrOfAnalyses ?? 0,
})}
language={language}
price={price}
/>
</TableHead>
)
})}
<PackageTableHead product={standardPackage} nrOfAnalyses={standardPackageAnalyses.length} />
<PackageTableHead product={standardPlusPackage} nrOfAnalyses={standardPlusPackageAnalyses.length} />
<PackageTableHead product={premiumPackage} nrOfAnalyses={premiumPackageAnalyses.length} />
</TableRow>
</TableHeader>
<TableBody>
{dummyRows.map(
{analysisPackageElements.map(
(
{
analysisNameKey,
tooltipContentKey,
includedInStandard,
includedInStandardPlus,
includedInPremium,
title,
id,
description,
},
index,
) => (
<TableRow key={index}>
<TableCell className="py-6">
{t(analysisNameKey)}{' '}
<InfoTooltip
content={t(tooltipContentKey)}
icon={<QuestionMarkCircledIcon />}
/>
</TableCell>
<TableCell align="center" className="py-6">
{!!includedInStandard && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{!!includedInStandardPlus && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{!!includedInPremium && <CheckWithBackground />}
</TableCell>
</TableRow>
),
)}
) => {
if (!title) {
return null;
}
const includedInStandard = standardPackageAnalyses.includes(id);
const includedInStandardPlus = standardPlusPackageAnalyses.includes(id);
const includedInPremium = premiumPackageAnalyses.includes(id);
return (
<TableRow key={index}>
<TableCell className="py-6">
{title}{' '}
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
</TableCell>
<TableCell align="center" className="py-6">
{includedInStandard && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{(includedInStandard || includedInStandardPlus) && <CheckWithBackground />}
</TableCell>
<TableCell align="center" className="py-6">
{(includedInStandard || includedInStandardPlus || includedInPremium) && <CheckWithBackground />}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>

View File

@@ -0,0 +1,80 @@
"use client";
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Card,
CardHeader,
CardFooter,
} 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';
export default function OrderAnalysesCards({
analyses,
countryCode,
}: {
analyses: StoreProduct[];
countryCode: string;
}) {
const router = useRouter();
const [isAddingToCart, setIsAddingToCart] = useState(false);
const handleSelect = async (selectedVariant: StoreProductVariant) => {
if (!selectedVariant?.id || isAddingToCart) return null
setIsAddingToCart(true);
try {
await handleAddToCart({
selectedVariant,
countryCode,
});
setIsAddingToCart(false);
router.push('/home/cart');
} catch (e) {
setIsAddingToCart(false);
console.error(e);
}
}
return (
<div className="grid grid-cols-3 gap-6 mt-4">
{analyses.map(({
title,
variants
}) => (
<Card
key={title}
variant="gradient-success"
className="flex flex-col justify-between"
>
<CardHeader className="items-end-safe">
<div className='flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
<Button
size="icon"
variant="outline"
className="px-2 text-black"
onClick={() => handleSelect(variants![0]!)}
>
{isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button>
</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>
{title}
</h5>
</CardFooter>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { cache } from 'react';
import { listProductTypes } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
import { getProductCategories } from '@lib/data/categories';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(),
);
return countryCodes ?? [];
}
export const loadCountryCodes = cache(countryCodesLoader);
async function productCategoriesLoader() {
const productCategories = await getProductCategories({ fields: "*products, *products.variants" });
return productCategories.product_categories ?? [];
}
export const loadProductCategories = cache(productCategoriesLoader);
async function productTypesLoader() {
const { productTypes } = await listProductTypes();
return productTypes ?? [];
}
export const loadProductTypes = cache(productTypesLoader);
async function analysesLoader() {
const [countryCodes, productCategories] = await Promise.all([
loadCountryCodes(),
loadProductCategories(),
]);
const countryCode = countryCodes[0]!;
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis');
return {
analyses: category?.products ?? [],
countryCode,
}
}
export const loadAnalyses = cache(analysesLoader);

View File

@@ -1,6 +1,9 @@
import { cache } from 'react';
import { listProductTypes, listProducts, listRegions } from "@lib/data";
import { listProductTypes, listProducts } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import type { StoreProduct } from '@medusajs/types';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -20,15 +23,32 @@ async function analysisPackagesLoader() {
const [countryCodes, productTypes] = await Promise.all([loadCountryCodes(), loadProductTypes()]);
const countryCode = countryCodes[0]!;
let analysisPackages: StoreProduct[] = [];
let analysisPackageElements: StoreProduct[] = [];
const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
if (!productType) {
return { analysisPackages: [], countryCode };
return { analysisPackageElements, analysisPackages, countryCode };
}
const { response } = await listProducts({
const analysisPackagesResponse = await listProducts({
countryCode,
queryParams: { limit: 100, "type_id[0]": productType.id },
});
return { analysisPackages: response.products, countryCode };
analysisPackages = analysisPackagesResponse.response.products;
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackages);
if (analysisElementMedusaProductIds.length > 0) {
const { response: { products } } = await listProducts({
countryCode,
queryParams: {
id: analysisElementMedusaProductIds,
limit: 100,
},
});
analysisPackageElements = products;
}
return { analysisPackageElements, analysisPackages, countryCode };
}
export const loadAnalysisPackages = cache(analysisPackagesLoader);

View File

@@ -0,0 +1,38 @@
"use server";
import { retrieveCart, updateCart, updateLineItem } from "@lib/data/cart";
export const updateCartPartnerLocation = async ({
cartId,
lineIds,
partnerLocationId,
partnerLocationName,
}: {
cartId: string;
lineIds: string[];
partnerLocationId: string;
partnerLocationName: string;
}) => {
const cart = await retrieveCart(cartId);
if (!cart) {
throw new Error("Cart not found");
}
for (const lineItemId of lineIds) {
await updateLineItem({
lineId: lineItemId,
quantity: 1,
metadata: {
partner_location_name: partnerLocationName,
partner_location_id: partnerLocationId,
},
});
}
await updateCart({
id: cartId,
metadata: {
partner_location_name: partnerLocationName,
partner_location_id: partnerLocationId,
},
});
}

View File

@@ -24,7 +24,7 @@ export const generateMetadata = async () => {
};
async function SelectPackagePage() {
const { analysisPackages, countryCode } = await loadAnalysisPackages();
const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages();
return (
<div className="container mx-auto my-24 flex flex-col items-center space-y-12">
@@ -35,6 +35,7 @@ async function SelectPackagePage() {
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
analysisPackageElements={analysisPackageElements}
triggerElement={
<Button variant="secondary" className="gap-2">
<Trans i18nKey={'marketing:comparePackages'} />

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from 'react';
import { use, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
@@ -19,11 +19,11 @@ import { handleAddToCart } from '@/lib/services/medusaCart.service';
import { PackageHeader } from './package-header';
import { ButtonTooltip } from './ui/button-tooltip';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
export interface IAnalysisPackage {
titleKey: string;
price: number;
nrOfAnalyses: number | string;
tagColor: string;
descriptionKey: string;
}
@@ -52,7 +52,8 @@ export default function SelectAnalysisPackage({
}
const titleKey = analysisPackage.title;
const nrOfAnalyses = analysisPackage?.metadata?.nrOfAnalyses ?? 0;
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds([analysisPackage]);
const nrOfAnalyses = analysisElementMedusaProductIds.length;
const description = analysisPackage.description ?? '';
const subtitle = analysisPackage.subtitle ?? '';
const variant = analysisPackage.variants?.[0];

View File

@@ -1,292 +0,0 @@
import { createClient as createCustomClient } from '@supabase/supabase-js';
import axios from 'axios';
import { format } from 'date-fns';
import { config } from 'dotenv';
import { XMLParser } from 'fast-xml-parser';
function getLatestMessage(messages) {
if (!messages?.length) {
return null;
}
return messages.reduce((prev, current) =>
Number(prev.messageId) > Number(current.messageId) ? prev : current,
);
}
export function toArray<T>(input?: T | T[] | null): T[] {
if (!input) return [];
return Array.isArray(input) ? input : [input];
}
async function syncData() {
if (process.env.NODE_ENV === 'local') {
config({ path: `.env.${process.env.NODE_ENV}` });
}
const baseUrl = process.env.MEDIPOST_URL;
const user = process.env.MEDIPOST_USER;
const password = process.env.MEDIPOST_PASSWORD;
const sender = process.env.MEDIPOST_MESSAGE_SENDER;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (
!baseUrl ||
!supabaseUrl ||
!supabaseServiceRoleKey ||
!user ||
!password ||
!sender
) {
throw new Error('Could not access all necessary environment variables');
}
const supabase = createCustomClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
});
try {
// GET LATEST PUBLIC MESSAGE ID
const { data: lastChecked } = await supabase
.schema('audit')
.from('sync_entries')
.select('created_at')
.eq('status', 'SUCCESS')
.order('created_at')
.limit(1);
const lastCheckedDate = lastChecked?.length
? {
LastChecked: format(lastChecked[0].created_at, 'yyyy-MM-dd HH:mm:ss'),
}
: {};
const { data, status } = await axios.get(baseUrl, {
params: {
Action: 'GetPublicMessageList',
User: user,
Password: password,
Sender: sender,
...lastCheckedDate,
MessageType: 'Teenus',
},
});
if (!data || status !== 200) {
console.error("Failed to get public message list, status: ", status, data);
throw new Error('Failed to get public message list');
}
if (data.code && data.code !== 0) {
throw new Error('Failed to get public message list');
}
if (!data.messages?.length) {
return supabase.schema('audit').from('sync_entries').insert({
operation: 'ANALYSES_SYNC',
comment: 'No new data received',
status: 'SUCCESS',
changed_by_role: 'service_role',
});
}
const latestMessage = getLatestMessage(data?.messages);
// GET PUBLIC MESSAGE WITH GIVEN ID
const { data: publicMessageData } = await axios.get(baseUrl, {
params: {
Action: 'GetPublicMessage',
User: user,
Password: password,
MessageId: latestMessage.messageId,
},
headers: {
Accept: 'application/xml',
},
});
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(publicMessageData);
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
throw new Error(
`Failed to get public message (id: ${latestMessage.messageId})`,
);
}
// SAVE PUBLIC MESSAGE DATA
const providers = toArray(parsed?.Saadetis?.Teenused.Teostaja);
const analysisGroups = providers.flatMap((provider) =>
toArray(provider.UuringuGrupp),
);
if (!parsed || !analysisGroups.length) {
return supabase.schema('audit').from('sync_entries').insert({
operation: 'ANALYSES_SYNC',
comment: 'No data received',
status: 'FAIL',
changed_by_role: 'service_role',
});
}
const codes: any[] = [];
for (const analysisGroup of analysisGroups) {
// SAVE ANALYSIS GROUP
const { data: insertedAnalysisGroup, error } = await supabase
.schema('medreport')
.from('analysis_groups')
.upsert(
{
original_id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
},
{ onConflict: 'original_id', ignoreDuplicates: false },
)
.select('id');
if (error || !insertedAnalysisGroup[0]?.id) {
throw new Error(
`Failed to insert analysis group (id: ${analysisGroup.UuringuGruppId}), error: ${error?.message}`,
);
}
const analysisGroupId = insertedAnalysisGroup[0].id;
const analysisGroupCodes = toArray(analysisGroup.Kood);
codes.push(
...analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId,
analysis_element_id: null,
analysis_id: null,
})),
);
const analysisGroupItems = toArray(analysisGroup.Uuring);
for (const item of analysisGroupItems) {
const analysisElement = item.UuringuElement;
const { data: insertedAnalysisElement, error } = await supabase
.schema('medreport')
.from('analysis_elements')
.upsert(
{
analysis_id_oid: analysisElement.UuringIdOID,
analysis_id_original: analysisElement.UuringId,
tehik_short_loinc: analysisElement.TLyhend,
tehik_loinc_name: analysisElement.KNimetus,
analysis_name_lab: analysisElement.UuringNimi,
order: analysisElement.Jarjekord,
parent_analysis_group_id: analysisGroupId,
material_groups: toArray(item.MaterjalideGrupp),
},
{ onConflict: 'analysis_id_original', ignoreDuplicates: false },
)
.select('id');
if (error || !insertedAnalysisElement[0]?.id) {
throw new Error(
`Failed to insert analysis element (id: ${analysisElement.UuringId}), error: ${error?.message}`,
);
}
const insertedAnalysisElementId = insertedAnalysisElement[0].id;
if (analysisElement.Kood) {
const analysisElementCodes = toArray(analysisElement.Kood);
codes.push(
...analysisElementCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: insertedAnalysisElementId,
analysis_id: null,
})),
);
}
const analyses = analysisElement.UuringuElement;
if (analyses?.length) {
for (const analysis of analyses) {
const { data: insertedAnalysis, error } = await supabase
.schema('medreport')
.from('analyses')
.upsert(
{
analysis_id_oid: analysis.UuringIdOID,
analysis_id_original: analysis.UuringId,
tehik_short_loinc: analysis.TLyhend,
tehik_loinc_name: analysis.KNimetus,
analysis_name_lab: analysis.UuringNimi,
order: analysis.Jarjekord,
parent_analysis_element_id: insertedAnalysisElementId,
},
{ onConflict: 'analysis_id_original', ignoreDuplicates: false },
)
.select('id');
if (error || !insertedAnalysis[0]?.id) {
throw new Error(
`Failed to insert analysis (id: ${analysis.UuringId}) error: ${error?.message}`,
);
}
const insertedAnalysisId = insertedAnalysis[0].id;
if (analysis.Kood) {
const analysisCodes = toArray(analysis.Kood);
codes.push(
...analysisCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: null,
analysis_id: insertedAnalysisId,
})),
);
}
}
}
}
}
await supabase.schema('medreport').from('codes').upsert(codes);
await supabase.schema('audit').from('sync_entries').insert({
operation: 'ANALYSES_SYNC',
status: 'SUCCESS',
changed_by_role: 'service_role',
});
} catch (e) {
await supabase
.schema('audit')
.from('sync_entries')
.insert({
operation: 'ANALYSES_SYNC',
status: 'FAIL',
comment: JSON.stringify(e),
changed_by_role: 'service_role',
});
throw new Error(
`Failed to sync public message data, error: ${JSON.stringify(e)}`,
);
}
}
syncData();

View File

@@ -36,6 +36,7 @@ export const defaultI18nNamespaces = [
'product',
'booking',
'order-analysis-package',
'order-analysis',
'cart',
'orders',
];

View File

@@ -0,0 +1,41 @@
import { getSupabaseServerClient } from "@kit/supabase/server-client";
import { getSupabaseServerAdminClient } from "@kit/supabase/server-admin-client";
import type { Tables } from "@/packages/supabase/src/database.types";
type Account = Tables<{ schema: 'medreport' }, 'accounts'>;
type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>;
export type AccountWithMemberships = Account & { memberships: Membership[] }
export async function getAccount(id: string): Promise<AccountWithMemberships> {
const { data } = await getSupabaseServerClient()
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
.eq('id', id)
.single()
.throwOnError();
return data as unknown as AccountWithMemberships;
}
export async function getAccountAdmin({
primaryOwnerUserId,
}: {
primaryOwnerUserId: string;
}): Promise<AccountWithMemberships> {
const query = getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
if (primaryOwnerUserId) {
query.eq('primary_owner_user_id', primaryOwnerUserId);
} else {
throw new Error('primaryOwnerUserId is required');
}
const { data } = await query.single().throwOnError();
return data as unknown as AccountWithMemberships;
}

View File

@@ -0,0 +1,116 @@
import type { Tables } from '@/packages/supabase/src/database.types';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { IUuringElement } from "./medipost.types";
type AnalysesWithGroupsAndElements = ({
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
} & Tables<{ schema: 'medreport' }, 'analyses'>)[];
export const createAnalysis = async (
analysis: IUuringElement,
insertedAnalysisElementId: number,
) => {
const { data: insertedAnalysis, error } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('analyses')
.upsert(
{
analysis_id_oid: analysis.UuringIdOID,
analysis_id_original: analysis.UuringId,
tehik_short_loinc: analysis.TLyhend,
tehik_loinc_name: analysis.KNimetus,
analysis_name_lab: analysis.UuringNimi,
order: analysis.Jarjekord,
parent_analysis_element_id: insertedAnalysisElementId,
},
{ onConflict: 'analysis_id_original', ignoreDuplicates: false },
)
.select('id');
const insertedAnalysisId = insertedAnalysis?.[0]?.id as number;
if (error || !insertedAnalysisId) {
throw new Error(
`Failed to insert analysis (id: ${analysis.UuringId}) error: ${error?.message}`,
);
}
return insertedAnalysisId;
}
const createSyncEntry = async ({
operation,
status,
comment,
}: {
operation: 'ANALYSES_SYNC' | 'ANALYSIS_GROUPS_SYNC' | 'ANALYSES_MEDUSA_SYNC';
status: 'SUCCESS' | 'FAIL';
comment?: string;
}) => {
await getSupabaseServerAdminClient()
.schema('audit').from('sync_entries')
.insert({
operation,
status,
changed_by_role: 'service_role',
comment,
});
}
export const createNoNewDataReceivedEntry = async () => {
await createSyncEntry({
operation: 'ANALYSES_SYNC',
status: 'SUCCESS',
comment: 'No new data received',
});
}
export const createNoDataReceivedEntry = async () => {
await createSyncEntry({
operation: 'ANALYSES_SYNC',
status: 'SUCCESS',
comment: 'No data received',
});
}
export const createSyncFailEntry = async (error: string) => {
await createSyncEntry({
operation: 'ANALYSES_SYNC',
status: 'FAIL',
comment: error,
});
}
export const createSyncSuccessEntry = async () => {
await createSyncEntry({
operation: 'ANALYSES_SYNC',
status: 'SUCCESS',
});
}
export const createMedusaSyncFailEntry = async (error: string) => {
await createSyncEntry({
operation: 'ANALYSES_MEDUSA_SYNC',
status: 'FAIL',
comment: error,
});
}
export const createMedusaSyncSuccessEntry = async () => {
await createSyncEntry({
operation: 'ANALYSES_MEDUSA_SYNC',
status: 'SUCCESS',
});
}
export async function getAnalyses({ ids }: { ids: number[] }): Promise<AnalysesWithGroupsAndElements> {
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`)
.in('id', ids);
return data as unknown as AnalysesWithGroupsAndElements;
}

View File

@@ -0,0 +1,93 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Json, Tables } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { IMaterialGroup, IUuringElement } from './medipost.types';
export type AnalysisElement = Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
export async function getAnalysisElements({
originalIds,
}: {
originalIds?: string[];
}): Promise<AnalysisElement[]> {
const query = getSupabaseServerClient()
.schema('medreport')
.from('analysis_elements')
.select(`*, analysis_groups(*)`)
.order('order', { ascending: true });
if (Array.isArray(originalIds)) {
query.in('analysis_id_original', [...new Set(originalIds)]);
}
const { data: analysisElements, error } = await query;
if (error) {
throw new Error(`Failed to get analysis elements: ${error.message}`);
}
return analysisElements ?? [];
}
export async function getAnalysisElementsAdmin({
ids,
}: {
ids?: number[];
} = {}): Promise<AnalysisElement[]> {
const query = getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_elements')
.select(`*, analysis_groups(*)`)
.order('order', { ascending: true });
if (Array.isArray(ids)) {
query.in('id', ids);
}
const { data: analysisElements, error } = await query;
if (error) {
throw new Error(`Failed to get analysis elements: ${error.message}`);
}
return analysisElements ?? [];
}
export async function createAnalysisElement({
analysisElement,
analysisGroupId,
materialGroups,
}: {
analysisElement: IUuringElement;
analysisGroupId: number;
materialGroups: IMaterialGroup[];
}) {
const { data: insertedAnalysisElement, error } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_elements')
.upsert(
{
analysis_id_oid: analysisElement.UuringIdOID,
analysis_id_original: analysisElement.UuringId,
tehik_short_loinc: analysisElement.TLyhend,
tehik_loinc_name: analysisElement.KNimetus,
analysis_name_lab: analysisElement.UuringNimi,
order: analysisElement.Jarjekord,
parent_analysis_group_id: analysisGroupId,
material_groups: materialGroups as unknown as Json[],
},
{ onConflict: 'analysis_id_original', ignoreDuplicates: false },
)
.select('id');
const id = insertedAnalysisElement?.[0]?.id;
if (error || !id) {
throw new Error(
`Failed to insert analysis element (id: ${analysisElement.UuringId}), error: ${error?.message}`,
);
}
return id;
}

View File

@@ -0,0 +1,40 @@
import { getSupabaseServerAdminClient } from "@kit/supabase/server-admin-client";
export const createAnalysisGroup = async (
analysisGroup: {
id: string;
name: string;
order: number;
}
) => {
const { data: insertedAnalysisGroup, error } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_groups')
.upsert(
{
original_id: analysisGroup.id,
name: analysisGroup.name,
order: analysisGroup.order,
},
{ onConflict: 'original_id', ignoreDuplicates: false },
)
.select('id');
const analysisGroupId = insertedAnalysisGroup?.[0]?.id as number;
if (error || !analysisGroupId) {
throw new Error(
`Failed to insert analysis group (id: ${analysisGroup.id}), error: ${error?.message}`,
);
}
return analysisGroupId;
}
export const getAnalysisGroups = async () => {
const { data: analysisGroups } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_groups')
.select('*');
return analysisGroups;
}

View File

@@ -0,0 +1,8 @@
import { getSupabaseServerAdminClient } from "@kit/supabase/server-admin-client";
import type { ICode } from "~/lib/types/code";
export const createCodes = async (codes: ICode[]) => {
await getSupabaseServerAdminClient()
.schema('medreport').from('codes')
.upsert(codes);
}

View File

@@ -10,7 +10,6 @@ import {
getClientInstitution,
getClientPerson,
getConfidentiality,
getOrderEnteredByPerson,
getPais,
getPatient,
getProviderInstitution,
@@ -20,6 +19,7 @@ import { SyncStatus } from '@/lib/types/audit';
import {
AnalysisOrderStatus,
GetMessageListResponse,
IMedipostResponseXMLBase,
MaterjalideGrupp,
MedipostAction,
MedipostOrderResponse,
@@ -34,10 +34,46 @@ import { XMLParser } from 'fast-xml-parser';
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 } from './order.service';
import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { getAnalyses } from './analyses.service';
import { getAccountAdmin } from './account.service';
import { StoreOrder } from '@medusajs/types';
import { listProducts } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!;
const PASSWORD = process.env.MEDIPOST_PASSWORD!;
const RECIPIENT = process.env.MEDIPOST_RECIPIENT!;
const ANALYSIS_ELEMENT_HANDLE_PREFIX = 'analysis-element-';
const ANALYSIS_PACKAGE_HANDLE_PREFIX = 'analysis-package-';
function parseXML(xml: string) {
const parser = new XMLParser({ ignoreAttributes: false });
return parser.parse(xml);
}
export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) {
const parsed: IMedipostResponseXMLBase = parseXML(response);
const code = parsed.ANSWER?.CODE;
if (canHaveEmptyCode) {
if (code && code !== 0) {
console.error("Bad response", response);
throw new Error(`Medipost response is invalid`);
}
return;
}
if (typeof code !== 'number' || (code !== 0 && !canHaveEmptyCode)) {
console.error("Bad response", response);
throw new Error(`Medipost response is invalid`);
}
}
export async function getMessages() {
try {
@@ -70,7 +106,7 @@ export async function getLatestPublicMessageListItem() {
throw new Error('Failed to get public message list');
}
return getLatestMessage(data?.messages);
return getLatestMessage({ messages: data?.messages });
}
export async function getPublicMessage(messageId: string) {
@@ -85,22 +121,16 @@ export async function getPublicMessage(messageId: string) {
Accept: 'application/xml',
},
});
const parser = new XMLParser({ ignoreAttributes: false });
const parsed: MedipostPublicMessageResponse = parser.parse(data);
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
throw new Error(`Failed to get public message (id: ${messageId})`);
}
return parsed;
await validateMedipostResponse(data);
return parseXML(data) as MedipostPublicMessageResponse;
}
export async function sendPrivateMessage(messageXml: string, receiver: string) {
export async function sendPrivateMessage(messageXml: string) {
const body = new FormData();
body.append('Action', MedipostAction.SendPrivateMessage);
body.append('User', USER);
body.append('Password', PASSWORD);
body.append('Receiver', receiver);
body.append('Receiver', RECIPIENT);
body.append('MessageType', 'Tellimus');
body.append(
'Message',
@@ -111,12 +141,14 @@ export async function sendPrivateMessage(messageXml: string, receiver: string) {
const { data } = await axios.post(BASE_URL, body);
if (data.code && data.code !== 0) {
throw new Error(`Failed to send private message`);
}
await validateMedipostResponse(data);
}
export async function getLatestPrivateMessageListItem() {
export async function getLatestPrivateMessageListItem({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}) {
const { data } = await axios.get<GetMessageListResponse>(BASE_URL, {
params: {
Action: MedipostAction.GetPrivateMessageList,
@@ -129,7 +161,7 @@ export async function getLatestPrivateMessageListItem() {
throw new Error('Failed to get private message list');
}
return getLatestMessage(data?.messages);
return getLatestMessage({ messages: data?.messages, excludedMessageIds });
}
export async function getPrivateMessage(messageId: string) {
@@ -145,14 +177,9 @@ export async function getPrivateMessage(messageId: string) {
},
});
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(data);
await validateMedipostResponse(data, { canHaveEmptyCode: true });
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
throw new Error(`Failed to get private message (id: ${messageId})`);
}
return parsed;
return parseXML(data) as MedipostOrderResponse;
}
export async function deletePrivateMessage(messageId: string) {
@@ -170,51 +197,64 @@ export async function deletePrivateMessage(messageId: string) {
}
}
export async function readPrivateMessageResponse() {
export async function readPrivateMessageResponse({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}) {
let messageIdErrored: string | null = null;
let messageIdProcessed: string | null = null;
try {
const privateMessage = await getLatestPrivateMessageListItem();
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
if (!privateMessage) {
return null;
throw new Error(`No private message found`);
}
messageIdErrored = privateMessage.messageId;
if (!messageIdErrored) {
throw new Error(`No message id found`);
}
const privateMessageContent = await getPrivateMessage(
privateMessage.messageId,
);
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
const status = await syncPrivateMessage(privateMessageContent);
if (!messageResponse) {
throw new Error(`Private message response has no results yet`);
}
console.info(`Private message content: ${JSON.stringify(privateMessageContent)}`);
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
try {
order = await getOrder({ medusaOrderId: messageResponse.ValisTellimuseId });
} catch (e) {
await deletePrivateMessage(privateMessage.messageId);
throw new Error(`Order not found by Medipost message ValisTellimuseId=${messageResponse.ValisTellimuseId}`);
}
const status = await syncPrivateMessage({ messageResponse, order });
if (status === 'COMPLETED') {
await deletePrivateMessage(privateMessage.messageId);
messageIdProcessed = privateMessage.messageId;
}
} catch (e) {
console.error(e);
console.warn(`Failed to process private message id=${messageIdErrored}, message=${(e as Error).message}`);
}
return { messageIdErrored, messageIdProcessed };
}
async function saveAnalysisGroup(
analysisGroup: UuringuGrupp,
supabase: SupabaseClient,
) {
const { data: insertedAnalysisGroup, error } = await supabase
.schema('medreport')
.from('analysis_groups')
.upsert(
{
original_id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
},
{ onConflict: 'original_id', ignoreDuplicates: false },
)
.select('id');
if (error || !insertedAnalysisGroup[0]?.id) {
throw new Error(
`Failed to insert analysis group (id: ${analysisGroup.UuringuGruppId}), error: ${error?.message}`,
);
}
const analysisGroupId = insertedAnalysisGroup[0].id;
const analysisGroupId = await createAnalysisGroup({
id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
});
const analysisGroupCodes = toArray(analysisGroup.Kood);
const codes: Partial<Tables<{ schema: 'medreport' }, 'codes'>>[] =
@@ -378,60 +418,35 @@ export async function syncPublicMessage(
}
}
// TODO use actual parameters
export async function composeOrderXML(
/* chosenAnalysisElements?: number[],
chosenAnalyses?: number[], */
comment?: string,
) {
const supabase = createCustomClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
},
);
// TODO remove dummy when actual implemetation is present
const orderedElements = [1, 75];
const orderedAnalyses = [10, 11, 100];
const createdAnalysisOrder = {
id: 4,
user_id: 'currentUser.user?.id',
analysis_element_ids: orderedElements,
analysis_ids: orderedAnalyses,
status: AnalysisOrderStatus[1],
created_at: new Date(),
export async function composeOrderXML({
person,
orderedAnalysisElementsIds,
orderedAnalysesIds,
orderId,
orderCreatedAt,
comment,
}: {
person: {
idCode: string;
firstName: string;
lastName: string;
phone: string;
};
orderedAnalysisElementsIds: number[];
orderedAnalysesIds: number[];
orderId: string;
orderCreatedAt: Date;
comment?: string;
}) {
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
if (analysisElements.length !== orderedAnalysisElementsIds.length) {
throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`);
}
const { data: analysisElements } = (await supabase
.schema('medreport')
.from('analysis_elements')
.select(`*, analysis_groups(*)`)
.in('id', orderedElements)) as {
data: ({
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
} & Tables<{ schema: 'medreport' }, 'analysis_elements'>)[];
};
const { data: analyses } = (await supabase
.schema('medreport')
.from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`)
.in('id', orderedAnalyses)) as {
data: ({
analysis_elements: Tables<
{ schema: 'medreport' },
'analysis_elements'
> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
} & Tables<{ schema: 'medreport' }, 'analyses'>)[];
};
const analyses = await getAnalyses({ ids: orderedAnalysesIds });
if (analyses.length !== orderedAnalysesIds.length) {
throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
}
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
uniqBy(
@@ -501,22 +516,19 @@ export async function composeOrderXML(
analysisSection.push(groupXml);
}
// TODO get actual data when order creation is implemented
return `<?xml version="1.0" encoding="UTF-8"?>
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
${getPais(process.env.MEDIPOST_USER!, process.env.MEDIPOST_RECIPIENT!, createdAnalysisOrder.created_at, createdAnalysisOrder.id)}
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
<Tellimus cito="EI">
<ValisTellimuseId>${createdAnalysisOrder.id}</ValisTellimuseId>
<ValisTellimuseId>${orderId}</ValisTellimuseId>
<!--<TellijaAsutus>-->
${getClientInstitution()}
<!--<TeostajaAsutus>-->
${getProviderInstitution()}
<!--<TellijaIsik>-->
${getClientPerson()}
<!--<SisestajaIsik>-->
${getOrderEnteredByPerson()}
${getClientPerson(person)}
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
${getPatient(49610230861, 'Surname', 'First Name', '1996-10-23', 'N')}
${getPatient(person)}
${getConfidentiality()}
${specimenSection.join('')}
${analysisSection?.join('')}
@@ -524,48 +536,47 @@ export async function composeOrderXML(
</Saadetis>`;
}
function getLatestMessage(messages?: Message[]) {
function getLatestMessage({
messages,
excludedMessageIds,
}: {
messages?: Message[];
excludedMessageIds?: string[];
}) {
if (!messages?.length) {
return null;
}
return messages.reduce((prev, current) =>
const filtered = messages.filter(({ messageId }) => !excludedMessageIds?.includes(messageId));
if (!filtered.length) {
return null;
}
return filtered.reduce((prev, current) =>
Number(prev.messageId) > Number(current.messageId) ? prev : current,
{ messageId: '' } as Message,
);
}
export async function syncPrivateMessage(
parsedMessage?: MedipostOrderResponse,
) {
const supabase = createCustomClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
},
);
const response = parsedMessage?.Saadetis?.Vastus;
if (!response) {
throw new Error(`Invalid data in private message response`);
}
const status = response.TellimuseOlek;
export async function syncPrivateMessage({
messageResponse,
order,
}: {
messageResponse: MedipostOrderResponse['Saadetis']['Vastus'];
order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
}) {
const supabase = getSupabaseServerAdminClient()
const { data: analysisOrder, error: analysisOrderError } = await supabase
.schema('medreport')
.from('analysis_orders')
.select('user_id')
.eq('id', response.ValisTellimuseId);
.eq('id', order.id);
if (analysisOrderError || !analysisOrder?.[0]?.user_id) {
throw new Error(
`Could not find analysis order with id ${response.ValisTellimuseId}`,
`Could not find analysis order with id ${messageResponse.ValisTellimuseId}`,
);
}
@@ -574,9 +585,9 @@ export async function syncPrivateMessage(
.from('analysis_responses')
.upsert(
{
analysis_order_id: response.ValisTellimuseId,
order_number: response.TellimuseNumber,
order_status: AnalysisOrderStatus[status],
analysis_order_id: order.id,
order_number: messageResponse.TellimuseNumber,
order_status: AnalysisOrderStatus[messageResponse.TellimuseOlek],
user_id: analysisOrder[0].user_id,
},
{ onConflict: 'order_number', ignoreDuplicates: false },
@@ -585,10 +596,11 @@ export async function syncPrivateMessage(
if (error || !analysisResponse?.[0]?.id) {
throw new Error(
`Failed to insert or update analysis order response (external id: ${response?.TellimuseNumber})`,
`Failed to insert or update analysis order response (external id: ${messageResponse?.TellimuseNumber})`,
);
}
const analysisGroups = toArray(response.UuringuGrupp);
const analysisGroups = toArray(messageResponse.UuringuGrupp);
console.info(`Order has results for ${analysisGroups.length} analysis groups`);
const responses: Omit<
Tables<{ schema: 'medreport' }, 'analysis_response_elements'>,
@@ -598,6 +610,7 @@ export async function syncPrivateMessage(
const groupItems = toArray(
analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'],
);
console.info(`Order has results in group ${analysisGroup.UuringuGruppNimi} for ${groupItems.length} analysis elements`);
for (const item of groupItems) {
const element = item.UuringuElement;
const elementAnalysisResponses = toArray(element.UuringuVastus);
@@ -646,5 +659,96 @@ export async function syncPrivateMessage(
);
}
return AnalysisOrderStatus[status];
return AnalysisOrderStatus[messageResponse.TellimuseOlek];
}
export async function sendOrderToMedipost({
medusaOrderId,
orderedAnalysisElements,
}: {
medusaOrderId: string;
orderedAnalysisElements: { analysisElementId: number }[];
}) {
const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderXml = await composeOrderXML({
person: {
idCode: account.personal_code!,
firstName: account.name ?? '',
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
comment: '',
});
await sendPrivateMessage(orderXml);
}
export async function getOrderedAnalysisElementsIds({
medusaOrder,
}: {
medusaOrder: StoreOrder;
}): Promise<{
analysisElementId: number;
}[]> {
const countryCodes = await listRegions();
const countryCode = countryCodes[0]!.countries![0]!.iso_2!;
function getOrderedAnalysisElements(medusaOrder: StoreOrder) {
return (medusaOrder?.items ?? [])
.filter(({ product }) => product?.handle.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX))
.map((line) => {
const analysisElementId = Number(line.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, ''));
if (Number.isNaN(analysisElementId)) {
return null;
}
return { analysisElementId };
}) as { analysisElementId: number }[];
}
async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) {
const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX));
const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[];
if (orderedPackageIds.length === 0) {
return [];
}
console.info(`Order has ${orderedPackageIds.length} packages`);
const { response: { products: orderedPackagesProducts } } = await listProducts({
countryCode,
queryParams: { limit: 100, id: orderedPackageIds },
});
console.info(`Order has ${orderedPackagesProducts.length} packages`);
if (orderedPackagesProducts.length !== orderedPackageIds.length) {
throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`);
}
const ids = getAnalysisElementMedusaProductIds(orderedPackagesProducts);
const { response: { products: analysisPackagesProducts } } = await listProducts({
countryCode,
queryParams: { limit: 100, id: ids },
});
if (analysisPackagesProducts.length !== ids.length) {
throw new Error(`Got ${analysisPackagesProducts.length} analysis packages products, expected ${ids.length}`);
}
const originalIds = analysisPackagesProducts
.map(({ metadata }) => metadata?.analysisIdOriginal)
.filter((id) => typeof id === 'string');
if (originalIds.length !== ids.length) {
throw new Error(`Got ${originalIds.length} analysis packages products with analysisIdOriginal, expected ${ids.length}`);
}
const analysisElements = await getAnalysisElements({ originalIds });
return analysisElements.map(({ id }) => ({ analysisElementId: id }));
}
const analysisPackageElements = await getOrderedAnalysisPackages(medusaOrder);
const orderedAnalysisElements = getOrderedAnalysisElements(medusaOrder);
return [...analysisPackageElements, ...orderedAnalysisElements];
}

View File

@@ -0,0 +1,85 @@
export interface IUuringElement {
UuringIdOID: string;
UuringId: string;
TLyhend: string;
KNimetus: string;
UuringNimi: string;
Jarjekord: number;
Kood: {
HkKood: string;
HkKoodiKordaja: number;
Koefitsient: number;
Hind: number;
}[];
UuringuElement: {
UuringIdOID: string;
UuringId: string;
TLyhend: string;
KNimetus: string;
UuringNimi: string;
Jarjekord: number;
Kood: {
HkKood: string;
HkKoodiKordaja: number;
Koefitsient: number;
Hind: number;
}[];
}[];
}
export interface IMaterialGroup {
id: string;
name: string;
order: number;
}
export interface IMedipostPublicMessageDataParsed {
ANSWER: {
CODE: number;
MESSAGE: string;
};
Saadetis: {
Teenused: {
Teostaja: {
UuringuGrupp: {
UuringuGruppId: string;
UuringuGruppNimi: string;
UuringuGruppJarjekord: number;
Kood: {
HkKood: string;
HkKoodiKordaja: number;
Koefitsient: number;
Hind: number;
}[];
Uuring: {
UuringId: string;
UuringNimi: string;
UuringJarjekord: number;
UuringuElement: {
UuringIdOID: string;
UuringId: string;
TLyhend: string;
KNimetus: string;
UuringNimi: string;
Jarjekord: number;
Kood: {
HkKood: string;
HkKoodiKordaja: number;
Koefitsient: number;
Hind: number;
}[];
UuringuElement: IUuringElement;
}[];
MaterjalideGrupp: IMaterialGroup[];
Kood: {
HkKood: string;
HkKoodiKordaja: number;
Koefitsient: number;
Hind: number;
}[];
}[];
}[];
}[];
};
};
}

View File

@@ -0,0 +1,189 @@
'use server';
import {
getClientInstitution,
getClientPerson,
getPais,
getPatient,
getProviderInstitution,
} from '@/lib/templates/medipost-order';
import {
MedipostAction,
} from '@/lib/types/medipost';
import axios from 'axios';
import { uniqBy } from 'lodash';
import { Tables } from '@kit/supabase/database';
import { formatDate } from 'date-fns';
import { getAnalyses } from './analyses.service';
import { getAnalysisElementsAdmin } from './analysis-element.service';
import { validateMedipostResponse } from './medipost.service';
const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!;
const PASSWORD = process.env.MEDIPOST_PASSWORD!;
const RECIPIENT = process.env.MEDIPOST_RECIPIENT!;
export async function sendPrivateMessageTestResponse({
messageXml,
}: {
messageXml: string;
}) {
const body = new FormData();
body.append('Action', MedipostAction.SendPrivateMessage);
body.append('User', USER);
body.append('Password', PASSWORD);
body.append('Receiver', RECIPIENT);
body.append('MessageType', 'Vastus');
body.append(
'Message',
new Blob([messageXml], {
type: 'text/xml; charset=UTF-8',
}),
);
const { data } = await axios.post(BASE_URL, body);
await validateMedipostResponse(data);
}
function getRandomInt(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export async function composeOrderTestResponseXML({
person,
orderedAnalysisElementsIds,
orderedAnalysesIds,
orderId,
orderCreatedAt,
}: {
person: {
idCode: string;
firstName: string;
lastName: string;
phone: string;
};
orderedAnalysisElementsIds: number[];
orderedAnalysesIds: number[];
orderId: string;
orderCreatedAt: Date;
}) {
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
const analyses = await getAnalyses({ ids: orderedAnalysesIds });
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
uniqBy(
(
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
[]
).concat(
analyses?.flatMap(
({ analysis_elements }) => analysis_elements.analysis_groups,
) ?? [],
),
'id',
);
// Tellimuse olek:
// 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 allAnalysisElementsForGroups = analysisElements?.filter((element) => {
return analysisGroups.some((group) => group.id === element.analysis_groups.id);
});
const addedIds = new Set<number>();
return `<?xml version="1.0" encoding="UTF-8"?>
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
${getPais(USER, RECIPIENT, orderCreatedAt, orderId, "AL")}
<Vastus>
<ValisTellimuseId>${orderId}</ValisTellimuseId>
${getClientInstitution({ index: 1 })}
${getProviderInstitution({ index: 1 })}
${getClientPerson(person)}
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
${getPatient(person)}
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.1.625.2.17</ProovinouIdOID>
<ProovinouId>16522314</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.8</MaterjaliTyypOID>
<MaterjaliTyyp>119297000</MaterjaliTyyp>
<MaterjaliNimi>Veri</MaterjaliNimi>
<Ribakood>16522314</Ribakood>
<Jarjenumber>1</Jarjenumber>
<VotmisAeg>2022-08-19 08:53:00</VotmisAeg>
<SaabumisAeg>2022-08-23 15:10:00</SaabumisAeg>
</Proov>
<TellimuseNumber>${orderNumber}</TellimuseNumber>
<TellimuseOlek>${orderStatus}</TellimuseOlek>
${allAnalysisElementsForGroups.map((analysisElement) => {
const group = analysisGroups.find((group) => group.id === analysisElement.analysis_groups.id);
if (!group) {
throw new Error(`Failed to find group for analysis element ${analysisElement.id}`);
}
let relatedAnalysisElement = analysisElements?.find(
(element) => element.analysis_groups.id === group.id && !addedIds.has(element.id),
);
const relatedAnalyses = analyses?.filter((analysis) => {
return analysis.analysis_elements.analysis_groups.id === group.id && !addedIds.has(analysis.analysis_elements.id);
});
if (!relatedAnalysisElement) {
relatedAnalysisElement = relatedAnalyses?.find(
(relatedAnalysis) =>
relatedAnalysis.analysis_elements.analysis_groups.id ===
group.id,
)?.analysis_elements;
}
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
throw new Error(
`Failed to find related analysis element for group ${group.name} (id: ${group.id})`,
);
}
const lower = getRandomInt(0, 100);
const upper = getRandomInt(lower + 1, 500);
const result = getRandomInt(lower, upper);
addedIds.add(relatedAnalysisElement.id);
return (`
<UuringuGrupp>
<UuringuGruppId>${group.original_id}</UuringuGruppId>
<UuringuGruppNimi>${group.name}</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>${relatedAnalysisElement.analysis_id_oid}</UuringIdOID>
<UuringId>${relatedAnalysisElement.analysis_id_original}</UuringId>
<TLyhend>${relatedAnalysisElement.tehik_short_loinc}</TLyhend>
<KNimetus>${relatedAnalysisElement.tehik_loinc_name}</KNimetus>
<UuringNimi>${relatedAnalysisElement.analysis_name_lab ?? relatedAnalysisElement.tehik_loinc_name}</UuringNimi>
<TellijaUuringId>${relatedAnalysisElement.id}</TellijaUuringId>
<TeostajaUuringId>${relatedAnalysisElement.id}</TeostajaUuringId>
<UuringOlek>4</UuringOlek>
<Mootyhik>%</Mootyhik>
<UuringuVastus>
<VastuseVaartus>${result}</VastuseVaartus>
<VastuseAeg>${formatDate(new Date(), 'yyyy-MM-dd HH:mm:ss')}</VastuseAeg>
<NormYlem kaasaarvatud=\"EI\">${upper}</NormYlem>
<NormAlum kaasaarvatud=\"EI\">${lower}</NormAlum>
<NormiStaatus>0</NormiStaatus>
<ProoviJarjenumber>1</ProoviJarjenumber>
</UuringuVastus>
</UuringuElement>
<UuringuTaitjaAsutuseJnr>2</UuringuTaitjaAsutuseJnr>
</Uuring>
</UuringuGrupp>
`);
}).join('')}
</Vastus>
</Saadetis>`;
}

View File

@@ -0,0 +1,86 @@
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { Tables } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { StoreOrder } from '@medusajs/types';
export async function createOrder({
medusaOrder,
orderedAnalysisElements,
}: {
medusaOrder: StoreOrder;
orderedAnalysisElements: { analysisElementId: number }[];
}) {
const supabase = getSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('User not found');
}
const orderResult = await supabase.schema('medreport')
.from('analysis_orders')
.insert({
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
analysis_ids: [],
status: 'QUEUED',
user_id: user.id,
medusa_order_id: medusaOrder.id,
})
.select('id')
.single()
.throwOnError();
if (orderResult.error || !orderResult.data?.id) {
throw new Error(`Failed to create order, message=${orderResult.error}, data=${JSON.stringify(orderResult)}`);
}
}
export async function updateOrder({
orderId,
orderStatus,
}: {
orderId: number;
orderStatus: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
}) {
const { error } = await getSupabaseServerClient()
.schema('medreport')
.from('analysis_orders')
.update({
status: orderStatus,
})
.eq('id', orderId)
.throwOnError();
if (error) {
throw new Error(`Failed to update order, message=${error}, data=${JSON.stringify(error)}`);
}
}
export async function getOrder({
medusaOrderId,
}: {
medusaOrderId: string;
}) {
const query = getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_orders')
.select('*')
.eq('medusa_order_id', medusaOrderId)
const { data: order } = await query.single().throwOnError();
return order;
}
export async function getOrders({
orderStatus,
}: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
} = {}) {
const query = getSupabaseServerClient()
.schema('medreport')
.from('analysis_orders')
.select('*')
if (orderStatus) {
query.eq('status', orderStatus);
}
const orders = await query.throwOnError();
return orders.data;
}

View File

@@ -0,0 +1,18 @@
import { format } from 'date-fns';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export const getLastCheckedDate = async () => {
const { data: lastChecked } = await getSupabaseServerAdminClient()
.schema('audit')
.from('sync_entries')
.select('created_at')
.eq('status', 'SUCCESS')
.order('created_at')
.limit(1);
const lastEntry = lastChecked?.[0];
const lastCheckedDate = lastEntry
? format(lastEntry.created_at, 'yyyy-MM-dd HH:mm:ss')
: null;
return lastCheckedDate;
}

View File

@@ -1,6 +1,7 @@
import { DATE_TIME_FORMAT } from '@/lib/constants';
import { Tables } from '@/packages/supabase/src/database.types';
import { format } from 'date-fns';
import Isikukood, { Gender } from 'isikukood';
import { Tables } from '@/packages/supabase/src/database.types';
import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants';
const isProd = process.env.NODE_ENV === 'production';
@@ -8,26 +9,27 @@ export const getPais = (
sender: string,
recipient: string,
createdAt: Date,
messageId: number,
orderId: string,
packageName = "OL",
) => {
if (isProd) {
// return correct data
}
return `<Pais>
<Pakett versioon="20">OL</Pakett>
<Pakett versioon="20">${packageName}</Pakett>
<Saatja>${sender}</Saatja>
<Saaja>${recipient}</Saaja>
<Aeg>${format(createdAt, DATE_TIME_FORMAT)}</Aeg>
<SaadetisId>${messageId}</SaadetisId>
<SaadetisId>${orderId}</SaadetisId>
<Email>argo@medreport.ee</Email>
</Pais>`;
};
export const getClientInstitution = () => {
export const getClientInstitution = ({ index }: { index?: number } = {}) => {
if (isProd) {
// return correct data
}
return `<Asutus tyyp="TELLIJA">
return `<Asutus tyyp="TELLIJA" ${index ? ` jarjenumber="${index}"` : ''}>
<AsutuseId>16381793</AsutuseId>
<AsutuseNimi>MedReport OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
@@ -35,11 +37,11 @@ export const getClientInstitution = () => {
</Asutus>`;
};
export const getProviderInstitution = () => {
export const getProviderInstitution = ({ index }: { index?: number } = {}) => {
if (isProd) {
// return correct data
}
return `<Asutus tyyp="TEOSTAJA">
return `<Asutus tyyp="TEOSTAJA" ${index ? ` jarjenumber="${index}"` : ''}>
<AsutuseId>11107913</AsutuseId>
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi>
<AsutuseKood>SLA</AsutuseKood>
@@ -48,47 +50,60 @@ export const getProviderInstitution = () => {
</Asutus>`;
};
export const getClientPerson = () => {
export const getClientPerson = ({
idCode,
firstName,
lastName,
phone,
}: {
idCode: string,
firstName: string,
lastName: string,
phone: string,
}) => {
if (isProd) {
// return correct data
}
return `<Personal tyyp="TELLIJA" jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
<Telefon>+37258131202</Telefon>
<PersonalKood>${idCode}</PersonalKood>
<PersonalPerekonnaNimi>${lastName}</PersonalPerekonnaNimi>
<PersonalEesNimi>${firstName}</PersonalEesNimi>
${phone ? `<Telefon>${phone.startsWith('+372') ? phone : `+372${phone}`}</Telefon>` : ''}
</Personal>`;
};
export const getOrderEnteredPerson = () => {
if (isProd) {
// return correct data
}
return `<Personal tyyp="SISESTAJA" jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
<Telefon>+37258131202</Telefon>
</Personal>`;
};
// export const getOrderEnteredPerson = () => {
// if (isProd) {
// // return correct data
// }
// return `<Personal tyyp="SISESTAJA" jarjenumber="1">
// <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
// <PersonalKood>D07907</PersonalKood>
// <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
// <PersonalEesNimi>Tsvetkov</PersonalEesNimi>
// <Telefon>+37258131202</Telefon>
// </Personal>`;
// };
export const getPatient = (
idCode: number,
surname: string,
export const getPatient = ({
idCode,
lastName,
firstName,
}: {
idCode: string,
lastName: string,
firstName: string,
birthDate: string,
genderLetter: string,
) => {
}) => {
const isikukood = new Isikukood(idCode);
return `<Patsient>
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
<Isikukood>${idCode}</Isikukood>
<PerekonnaNimi>${surname}</PerekonnaNimi>
<PerekonnaNimi>${lastName}</PerekonnaNimi>
<EesNimi>${firstName}</EesNimi>
<SynniAeg>${birthDate}</SynniAeg>
<SynniAeg>${format(isikukood.getBirthday(), DATE_FORMAT)}</SynniAeg>
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
<Sugu>${genderLetter}</Sugu>
<Sugu>${isikukood.getGender() === Gender.MALE ? 'M' : 'N'}</Sugu>
</Patsient>`;
};
@@ -106,19 +121,19 @@ export const getConfidentiality = () => {
</Konfidentsiaalsus>`;
};
export const getOrderEnteredByPerson = () => {
if (isProd) {
// return correct data
}
return `
<Personal tyyp="SISESTAJA" jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
<Telefon>+37258131202</Telefon>
</Personal>`;
};
// export const getOrderEnteredByPerson = () => {
// if (isProd) {
// // return correct data
// }
// return `
// <Personal tyyp="SISESTAJA" jarjenumber="1">
// <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
// <PersonalKood>D07907</PersonalKood>
// <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
// <PersonalEesNimi>Tsvetkov</PersonalEesNimi>
// <Telefon>+37258131202</Telefon>
// </Personal>`;
// };
export const getSpecimen = (
materialTypeOid: string,

9
lib/types/code.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface ICode {
hk_code: string;
hk_code_multiplier: number;
coefficient: number;
price: number;
analysis_group_id: number | null;
analysis_element_id: number | null;
analysis_id: number | null;
}

View File

@@ -224,3 +224,36 @@ export const ConfirmedLoadResponseSchema = z.object({
ErrorMessage: z.union([z.string(), z.null()]),
});
export type ConfirmedLoadResponse = z.infer<typeof ConfirmedLoadResponseSchema>;
export interface ISearchLoadResponse {
Value: string;
Data: {
T_Lic: {
ID: number;
Name: string;
OnlineCanSelectWorker: boolean;
Email: string | null;
PersonalCodeRequired: boolean;
Phone: string | null;
}[];
T_Service: {
ID: number;
ClinicID: number;
Code: string;
Description: string | null;
Display: string;
Duration: number;
HasFreeCodes: boolean;
Name: string;
NetoDuration: number;
OnlineHideDuration: number;
OnlineHidePrice: number;
Price: number;
PricePeriods: string | null;
RequiresPayment: boolean;
SyncID: string;
}[];
};
ErrorCode: number;
ErrorMessage: string;
}

View File

@@ -1,3 +1,12 @@
export interface IMedipostResponseXMLBase {
'?xml': {
'@_version': string;
'@_encoding': string;
'@_standalone': 'yes' | 'no';
};
ANSWER?: { CODE: number };
}
export type Message = {
messageId: string;
messageType: string;
@@ -120,13 +129,7 @@ export type Teostaja = {
Sisendparameeter?: Sisendparameeter | Sisendparameeter[]; //0...n
};
export type MedipostPublicMessageResponse = {
'?xml': {
'@_version': string;
'@_encoding': string;
'@_standalone'?: 'yes' | 'no';
};
ANSWER?: { CODE: number };
export type MedipostPublicMessageResponse = IMedipostResponseXMLBase & {
Saadetis?: {
Pais: {
Pakett: { '#text': 'SL' | 'OL' | 'AL' | 'ME' }; // SL - Teenused, OL - Tellimus (meie poolt saadetav saatekiri), AL - Vastus (saatekirja vastus), ME - Teade
@@ -186,13 +189,7 @@ export type ResponseUuringuGrupp = {
};
// type for UuringuGrupp is correct, but some of this is generated by an LLM and should be checked if data in use
export type MedipostOrderResponse = {
'?xml': {
'@_version': string;
'@_encoding': string;
'@_standalone': 'yes' | 'no';
};
ANSWER?: { CODE: number };
export type MedipostOrderResponse = IMedipostResponseXMLBase & {
Saadetis: {
Pais: {
Pakett: {
@@ -206,7 +203,7 @@ export type MedipostOrderResponse = {
Email: string;
};
Vastus: {
ValisTellimuseId: number;
ValisTellimuseId: string;
Asutus: {
'@_tyyp': string; // TEOSTAJA
'@_jarjenumber': string;
@@ -252,16 +249,16 @@ export type MedipostOrderResponse = {
};
};
export const AnalysisOrderStatus: Record<number, string> = {
export const AnalysisOrderStatus = {
1: 'QUEUED',
2: 'ON_HOLD',
3: 'PROCESSING',
4: 'COMPLETED',
5: 'REJECTED',
6: 'CANCELLED',
};
} as const;
export const NormStatus: Record<number, string> = {
1: 'NORMAL',
2: 'WARNING',
3: 'REQUIRES_ATTENTION',
};
} as const;

View File

@@ -26,9 +26,7 @@
"supabase:db:diff": "supabase db diff --schema auth --schema audit --schema medreport",
"supabase:deploy": "supabase link --project-ref $SUPABASE_PROJECT_REF && supabase db push",
"supabase:typegen": "supabase gen types typescript --local > ./packages/supabase/src/database.types.ts",
"supabase:db:dump:local": "supabase db dump --local --data-only",
"sync-analysis-groups:dev": "NODE_ENV=local ts-node jobs/sync-analysis-groups.ts",
"sync-connected-online:dev": "NODE_ENV=local ts-node jobs/sync-connected-online.ts"
"supabase:db:dump:local": "supabase db dump --local --data-only"
},
"dependencies": {
"@edge-csrf/nextjs": "2.5.3-cloudflare-rc1",

View File

@@ -209,15 +209,10 @@ class AccountsApi {
return null;
}
const elementMap = new Map(
analysisResponseElements.map((e) => [e.analysis_response_id, e]),
);
return analysisResponses
.filter((r) => elementMap.has(r.id))
.map((r) => ({
...r,
element: elementMap.get(r.id)!,
elements: analysisResponseElements.filter((e) => e.analysis_response_id === r.id),
}));
}
}

View File

@@ -2,5 +2,5 @@ import { Database } from '@kit/supabase/database';
export type UserAnalysis =
(Database['medreport']['Tables']['analysis_responses']['Row'] & {
element: Database['medreport']['Tables']['analysis_response_elements']['Row'];
elements: Database['medreport']['Tables']['analysis_response_elements']['Row'][];
})[];

View File

@@ -87,8 +87,8 @@ export async function getOrSetCart(countryCode: string) {
return cart;
}
export async function updateCart(data: HttpTypes.StoreUpdateCart) {
const cartId = await getCartId();
export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & { id?: string }) {
const cartId = id || (await getCartId());
if (!cartId) {
throw new Error(

View File

@@ -27,8 +27,22 @@ export const listCategories = async (query?: Record<string, any>) => {
}
export const getCategoryByHandle = async (categoryHandle: string[]) => {
const handle = `${categoryHandle.join("/")}`
const { product_categories } = await getProductCategories({
handle: `${categoryHandle.join("/")}`,
limit: 1,
});
return product_categories[0];
}
export const getProductCategories = async ({
handle,
limit,
fields = "*category_children, *products",
}: {
handle?: string;
limit?: number;
fields?: string;
} = {}) => {
const next = {
...(await getCacheOptions("categories")),
}
@@ -38,12 +52,12 @@ export const getCategoryByHandle = async (categoryHandle: string[]) => {
`/store/product-categories`,
{
query: {
fields: "*category_children, *products",
fields,
handle,
limit,
},
next,
cache: "force-cache",
//cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories[0])
);
}

View File

@@ -14,7 +14,7 @@ export const listProducts = async ({
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string }
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[] }
countryCode?: string
regionId?: string
}): Promise<{
@@ -145,7 +145,7 @@ export const listProductTypes = async (): Promise<{ productTypes: HttpTypes.Stor
"/store/product-types",
{
next,
cache: "force-cache",
//cache: "force-cache",
query: {
fields: "id,value,metadata",
},

View File

@@ -490,6 +490,7 @@ export type Database = {
analysis_ids: number[] | null
created_at: string
id: number
medusa_order_id: string
status: Database["medreport"]["Enums"]["analysis_order_status"]
user_id: string
}
@@ -498,6 +499,7 @@ export type Database = {
analysis_ids?: number[] | null
created_at?: string
id?: number
medusa_order_id: string
status: Database["medreport"]["Enums"]["analysis_order_status"]
user_id: string
}
@@ -506,6 +508,7 @@ export type Database = {
analysis_ids?: number[] | null
created_at?: string
id?: number
medusa_order_id?: string
status?: Database["medreport"]["Enums"]["analysis_order_status"]
user_id?: string
}

View File

@@ -125,7 +125,8 @@
},
"analysisResults": {
"pageTitle": "My analysis results",
"description": "Super, oled käinud tervist kontrollimas. Siin on sinule olulised näitajad:",
"orderNewAnalysis": "Telli uued analüüsid"
"description": "Super, you've already done your analysis. Here are your important results:",
"descriptionEmpty": "If you've already done your analysis, your results will appear here soon.",
"orderNewAnalysis": "Order new analyses"
}
}

View File

@@ -0,0 +1,4 @@
{
"title": "Select analysis",
"description": "Select the analysis that suits your needs"
}

View File

@@ -149,6 +149,7 @@
"analysisResults": {
"pageTitle": "Minu analüüside vastused",
"description": "Super, oled käinud tervist kontrollimas. Siin on sinule olulised näitajad:",
"descriptionEmpty": "Kui oled juba käinud analüüse andmas, siis varsti jõuavad siia sinu analüüside vastused.",
"orderNewAnalysis": "Telli uued analüüsid"
}
}

View File

@@ -0,0 +1,4 @@
{
"title": "Vali analüüs",
"description": "Vali enda vajadustele sobiv analüüs"
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE medreport.analysis_orders
ADD COLUMN medusa_order_id TEXT NOT NULL;

19
utils/medusa-product.ts Normal file
View File

@@ -0,0 +1,19 @@
export const getAnalysisElementMedusaProductIds = (products: ({ metadata?: { analysisElementMedusaProductIds?: string } | null } | null)[]) => {
if (!products) {
return [];
}
const mapped = products
.flatMap((product) => {
const value = product?.metadata?.analysisElementMedusaProductIds;
try {
return JSON.parse(value as string);
} catch (e) {
console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e);
return [];
}
})
.filter(Boolean) as string[];
return [...new Set(mapped)];
}