diff --git a/.env b/.env index 04c866d..14a35b4 100644 --- a/.env +++ b/.env @@ -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 diff --git a/.env.example b/.env.example index ce9f94c..091949f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 4fba666..3ace0ef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/admin/accounts/[id]/page.tsx b/app/admin/accounts/[id]/page.tsx index 0417247..6f17480 100644 --- a/app/admin/accounts/[id]/page.tsx +++ b/app/admin/accounts/[id]/page.tsx @@ -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); diff --git a/app/api/job/handler/load-env.ts b/app/api/job/handler/load-env.ts new file mode 100644 index 0000000..9a218b6 --- /dev/null +++ b/app/api/job/handler/load-env.ts @@ -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}` }); + } +} diff --git a/app/api/job/handler/sync-analysis-groups-store.ts b/app/api/job/handler/sync-analysis-groups-store.ts new file mode 100644 index 0000000..ccf9c0a --- /dev/null +++ b/app/api/job/handler/sync-analysis-groups-store.ts @@ -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)}`, + ); + } +} diff --git a/app/api/job/handler/sync-analysis-groups.ts b/app/api/job/handler/sync-analysis-groups.ts new file mode 100644 index 0000000..fe76f2d --- /dev/null +++ b/app/api/job/handler/sync-analysis-groups.ts @@ -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(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)}`, + ); + } +} diff --git a/app/api/job/handler/sync-analysis-results.ts b/app/api/job/handler/sync-analysis-results.ts new file mode 100644 index 0000000..ddf07de --- /dev/null +++ b/app/api/job/handler/sync-analysis-results.ts @@ -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(', ')}`); +} diff --git a/jobs/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts similarity index 81% rename from jobs/sync-connected-online.ts rename to app/api/job/handler/sync-connected-online.ts index 4944bb7..829ba54 100644 --- a/jobs/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -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(); diff --git a/app/api/job/handler/validate-api-key.ts b/app/api/job/handler/validate-api-key.ts new file mode 100644 index 0000000..70af4ba --- /dev/null +++ b/app/api/job/handler/validate-api-key.ts @@ -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'); + } +} diff --git a/app/api/job/sync-analysis-groups-store/route.ts b/app/api/job/sync-analysis-groups-store/route.ts new file mode 100644 index 0000000..96a4d90 --- /dev/null +++ b/app/api/job/sync-analysis-groups-store/route.ts @@ -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 }); + } +}; diff --git a/app/api/job/sync-analysis-groups/route.ts b/app/api/job/sync-analysis-groups/route.ts new file mode 100644 index 0000000..e60b414 --- /dev/null +++ b/app/api/job/sync-analysis-groups/route.ts @@ -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 }); + } +}; diff --git a/app/api/job/sync-analysis-results/route.ts b/app/api/job/sync-analysis-results/route.ts new file mode 100644 index 0000000..dc0b6eb --- /dev/null +++ b/app/api/job/sync-analysis-results/route.ts @@ -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 }); + } +}; diff --git a/app/api/job/sync-connected-online/route.ts b/app/api/job/sync-connected-online/route.ts new file mode 100644 index 0000000..04e90d9 --- /dev/null +++ b/app/api/job/sync-connected-online/route.ts @@ -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 }); + } +}; diff --git a/app/api/order/medipost-create/route.ts b/app/api/order/medipost-create/route.ts new file mode 100644 index 0000000..10b9ea1 --- /dev/null +++ b/app/api/order/medipost-create/route.ts @@ -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 }); +}; diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts new file mode 100644 index 0000000..c91a0a9 --- /dev/null +++ b/app/api/order/medipost-test-response/route.ts @@ -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 }); +} diff --git a/app/auth/verify/page.tsx b/app/auth/verify/page.tsx index 8d275bf..2bddfa4 100644 --- a/app/auth/verify/page.tsx +++ b/app/auth/verify/page.tsx @@ -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 (

- + {analysisList && analysisList.length > 0 ? ( + + ) : ( + + )}

- {analysisList?.map((analysis, index) => ( - + {analysisList?.map((analysis) => ( + + {analysis.elements.map((element) => ( + + ))} + ))}
diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts index 0769f7c..6bddbf5 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts @@ -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); diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/route.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/route.ts new file mode 100644 index 0000000..907bc74 --- /dev/null +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/route.ts @@ -0,0 +1 @@ +export { GET } from "./[montonioId]/route"; diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 7e66b9f..4e96c49 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -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'; diff --git a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx index 81ef7e8..8be76c5 100644 --- a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx @@ -20,7 +20,7 @@ export const generateMetadata = async () => { }; async function OrderAnalysisPackagePage() { - const { analysisPackages, countryCode } = await loadAnalysisPackages(); + const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages(); return ( @@ -30,6 +30,7 @@ async function OrderAnalysisPackagePage() { diff --git a/app/home/(user)/(dashboard)/order-analysis/page.tsx b/app/home/(user)/(dashboard)/order-analysis/page.tsx index e8d28ad..942f78e 100644 --- a/app/home/(user)/(dashboard)/order-analysis/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis/page.tsx @@ -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 ( <> } /> + ); diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index 511e861..a26d6e1 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -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'; diff --git a/app/home/(user)/_components/cart/analysis-location.tsx b/app/home/(user)/_components/cart/analysis-location.tsx index b6128e7..ff1ff0e 100644 --- a/app/home/(user)/_components/cart/analysis-location.tsx +++ b/app/home/(user)/_components/cart/analysis-location.tsx @@ -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) => { - 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`), diff --git a/app/home/(user)/_components/compare-packages-modal.tsx b/app/home/(user)/_components/compare-packages-modal.tsx index ca2e2db..1b561ad 100644 --- a/app/home/(user)/_components/compare-packages-modal.tsx +++ b/app/home/(user)/_components/compare-packages-modal.tsx @@ -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 ( + + + + ) +} + 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 ( @@ -138,64 +98,50 @@ const ComparePackagesModal = async ({

{t('product:healthPackageComparison.description')}

-
+
- {analysisPackages.map( - (product) => { - const variant = product.variants?.[0]; - const titleKey = product.title; - const price = variant?.calculated_price?.calculated_amount ?? 0; - return ( - - - - ) - })} + + + - {dummyRows.map( + {analysisPackageElements.map( ( { - analysisNameKey, - tooltipContentKey, - includedInStandard, - includedInStandardPlus, - includedInPremium, + title, + id, + description, }, index, - ) => ( - - - {t(analysisNameKey)}{' '} - } - /> - - - {!!includedInStandard && } - - - {!!includedInStandardPlus && } - - - {!!includedInPremium && } - - - ), - )} + ) => { + if (!title) { + return null; + } + const includedInStandard = standardPackageAnalyses.includes(id); + const includedInStandardPlus = standardPlusPackageAnalyses.includes(id); + const includedInPremium = premiumPackageAnalyses.includes(id); + return ( + + + {title}{' '} + {description && (} />)} + + + {includedInStandard && } + + + {(includedInStandard || includedInStandardPlus) && } + + + {(includedInStandard || includedInStandardPlus || includedInPremium) && } + + + ); + })}
diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx new file mode 100644 index 0000000..9a9d8d8 --- /dev/null +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -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 ( +
+ {analyses.map(({ + title, + variants + }) => ( + + +
+ +
+
+ +
+ +
+
+ {title} +
+
+
+ ))} +
+ ); +} diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts new file mode 100644 index 0000000..d6bc2e2 --- /dev/null +++ b/app/home/(user)/_lib/server/load-analyses.ts @@ -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); diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index 59da4ae..b7caa78 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -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); diff --git a/app/home/(user)/_lib/server/update-cart-partner-location.ts b/app/home/(user)/_lib/server/update-cart-partner-location.ts new file mode 100644 index 0000000..0ad1c6f --- /dev/null +++ b/app/home/(user)/_lib/server/update-cart-partner-location.ts @@ -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, + }, + }); +} diff --git a/app/select-package/page.tsx b/app/select-package/page.tsx index 3ebc34b..56742bd 100644 --- a/app/select-package/page.tsx +++ b/app/select-package/page.tsx @@ -24,7 +24,7 @@ export const generateMetadata = async () => { }; async function SelectPackagePage() { - const { analysisPackages, countryCode } = await loadAnalysisPackages(); + const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages(); return (
@@ -35,6 +35,7 @@ async function SelectPackagePage() { diff --git a/components/select-analysis-package.tsx b/components/select-analysis-package.tsx index 950da9b..b4863cf 100644 --- a/components/select-analysis-package.tsx +++ b/components/select-analysis-package.tsx @@ -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]; diff --git a/jobs/sync-analysis-groups.ts b/jobs/sync-analysis-groups.ts deleted file mode 100644 index d7e32d1..0000000 --- a/jobs/sync-analysis-groups.ts +++ /dev/null @@ -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(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(); diff --git a/lib/i18n/i18n.settings.ts b/lib/i18n/i18n.settings.ts index 97a8893..2d9dad2 100644 --- a/lib/i18n/i18n.settings.ts +++ b/lib/i18n/i18n.settings.ts @@ -36,6 +36,7 @@ export const defaultI18nNamespaces = [ 'product', 'booking', 'order-analysis-package', + 'order-analysis', 'cart', 'orders', ]; diff --git a/lib/services/account.service.ts b/lib/services/account.service.ts new file mode 100644 index 0000000..29eb6cb --- /dev/null +++ b/lib/services/account.service.ts @@ -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 { + 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 { + 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; +} diff --git a/lib/services/analyses.service.ts b/lib/services/analyses.service.ts new file mode 100644 index 0000000..81eaa56 --- /dev/null +++ b/lib/services/analyses.service.ts @@ -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 { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analyses') + .select(`*, analysis_elements(*, analysis_groups(*))`) + .in('id', ids); + + return data as unknown as AnalysesWithGroupsAndElements; +} diff --git a/lib/services/analysis-element.service.ts b/lib/services/analysis-element.service.ts new file mode 100644 index 0000000..2777a77 --- /dev/null +++ b/lib/services/analysis-element.service.ts @@ -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 { + 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 { + 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; +} diff --git a/lib/services/analysis-group.service.ts b/lib/services/analysis-group.service.ts new file mode 100644 index 0000000..17e323c --- /dev/null +++ b/lib/services/analysis-group.service.ts @@ -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; +} diff --git a/lib/services/codes.service.ts b/lib/services/codes.service.ts new file mode 100644 index 0000000..f3b19d3 --- /dev/null +++ b/lib/services/codes.service.ts @@ -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); +} diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 7e25449..7c1e049 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -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(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>[] = @@ -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 ` - ${getPais(process.env.MEDIPOST_USER!, process.env.MEDIPOST_RECIPIENT!, createdAnalysisOrder.created_at, createdAnalysisOrder.id)} + ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)} - ${createdAnalysisOrder.id} + ${orderId} ${getClientInstitution()} ${getProviderInstitution()} - ${getClientPerson()} - - ${getOrderEnteredByPerson()} + ${getClientPerson(person)} ${comment ?? ''} - ${getPatient(49610230861, 'Surname', 'First Name', '1996-10-23', 'N')} + ${getPatient(person)} ${getConfidentiality()} ${specimenSection.join('')} ${analysisSection?.join('')} @@ -524,48 +536,47 @@ export async function composeOrderXML( `; } -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]; } diff --git a/lib/services/medipost.types.ts b/lib/services/medipost.types.ts new file mode 100644 index 0000000..3f14de8 --- /dev/null +++ b/lib/services/medipost.types.ts @@ -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; + }[]; + }[]; + }[]; + }[]; + }; + }; +} diff --git a/lib/services/medipostTest.service.ts b/lib/services/medipostTest.service.ts new file mode 100644 index 0000000..192db4c --- /dev/null +++ b/lib/services/medipostTest.service.ts @@ -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(); + + return ` + + ${getPais(USER, RECIPIENT, orderCreatedAt, orderId, "AL")} + + ${orderId} + ${getClientInstitution({ index: 1 })} + ${getProviderInstitution({ index: 1 })} + ${getClientPerson(person)} + Siia tuleb tellija poolne märkus + + ${getPatient(person)} + + + 1.3.6.1.4.1.28284.1.625.2.17 + 16522314 + 1.3.6.1.4.1.28284.6.2.1.244.8 + 119297000 + Veri + 16522314 + 1 + 2022-08-19 08:53:00 + 2022-08-23 15:10:00 + + + ${orderNumber} + + ${orderStatus} + ${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 (` + + ${group.original_id} + ${group.name} + + + ${relatedAnalysisElement.analysis_id_oid} + ${relatedAnalysisElement.analysis_id_original} + ${relatedAnalysisElement.tehik_short_loinc} + ${relatedAnalysisElement.tehik_loinc_name} + ${relatedAnalysisElement.analysis_name_lab ?? relatedAnalysisElement.tehik_loinc_name} + ${relatedAnalysisElement.id} + ${relatedAnalysisElement.id} + 4 + % + + ${result} + ${formatDate(new Date(), 'yyyy-MM-dd HH:mm:ss')} + ${upper} + ${lower} + 0 + 1 + + + 2 + + + `); + }).join('')} + +`; +} diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts new file mode 100644 index 0000000..7702d1b --- /dev/null +++ b/lib/services/order.service.ts @@ -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; +} diff --git a/lib/services/sync-entries.service.ts b/lib/services/sync-entries.service.ts new file mode 100644 index 0000000..01ca7bb --- /dev/null +++ b/lib/services/sync-entries.service.ts @@ -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; +} diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 9439438..6568c22 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -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 ` - OL + ${packageName} ${sender} ${recipient} ${format(createdAt, DATE_TIME_FORMAT)} - ${messageId} + ${orderId} argo@medreport.ee `; }; -export const getClientInstitution = () => { +export const getClientInstitution = ({ index }: { index?: number } = {}) => { if (isProd) { // return correct data } - return ` + return ` 16381793 MedReport OÜ TSU @@ -35,11 +37,11 @@ export const getClientInstitution = () => { `; }; -export const getProviderInstitution = () => { +export const getProviderInstitution = ({ index }: { index?: number } = {}) => { if (isProd) { // return correct data } - return ` + return ` 11107913 Synlab HTI Tallinn SLA @@ -48,47 +50,60 @@ export const getProviderInstitution = () => { `; }; -export const getClientPerson = () => { +export const getClientPerson = ({ + idCode, + firstName, + lastName, + phone, +}: { + idCode: string, + firstName: string, + lastName: string, + phone: string, +}) => { if (isProd) { // return correct data } return ` 1.3.6.1.4.1.28284.6.2.4.9 - D07907 - Eduard - Tsvetkov - +37258131202 + ${idCode} + ${lastName} + ${firstName} + ${phone ? `${phone.startsWith('+372') ? phone : `+372${phone}`}` : ''} `; }; -export const getOrderEnteredPerson = () => { - if (isProd) { - // return correct data - } - return ` - 1.3.6.1.4.1.28284.6.2.4.9 - D07907 - Eduard - Tsvetkov - +37258131202 - `; -}; +// export const getOrderEnteredPerson = () => { +// if (isProd) { +// // return correct data +// } +// return ` +// 1.3.6.1.4.1.28284.6.2.4.9 +// D07907 +// Eduard +// Tsvetkov +// +37258131202 +// `; +// }; -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 ` 1.3.6.1.4.1.28284.6.2.2.1 ${idCode} - ${surname} + ${lastName} ${firstName} - ${birthDate} + ${format(isikukood.getBirthday(), DATE_FORMAT)} 1.3.6.1.4.1.28284.6.2.3.16.2 - ${genderLetter} + ${isikukood.getGender() === Gender.MALE ? 'M' : 'N'} `; }; @@ -106,19 +121,19 @@ export const getConfidentiality = () => { `; }; -export const getOrderEnteredByPerson = () => { - if (isProd) { - // return correct data - } - return ` - - 1.3.6.1.4.1.28284.6.2.4.9 - D07907 - Eduard - Tsvetkov - +37258131202 - `; -}; +// export const getOrderEnteredByPerson = () => { +// if (isProd) { +// // return correct data +// } +// return ` +// +// 1.3.6.1.4.1.28284.6.2.4.9 +// D07907 +// Eduard +// Tsvetkov +// +37258131202 +// `; +// }; export const getSpecimen = ( materialTypeOid: string, diff --git a/lib/types/code.ts b/lib/types/code.ts new file mode 100644 index 0000000..a60ca8b --- /dev/null +++ b/lib/types/code.ts @@ -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; +} diff --git a/lib/types/connected-online.ts b/lib/types/connected-online.ts index db6205d..57e95d4 100644 --- a/lib/types/connected-online.ts +++ b/lib/types/connected-online.ts @@ -224,3 +224,36 @@ export const ConfirmedLoadResponseSchema = z.object({ ErrorMessage: z.union([z.string(), z.null()]), }); export type ConfirmedLoadResponse = z.infer; + +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; +} diff --git a/lib/types/medipost.ts b/lib/types/medipost.ts index 42b9618..0794fa0 100644 --- a/lib/types/medipost.ts +++ b/lib/types/medipost.ts @@ -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 = { +export const AnalysisOrderStatus = { 1: 'QUEUED', 2: 'ON_HOLD', 3: 'PROCESSING', 4: 'COMPLETED', 5: 'REJECTED', 6: 'CANCELLED', -}; +} as const; export const NormStatus: Record = { 1: 'NORMAL', 2: 'WARNING', 3: 'REQUIRES_ATTENTION', -}; +} as const; diff --git a/package.json b/package.json index 4106646..11b1f81 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts index dba85a8..436c2e4 100644 --- a/packages/features/accounts/src/server/api.ts +++ b/packages/features/accounts/src/server/api.ts @@ -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), })); } } diff --git a/packages/features/accounts/src/types/accounts.ts b/packages/features/accounts/src/types/accounts.ts index 459b53d..8048dea 100644 --- a/packages/features/accounts/src/types/accounts.ts +++ b/packages/features/accounts/src/types/accounts.ts @@ -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'][]; })[]; diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index 6962415..42b3700 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -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( diff --git a/packages/features/medusa-storefront/src/lib/data/categories.ts b/packages/features/medusa-storefront/src/lib/data/categories.ts index f847745..7b3987d 100644 --- a/packages/features/medusa-storefront/src/lib/data/categories.ts +++ b/packages/features/medusa-storefront/src/lib/data/categories.ts @@ -27,8 +27,22 @@ export const listCategories = async (query?: Record) => { } 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]) + ); } diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index 810d205..d242b3c 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -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", }, diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index cec67c4..94e7d39 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -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 } diff --git a/public/locales/en/account.json b/public/locales/en/account.json index b262c7f..95f7e2f 100644 --- a/public/locales/en/account.json +++ b/public/locales/en/account.json @@ -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" } } diff --git a/public/locales/en/order-analysis.json b/public/locales/en/order-analysis.json new file mode 100644 index 0000000..3cc4ea9 --- /dev/null +++ b/public/locales/en/order-analysis.json @@ -0,0 +1,4 @@ +{ + "title": "Select analysis", + "description": "Select the analysis that suits your needs" +} \ No newline at end of file diff --git a/public/locales/et/account.json b/public/locales/et/account.json index 1c28173..836c5e4 100644 --- a/public/locales/et/account.json +++ b/public/locales/et/account.json @@ -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" } } diff --git a/public/locales/et/order-analysis.json b/public/locales/et/order-analysis.json new file mode 100644 index 0000000..f04be5e --- /dev/null +++ b/public/locales/et/order-analysis.json @@ -0,0 +1,4 @@ +{ + "title": "Vali analüüs", + "description": "Vali enda vajadustele sobiv analüüs" +} \ No newline at end of file diff --git a/supabase/migrations/20250804041940_medusa_order_id.sql b/supabase/migrations/20250804041940_medusa_order_id.sql new file mode 100644 index 0000000..df963ee --- /dev/null +++ b/supabase/migrations/20250804041940_medusa_order_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE medreport.analysis_orders +ADD COLUMN medusa_order_id TEXT NOT NULL; diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts new file mode 100644 index 0000000..a93669b --- /dev/null +++ b/utils/medusa-product.ts @@ -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)]; +}