From cffb0d584341a5b827059dd2e968809fdd572c74 Mon Sep 17 00:00:00 2001 From: Helena <37183360+helenarebane@users.noreply.github.com> Date: Wed, 11 Jun 2025 19:22:28 +0300 Subject: [PATCH] B2B: add analysis group sync (#12) Co-authored-by: Helena --- jobs/sync-analysis-groups.ts | 273 +++++++++++++++++++++++++++++++++++ package.json | 9 +- pnpm-lock.yaml | 106 ++++++++++++++ 3 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 jobs/sync-analysis-groups.ts diff --git a/jobs/sync-analysis-groups.ts b/jobs/sync-analysis-groups.ts new file mode 100644 index 0000000..a0d977e --- /dev/null +++ b/jobs/sync-analysis-groups.ts @@ -0,0 +1,273 @@ +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!; + + if (!baseUrl || !user || !password || !sender) { + throw new Error('Could not access all necessary environment variables'); + } + + const supabase = createCustomClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!, + { + 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 } = await axios.get(baseUrl, { + params: { + Action: 'GetPublicMessageList', + User: user, + Password: password, + Sender: sender, + ...lastCheckedDate, + MessageType: 'Teenus', + }, + }); + + 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', + }); + } + + for (const analysisGroup of analysisGroups) { + // SAVE ANALYSIS GROUP + const { data: insertedAnalysisGroup, error } = await supabase + .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); + const codes = 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 + .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 + .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 (analysisElement.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('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/package.json b/package.json index 118e90f..e79bb89 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "supabase:typegen": "pnpm run supabase:typegen:packages && pnpm run supabase:typegen:app", "supabase:typegen:packages": "supabase gen types typescript --local > ../../packages/supabase/src/database.types.ts", "supabase:typegen:app": "supabase gen types typescript --local > ./lib/database.types.ts", - "supabase:db:dump:local": "supabase db dump --local --data-only" + "supabase:db:dump:local": "supabase db dump --local --data-only", + "sync-data:dev": "NODE_ENV=local ts-node jobs/sync-analysis-groups.ts" }, "dependencies": { "@edge-csrf/nextjs": "2.5.3-cloudflare-rc1", @@ -76,6 +77,7 @@ "recharts": "2.15.3", "sonner": "^2.0.3", "tailwind-merge": "^3.3.0", + "ts-node": "^10.9.2", "zod": "^3.24.4" }, "devDependencies": { @@ -85,10 +87,10 @@ "@kit/tsconfig": "workspace:*", "@next/bundle-analyzer": "15.3.2", "@tailwindcss/postcss": "^4.1.7", + "@types/lodash": "^4.17.17", "@types/node": "^22.15.18", "@types/react": "19.1.4", "@types/react-dom": "19.1.5", - "@types/lodash": "^4.17.17", "babel-plugin-react-compiler": "19.1.0-rc.2", "cssnano": "^7.0.7", "pino-pretty": "^13.0.0", @@ -98,7 +100,8 @@ "tailwindcss": "4.1.7", "tailwindcss-animate": "^1.0.7", "typescript": "^5.8.3", - "yup": "^1.6.1" + "yup": "^1.6.1", + "dotenv": "^16.5.0" }, "prettier": "@kit/prettier-config", "browserslist": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb0673c..1c05d79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: tailwind-merge: specifier: ^3.3.0 version: 3.3.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.15.30)(typescript@5.8.3) zod: specifier: ^3.24.4 version: 3.25.56 @@ -180,6 +183,9 @@ importers: cssnano: specifier: ^7.0.7 version: 7.0.7(postcss@8.5.4) + dotenv: + specifier: ^16.5.0 + version: 16.5.0 pino-pretty: specifier: ^13.0.0 version: 13.0.0 @@ -1581,6 +1587,10 @@ packages: '@corex/deepmerge@4.0.43': resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -1899,6 +1909,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} @@ -4168,6 +4181,18 @@ packages: peerDependencies: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || 14 || 15 || 16 + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -4620,6 +4645,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4876,6 +4904,9 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5062,6 +5093,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + direction@1.0.4: resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} hasBin: true @@ -6017,6 +6052,9 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -7331,6 +7369,20 @@ packages: ts-case-convert@2.1.0: resolution: {integrity: sha512-Ye79el/pHYXfoew6kqhMwCoxp4NWjKNcm2kBzpmEMIU9dd9aBmHNNFtZ+WTm0rz1ngyDmfqDXDlyUnBXayiD0w==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -7490,6 +7542,9 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} @@ -7660,6 +7715,10 @@ packages: resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -7816,6 +7875,10 @@ snapshots: '@corex/deepmerge@4.0.43': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@discoveryjs/json-ext@0.5.7': {} '@edge-csrf/nextjs@2.5.3-cloudflare-rc1(next@15.3.2(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': @@ -8147,6 +8210,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@js-sdsl/ordered-map@4.4.2': {} '@juggle/resize-observer@3.4.0': {} @@ -11071,6 +11139,14 @@ snapshots: graphql: 16.11.0 graphql-tag: 2.12.6(graphql@16.11.0) + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -11593,6 +11669,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + arg@4.1.3: {} + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -11888,6 +11966,8 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + create-require@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -12080,6 +12160,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@4.0.2: {} + direction@1.0.4: {} doctrine@2.1.0: @@ -13123,6 +13205,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + make-error@1.3.6: {} + markdown-table@3.0.4: {} marked@7.0.4: {} @@ -14689,6 +14773,24 @@ snapshots: ts-case-convert@2.1.0: {} + ts-node@10.9.2(@types/node@22.15.30)(typescript@5.8.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.15.30 + acorn: 8.14.1 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.8.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -14880,6 +14982,8 @@ snapshots: uuid@9.0.1: {} + v8-compile-cache-lib@3.0.1: {} + vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -15090,6 +15194,8 @@ snapshots: dependencies: lib0: 0.2.108 + yn@3.1.1: {} + yocto-queue@0.1.0: {} yup@1.6.1: