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] 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";