From 0d560e5a02bef133fbbe895766d0bb7c3f17e6c9 Mon Sep 17 00:00:00 2001 From: Helena <37183360+helenarebane@users.noreply.github.com> Date: Wed, 11 Jun 2025 18:25:53 +0300 Subject: [PATCH 1/2] B2B-86: add function to save order response (#11) * fix import * B2B-86: add function to save order response * fix private message parsing * generate new types * clean up * remove comment --------- Co-authored-by: Helena --- app/(marketing)/page.tsx | 8 +- lib/services/medipost.service.ts | 126 ++++++++++++- lib/types/medipost.ts | 172 +++++++++++++++--- supabase/database.types.ts | 112 +++++++++++- ...159_add_analysis_order_response_tables.sql | 129 +++++++++++++ 5 files changed, 501 insertions(+), 46 deletions(-) create mode 100644 supabase/migrations/20250610092159_add_analysis_order_response_tables.sql diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 8826673..d1c8b2d 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,15 +1,12 @@ import Link from 'next/link'; +import { MedReportTitle } from '@/components/med-report-title'; import { ArrowRightIcon } from 'lucide-react'; -import { - CtaButton, - Hero, -} from '@kit/ui/marketing'; +import { CtaButton, Hero } from '@kit/ui/marketing'; import { Trans } from '@kit/ui/trans'; import { withI18n } from '~/lib/i18n/with-i18n'; -import { MedReportTitle } from '@/components/MedReportTitle'; function Home() { return ( @@ -25,7 +22,6 @@ function Home() { cta={} /> - ); } diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 3f1007f..994581b 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -20,8 +20,10 @@ import { GetMessageListResponse, MaterjalideGrupp, MedipostAction, + MedipostOrderResponse, MedipostPublicMessageResponse, Message, + ResponseUuringuGrupp, UuringuGrupp, } from '@/lib/types/medipost'; import { toArray } from '@/lib/utils'; @@ -140,12 +142,14 @@ export async function getPrivateMessage(messageId: string) { }, }); - if (data.code && data.code !== 0) { + const parser = new XMLParser({ ignoreAttributes: false }); + const parsed = parser.parse(data); + + if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) { throw new Error(`Failed to get private message (id: ${messageId})`); } - const parser = new XMLParser({ ignoreAttributes: false }); - return parser.parse(data); + return parsed; } export async function deletePrivateMessage(messageId: string) { @@ -175,8 +179,9 @@ export async function readPrivateMessageResponse() { privateMessage.messageId, ); - if (privateMessageContent) { - // to be implemented: map and save, only delete if successful + const status = await syncPrivateMessage(privateMessageContent); + + if (status === 'COMPLETED') { await deletePrivateMessage(privateMessage.messageId); } } catch (e) { @@ -307,7 +312,6 @@ async function saveAnalysisGroup( .upsert(codes, { ignoreDuplicates: false }); if (codesError?.code) { - console.error(codesError); // TODO remove this throw new Error( `Failed to insert codes (analysis group id: ${analysisGroup.UuringuGruppId})`, ); @@ -514,3 +518,113 @@ function getLatestMessage(messages?: Message[]) { Number(prev.messageId) > Number(current.messageId) ? prev : current, ); } + +export async function syncPrivateMessage( + parsedMessage?: MedipostOrderResponse, +) { + const supabase = createCustomClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_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; + + const { data: analysisOrder, error: analysisOrderError } = await supabase + .from('analysis_orders') + .select('user_id') + .eq('id', response.ValisTellimuseId); + + if (analysisOrderError || !analysisOrder?.[0]?.user_id) { + throw new Error( + `Could not find analysis order with id ${response.ValisTellimuseId}`, + ); + } + + const { data: analysisResponse, error } = await supabase + .from('analysis_responses') + .upsert( + { + analysis_order_id: response.ValisTellimuseId, + order_number: response.TellimuseNumber, + order_status: AnalysisOrderStatus[status], + user_id: analysisOrder[0].user_id, + }, + { onConflict: 'order_number', ignoreDuplicates: false }, + ) + .select('id'); + + if (error || !analysisResponse?.[0]?.id) { + throw new Error( + `Failed to insert or update analysis order response (external id: ${response?.TellimuseNumber})`, + ); + } + const analysisGroups = toArray(response.UuringuGrupp); + + const responses: Omit< + Tables<'analysis_response_elements'>, + 'id' | 'created_at' | 'updated_at' + >[] = []; + for (const analysisGroup of analysisGroups) { + const groupItems = toArray( + analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], + ); + for (const item of groupItems) { + const element = item.UuringuElement; + const elementAnalysisResponses = toArray(element.UuringuVastus); + + responses.push( + ...elementAnalysisResponses.map((response) => ({ + analysis_element_original_id: element.UuringId, + analysis_response_id: analysisResponse[0]!.id, + norm_lower: response.NormAlum?.['#text'] ?? null, + norm_lower_included: + response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', + norm_status: response.NormiStaatus, + norm_upper: response.NormYlem?.['#text'] ?? null, + norm_upper_included: + response.NormYlem?.['@_kaasaarvatud'].toLowerCase() === 'jah', + response_time: response.VastuseAeg ?? null, + response_value: response.VastuseVaartus, + unit: element.Mootyhik ?? null, + original_response_element: element, + })), + ); + } + } + + const { error: deleteError } = await supabase + .from('analysis_response_elements') + .delete() + .eq('analysis_response_id', analysisResponse[0].id); + + if (deleteError) { + throw new Error( + `Failed to clean up response elements for response id ${analysisResponse[0].id}`, + ); + } + + const { error: elementInsertError } = await supabase + .from('analysis_response_elements') + .insert(responses); + + if (elementInsertError) { + throw new Error( + `Failed to insert order response elements for response id ${analysisResponse[0].id}`, + ); + } + + return AnalysisOrderStatus[status]; +} diff --git a/lib/types/medipost.ts b/lib/types/medipost.ts index 60d116d..42b9618 100644 --- a/lib/types/medipost.ts +++ b/lib/types/medipost.ts @@ -13,25 +13,25 @@ export type GetMessageListResponse = { }; export enum MedipostAction { - GetPublicMessageList = "GetPublicMessageList", - GetPublicMessage = "GetPublicMessage", - SendPrivateMessage = "SendPrivateMessage", - GetPrivateMessageList = "GetPrivateMessageList", - GetPrivateMessage = "GetPrivateMessage", - DeletePrivateMessage = "DeletePrivateMessage", + GetPublicMessageList = 'GetPublicMessageList', + GetPublicMessage = 'GetPublicMessage', + SendPrivateMessage = 'SendPrivateMessage', + GetPrivateMessageList = 'GetPrivateMessageList', + GetPrivateMessage = 'GetPrivateMessage', + DeletePrivateMessage = 'DeletePrivateMessage', } export type VoimalikVaartus = { VaartusId: string; - Vaartus: "jah" | "ei"; + Vaartus: 'jah' | 'ei'; VaartusJarjekord: number; }; export type Sisendparameeter = { - "@_VastuseTyyp"?: "ARV" | "VABATEKST" | "KODEERITUD" | "AJAHETK"; - "@_VastuseKoodistikuOID"?: string; - "@_VastuseKoodistikuNimi"?: string; - "@_URL"?: string; + '@_VastuseTyyp'?: 'ARV' | 'VABATEKST' | 'KODEERITUD' | 'AJAHETK'; + '@_VastuseKoodistikuOID'?: string; + '@_VastuseKoodistikuNimi'?: string; + '@_URL'?: string; UuringIdOID: string; UuringId: string; @@ -72,7 +72,7 @@ export type UuringuElement = { }; export type Uuring = { - tellitav: "JAH" | "EI"; + tellitav: 'JAH' | 'EI'; UuringuElement: UuringuElement; //1..1 MaterjalideGrupp?: MaterjalideGrupp | MaterjalideGrupp[]; //0..n If this is not present, then the analysis can't be ordered. }; @@ -97,13 +97,13 @@ export type Materjal = { MaterjaliTyyp: string; MaterjaliNimi: string; KonteineriOmadus: string; - MaterjaliPaige: { Kohustuslik: "JAH" | "EI" }; //0..1 + MaterjaliPaige: { Kohustuslik: 'JAH' | 'EI' }; //0..1 MaterjalJarjekord?: number; //0..1 Konteiner?: Konteiner | Konteiner[]; //0..n }; export type MaterjalideGrupp = { - vaikimisi: "JAH" | "EI"; + vaikimisi: 'JAH' | 'EI'; Materjal: Materjal | Materjal[]; //1..n }; @@ -114,22 +114,22 @@ export type Teostaja = { AsutuseNimi: string; AsutuseKood: string; AllyksuseNimi: string; - Telefon: string; + Telefon: number; Aadress: string; }; Sisendparameeter?: Sisendparameeter | Sisendparameeter[]; //0...n }; export type MedipostPublicMessageResponse = { - "?xml": { - "@_version": string; - "@_encoding": "UTF-8"; - "@_standalone"?: "yes" | "no"; + '?xml': { + '@_version': string; + '@_encoding': string; + '@_standalone'?: 'yes' | 'no'; }; ANSWER?: { CODE: number }; Saadetis?: { Pais: { - Pakett: { "#text": "SL" | "OL" | "AL" | "ME" }; // SL - Teenused, OL - Tellimus (meie poolt saadetav saatekiri), AL - Vastus (saatekirja vastus), ME - Teade + Pakett: { '#text': 'SL' | 'OL' | 'AL' | 'ME' }; // SL - Teenused, OL - Tellimus (meie poolt saadetav saatekiri), AL - Vastus (saatekirja vastus), ME - Teade Saatja: string; Saaja: string; Aeg: string; @@ -141,11 +141,127 @@ export type MedipostPublicMessageResponse = { }; }; -export const AnalysisOrderStatus = { - 1: "QUEUED", - 2: "ON_HOLD", - 3: "PROCESSING", - 4: "COMPLETED", - 5: "REJECTED", - 6: "CANCELLED", -} as const; +export type UuringuVastus = { + VastuseVaartus: number; // text according to docs, but based on examples and logically, float + VastuseAeg: string; + NormYlem?: { + '#text': number; + '@_kaasaarvatud': string; + }; // 0..1 + NormAlum?: { + '#text': number; + '@_kaasaarvatud': string; + }; + NormiStaatus: keyof typeof NormStatus; + ProoviJarjenumber: number; +}; + +export type ResponseUuring = { + UuringuElement: { + UuringIdOID: string; + UuringId: string; + TLyhend: string; + KNimetus: string; + UuringNimi: string; + TellijaUuringId: number; + TeostajaUuringId: string; + UuringOlek: keyof typeof AnalysisOrderStatus; + Mootyhik?: string; // 0..1 + Kood: { + HkKood: number; + HkKoodiKordaja: number; + Koefitsient: number; + Hind: number; + }; + UuringuVastus?: UuringuVastus | UuringuVastus[]; // 0..n + UuringuKommentaar?: string; + }; // 1..1 + UuringuTaitjaAsutuseJnr: number; +}; + +export type ResponseUuringuGrupp = { + UuringuGruppId: string; + UuringuGruppNimi: string; + Uuring: ResponseUuring | ResponseUuring[]; // 1..n +}; + +// 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 }; + Saadetis: { + Pais: { + Pakett: { + '#text': string; + '@_versioon': string; + }; + Saatja: string; + Saaja: string; + Aeg: string; + SaadetisId: string; + Email: string; + }; + Vastus: { + ValisTellimuseId: number; + Asutus: { + '@_tyyp': string; // TEOSTAJA + '@_jarjenumber': string; + AsutuseId: number; + AsutuseNimi: string; + AsutuseKood: string; + AllyksuseNimi?: string; + Telefon: number; + }[]; //@_tyyp = TELLIJA 1..1, @_tyyp = TEOSTAJA 1..n + Personal: { + '@_tyyp': string; + '@_jarjenumber': string; + PersonalOID: string; + PersonalKood: string; + PersonalPerekonnaNimi: string; + PersonalEesNimi: string; + Telefon: number; + }; + Patsient: { + IsikukoodiOID: string; + Isikukood: string; + PerekonnaNimi: string; + EesNimi: string; + SynniAeg: string; + SuguOID: string; + Sugu: string; + }; + Proov: Array<{ + ProovinouIdOID: string; + ProovinouId: string; + MaterjaliTyypOID: string; + MaterjaliTyyp: number; + MaterjaliNimi: string; + Ribakood: string; + Jarjenumber: number; + VotmisAeg: string; + SaabumisAeg: string; + }>; + TellimuseNumber: string; + TellimuseOlek: keyof typeof AnalysisOrderStatus; + UuringuGrupp?: ResponseUuringuGrupp | ResponseUuringuGrupp[]; + }; + }; +}; + +export const AnalysisOrderStatus: Record = { + 1: 'QUEUED', + 2: 'ON_HOLD', + 3: 'PROCESSING', + 4: 'COMPLETED', + 5: 'REJECTED', + 6: 'CANCELLED', +}; +export const NormStatus: Record = { + 1: 'NORMAL', + 2: 'WARNING', + 3: 'REQUIRES_ATTENTION', +}; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index c432b1d..3671308 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -346,28 +346,128 @@ export type Database = { } analysis_orders: { Row: { - analysis_group_ids: number[] | null + analysis_element_ids: number[] | null + analysis_ids: number[] | null created_at: string - id: string + id: number status: Database["public"]["Enums"]["analysis_order_status"] user_id: string } Insert: { - analysis_group_ids?: number[] | null + analysis_element_ids?: number[] | null + analysis_ids?: number[] | null created_at?: string - id: string + id?: number status: Database["public"]["Enums"]["analysis_order_status"] user_id: string } Update: { - analysis_group_ids?: number[] | null + analysis_element_ids?: number[] | null + analysis_ids?: number[] | null created_at?: string - id?: string + id?: number status?: Database["public"]["Enums"]["analysis_order_status"] user_id?: string } Relationships: [] } + analysis_response_elements: { + Row: { + analysis_element_original_id: string + analysis_response_id: number + created_at: string + id: number + norm_lower: number | null + norm_lower_included: boolean | null + norm_status: number | null + norm_upper: number | null + norm_upper_included: boolean | null + original_response_element: Json + response_time: string + response_value: Json + unit: string | null + updated_at: string | null + } + Insert: { + analysis_element_original_id: string + analysis_response_id: number + created_at?: string + id?: number + norm_lower?: number | null + norm_lower_included?: boolean | null + norm_status?: number | null + norm_upper?: number | null + norm_upper_included?: boolean | null + original_response_element: Json + response_time: string + response_value: Json + unit?: string | null + updated_at?: string | null + } + Update: { + analysis_element_original_id?: string + analysis_response_id?: number + created_at?: string + id?: number + norm_lower?: number | null + norm_lower_included?: boolean | null + norm_status?: number | null + norm_upper?: number | null + norm_upper_included?: boolean | null + original_response_element?: Json + response_time?: string + response_value?: Json + unit?: string | null + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "analysis_response_element_analysis_response_id_fkey" + columns: ["analysis_response_id"] + isOneToOne: false + referencedRelation: "analysis_responses" + referencedColumns: ["id"] + }, + ] + } + analysis_responses: { + Row: { + analysis_order_id: number + created_at: string + id: number + order_number: string + order_status: Database["public"]["Enums"]["analysis_order_status"] + updated_at: string | null + user_id: string + } + Insert: { + analysis_order_id: number + created_at?: string + id?: number + order_number: string + order_status: Database["public"]["Enums"]["analysis_order_status"] + updated_at?: string | null + user_id: string + } + Update: { + analysis_order_id?: number + created_at?: string + id?: number + order_number?: string + order_status?: Database["public"]["Enums"]["analysis_order_status"] + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "analysis_responses_analysis_order_id_fkey" + columns: ["analysis_order_id"] + isOneToOne: false + referencedRelation: "analysis_orders" + referencedColumns: ["id"] + }, + ] + } billing_customers: { Row: { account_id: string diff --git a/supabase/migrations/20250610092159_add_analysis_order_response_tables.sql b/supabase/migrations/20250610092159_add_analysis_order_response_tables.sql new file mode 100644 index 0000000..9873f1d --- /dev/null +++ b/supabase/migrations/20250610092159_add_analysis_order_response_tables.sql @@ -0,0 +1,129 @@ +create table "public"."analysis_response_elements" ( + "id" bigint generated by default as identity not null, + "analysis_response_id" bigint not null, + "analysis_element_original_id" text not null, + "unit" text, + "response_value" jsonb not null, + "response_time" timestamp with time zone not null, + "norm_upper" double precision, + "norm_upper_included" boolean, + "norm_lower" double precision, + "norm_lower_included" boolean, + "norm_status" smallint, + "original_response_element" jsonb not null, + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp with time zone default now() +); + + +alter table "public"."analysis_response_elements" enable row level security; + +create table "public"."analysis_responses" ( + "id" bigint generated by default as identity not null, + "analysis_order_id" bigint not null, + "order_number" text not null, + "order_status" analysis_order_status not null, + "user_id" uuid not null, + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp with time zone default now() +); + +alter table "public"."analysis_responses" enable row level security; + +CREATE UNIQUE INDEX analysis_response_element_pkey ON public.analysis_response_elements USING btree (id); + +CREATE UNIQUE INDEX analysis_response_pkey ON public.analysis_responses USING btree (id); + +CREATE UNIQUE INDEX analysis_responses_order_number_key ON public.analysis_responses USING btree (order_number); + +alter table "public"."analysis_response_elements" add constraint "analysis_response_element_pkey" PRIMARY KEY using index "analysis_response_element_pkey"; + +alter table "public"."analysis_responses" add constraint "analysis_response_pkey" PRIMARY KEY using index "analysis_response_pkey"; + +alter table "public"."analysis_response_elements" add constraint "analysis_response_element_analysis_response_id_fkey" FOREIGN KEY (analysis_response_id) REFERENCES analysis_responses(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."analysis_response_elements" validate constraint "analysis_response_element_analysis_response_id_fkey"; + +alter table "public"."analysis_responses" add constraint "analysis_response_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."analysis_responses" validate constraint "analysis_response_user_id_fkey"; + +alter table "public"."analysis_responses" add constraint "analysis_responses_order_number_key" UNIQUE using index "analysis_responses_order_number_key"; + +grant delete on table "public"."analysis_response_elements" to "service_role"; + +grant insert on table "public"."analysis_response_elements" to "service_role"; + +grant references on table "public"."analysis_response_elements" to "service_role"; + +grant select on table "public"."analysis_response_elements" to "service_role"; + +grant trigger on table "public"."analysis_response_elements" to "service_role"; + +grant truncate on table "public"."analysis_response_elements" to "service_role"; + +grant update on table "public"."analysis_response_elements" to "service_role"; + +grant delete on table "public"."analysis_responses" to "service_role"; + +grant insert on table "public"."analysis_responses" to "service_role"; + +grant references on table "public"."analysis_responses" to "service_role"; + +grant select on table "public"."analysis_responses" to "service_role"; + +grant trigger on table "public"."analysis_responses" to "service_role"; + +grant truncate on table "public"."analysis_responses" to "service_role"; + +grant update on table "public"."analysis_responses" to "service_role"; + +create policy "select_own" +on "public"."analysis_response_elements" +as permissive +for select +to authenticated +using ((( SELECT auth.uid() AS uid) IN ( SELECT analysis_responses.user_id + FROM analysis_responses + WHERE (analysis_responses.id = analysis_response_elements.analysis_response_id)))); + + +create policy "service_role_all" +on "public"."analysis_response_elements" +as permissive +for all +to service_role +using (true); + + +create policy "select_own" +on "public"."analysis_responses" +as permissive +for select +to authenticated +using ((( SELECT auth.uid() AS uid) = user_id)); + + +create policy "service_role_all" +on "public"."analysis_responses" +as permissive +for all +to service_role +using (true); + +create policy "service_role_all" +on "public"."analysis_orders" +as permissive +for all +to service_role +using (true); + + +alter table "public"."analysis_orders" add constraint "analysis_orders_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; +alter table "public"."analysis_orders" drop constraint "analysis_orders_id_fkey"; +alter table "public"."analysis_orders" drop column "id"; +alter table "public"."analysis_orders" add "id" bigint generated by default as identity not null; +CREATE UNIQUE INDEX analysis_orders_pkey ON public.analysis_orders USING btree (id); +alter table "public"."analysis_orders" add constraint "analysis_orders_pkey" PRIMARY KEY using index "analysis_orders_pkey"; +alter table "public"."analysis_responses" add constraint "analysis_responses_analysis_order_id_fkey" FOREIGN KEY (analysis_order_id) REFERENCES analysis_orders(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; +alter table "public"."analysis_responses" validate constraint "analysis_responses_analysis_order_id_fkey"; 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 2/2] 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: