diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index c0be7fb..9917845 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -1,9 +1,12 @@ +import { Suspense } from 'react'; + import { redirect } from 'next/navigation'; import { toTitleCase } from '@/lib/utils'; import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; import { PageBody, PageHeader } from '@kit/ui/page'; +import { Skeleton } from '@kit/ui/skeleton'; import { Trans } from '@kit/ui/trans'; import { createUserAnalysesApi } from '@kit/user-analyses/api'; @@ -12,6 +15,8 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import Dashboard from '../_components/dashboard'; import DashboardCards from '../_components/dashboard-cards'; +import Recommendations from '../_components/recommendations'; +import RecommendationsSkeleton from '../_components/recommendations-skeleton'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; export const generateMetadata = async () => { @@ -48,6 +53,12 @@ async function UserHomePage() { /> +

+ +

+ }> + +
); diff --git a/app/home/(user)/_components/recommendations-skeleton.tsx b/app/home/(user)/_components/recommendations-skeleton.tsx new file mode 100644 index 0000000..74cb352 --- /dev/null +++ b/app/home/(user)/_components/recommendations-skeleton.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import { InfoTooltip } from '@/packages/shared/src/components/ui/info-tooltip'; +import { HeartPulse } from 'lucide-react'; + +import { Button } from '@kit/ui/shadcn/button'; +import { + Card, + CardDescription, + CardFooter, + CardHeader, +} from '@kit/ui/shadcn/card'; +import { Skeleton } from '@kit/ui/skeleton'; + +import OrderAnalysesCards from './order-analyses-cards'; + +const RecommendationsSkeleton = () => { + const emptyData = [ + { + title: '1', + description: '', + subtitle: '', + variant: { id: '' }, + price: 1, + }, + { + title: '2', + description: '', + subtitle: '', + variant: { id: '' }, + price: 1, + }, + ]; + return ( +
+ {emptyData.map(({ title, description, subtitle }) => ( + + + +
+
+
+ + +
+
+ {title} + {description && ( + <> + {' '} + + {description} +
+ } + /> + + )} + + {subtitle && {subtitle}} +
+
+ +
+
+ ))} +
+ ); +}; + +export default RecommendationsSkeleton; diff --git a/app/home/(user)/_components/recommendations.tsx b/app/home/(user)/_components/recommendations.tsx new file mode 100644 index 0000000..71403ef --- /dev/null +++ b/app/home/(user)/_components/recommendations.tsx @@ -0,0 +1,30 @@ +'use server'; + +import React from 'react'; + +import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts'; + +import { loadAnalyses } from '../_lib/server/load-analyses'; +import { loadRecommendations } from '../_lib/server/load-recommendations'; +import OrderAnalysesCards from './order-analyses-cards'; + +export default async function Recommendations({ + account, +}: { + account: AccountWithParams; +}) { + const { analyses, countryCode } = await loadAnalyses(); + + const analysisRecommendations = await loadRecommendations(analyses, account); + const orderAnalyses = analyses.filter((analysis) => + analysisRecommendations.includes(analysis.title), + ); + + if (orderAnalyses.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/app/home/(user)/_lib/server/load-recommendations.ts b/app/home/(user)/_lib/server/load-recommendations.ts new file mode 100644 index 0000000..76e1426 --- /dev/null +++ b/app/home/(user)/_lib/server/load-recommendations.ts @@ -0,0 +1,128 @@ +import { cache } from 'react'; + +import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts'; +import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; +import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; +import { Database } from '@/packages/supabase/src/database.types'; +import OpenAI from 'openai'; + +import PersonalCode from '~/lib/utils'; + +import { OrderAnalysisCard } from '../../_components/order-analyses-cards'; + +export const loadRecommendations = cache(recommendationsLoader); + +type AnalysisResponses = + Database['medreport']['Functions']['get_latest_analysis_response_elements_for_current_user']['Returns']; + +const getLatestResponseTime = (items: AnalysisResponses) => { + if (!items?.length) return null; + + let latest = null; + for (const it of items) { + const d = new Date(it.response_time); + const t = d.getTime(); + if (!Number.isNaN(t) && (latest === null || t > latest.getTime())) { + latest = d; + } + } + return latest; +}; + +async function recommendationsLoader( + analyses: OrderAnalysisCard[], + account: AccountWithParams | null, +): Promise { + if (!process.env.OPENAI_API_KEY) { + return []; + } + if (!account?.personal_code) { + return []; + } + const supabaseClient = getSupabaseServerClient(); + const userAnalysesApi = createUserAnalysesApi(supabaseClient); + const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses(); + const analysesRecommendationsPromptId = + 'pmpt_68ca9c8bfa8c8193b27eadc6496c36440df449ece4f5a8dd'; + const latestResponseTime = getLatestResponseTime(analysisResponses); + const latestISO = latestResponseTime + ? new Date(latestResponseTime).toISOString() + : new Date('2025').toISOString(); + + const previouslyRecommended = await supabaseClient + .schema('medreport') + .from('ai_responses') + .select('*') + .eq('account_id', account.id) + .eq('prompt_id', analysesRecommendationsPromptId) + .eq('latest_data_change', latestISO); + + if (previouslyRecommended.data?.[0]?.response) { + return JSON.parse(previouslyRecommended.data[0].response as string) + .recommended; + } + + const openAIClient = new OpenAI(); + const { gender, age } = PersonalCode.parsePersonalCode(account.personal_code); + const weight = account.accountParams?.weight || 'unknown'; + + const formattedAnalysisResponses = analysisResponses.map( + ({ + analysis_name_lab, + response_value, + norm_upper, + norm_lower, + norm_status, + }) => ({ + name: analysis_name_lab, + value: response_value, + normUpper: norm_upper, + normLower: norm_lower, + normStatus: norm_status, + }), + ); + const formattedAnalyses = analyses.map(({ description, title }) => ({ + description, + title, + })); + + const response = await openAIClient.responses.create({ + store: false, + prompt: { + id: analysesRecommendationsPromptId, + variables: { + analyses: JSON.stringify(formattedAnalyses), + results: JSON.stringify(formattedAnalysisResponses), + gender: gender.value, + age: age.toString(), + weight: weight.toString(), + }, + }, + }); + + const json = JSON.parse(response.output_text); + + try { + await supabaseClient + .schema('medreport') + .from('ai_responses') + .insert({ + account_id: account.id, + prompt_name: 'Analysis Recommendations', + prompt_id: analysesRecommendationsPromptId, + input: JSON.stringify({ + analyses: formattedAnalyses, + results: formattedAnalysisResponses, + gender, + age, + weight, + }), + latest_data_change: latestISO, + response: response.output_text, + }); + } catch (error) { + console.error('Error saving AI response: ', error); + } + + return json.recommended; +} diff --git a/package.json b/package.json index cc21d18..ff29c46 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "next": "15.3.2", "next-sitemap": "^4.2.3", "next-themes": "0.4.6", + "openai": "^5.20.3", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.58.0", diff --git a/packages/features/user-analyses/src/server/api.ts b/packages/features/user-analyses/src/server/api.ts index 5693e32..0532886 100644 --- a/packages/features/user-analyses/src/server/api.ts +++ b/packages/features/user-analyses/src/server/api.ts @@ -425,6 +425,31 @@ class UserAnalysesApi { } return data; } + + async getAllUserAnalysisResponses(): Promise< + Database['medreport']['Functions']['get_latest_analysis_response_elements_for_current_user']['Returns'] + > { + const { + data: { user }, + } = await this.client.auth.getUser(); + + if (!user) { + return []; + } + + const { data, error } = await this.client + .schema('medreport') + .rpc('get_latest_analysis_response_elements_for_current_user', { + p_user_id: user.id, + }); + + if (error) { + console.error('Error fetching user analysis responses: ', error); + throw error; + } + + return data; + } } export function createUserAnalysesApi(client: SupabaseClient) { diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index f893f49..30db3f3 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -198,6 +198,7 @@ export type Database = { action: string changed_by: string created_at: string + extra_data: Json | null id: number } Insert: { @@ -205,6 +206,7 @@ export type Database = { action: string changed_by: string created_at?: string + extra_data?: Json | null id?: number } Update: { @@ -212,6 +214,7 @@ export type Database = { action?: string changed_by?: string created_at?: string + extra_data?: Json | null id?: number } Relationships: [] @@ -517,6 +520,61 @@ export type Database = { }, ] } + ai_responses: { + Row: { + account_id: string + created_at: string + id: string + input: Json + latest_data_change: string + prompt_id: string + prompt_name: string + response: Json + } + Insert: { + account_id: string + created_at?: string + id?: string + input: Json + latest_data_change: string + prompt_id: string + prompt_name: string + response: Json + } + Update: { + account_id?: string + created_at?: string + id?: string + input?: Json + latest_data_change?: string + prompt_id?: string + prompt_name?: string + response?: Json + } + Relationships: [ + { + foreignKeyName: "ai_responses_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "ai_responses_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "ai_responses_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } analyses: { Row: { analysis_id_oid: string @@ -687,9 +745,9 @@ export type Database = { original_response_element: Json response_time: string | null response_value: number | null - response_value_is_negative?: boolean | null - response_value_is_within_norm?: boolean | null - status: string + response_value_is_negative: boolean | null + response_value_is_within_norm: boolean | null + status: string | null unit: string | null updated_at: string | null } @@ -706,11 +764,11 @@ export type Database = { norm_upper?: number | null norm_upper_included?: boolean | null original_response_element: Json - response_time: string | null - response_value: number | null + response_time?: string | null + response_value?: number | null response_value_is_negative?: boolean | null response_value_is_within_norm?: boolean | null - status: string + status?: string | null unit?: string | null updated_at?: string | null } @@ -731,7 +789,7 @@ export type Database = { response_value?: number | null response_value_is_negative?: boolean | null response_value_is_within_norm?: boolean | null - status: string + status?: string | null unit?: string | null updated_at?: string | null } @@ -1159,7 +1217,7 @@ export type Database = { doctor_user_id: string | null id: number status: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at: string + updated_at: string | null updated_by: string | null user_id: string value: string | null @@ -1171,7 +1229,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id: string value?: string | null @@ -1183,7 +1241,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id?: string value?: string | null @@ -1268,27 +1326,45 @@ export type Database = { } medipost_actions: { Row: { - created_at: string - id: number action: string - xml: string + created_at: string | null has_analysis_results: boolean - medipost_external_order_id: string - medipost_private_message_id: string - medusa_order_id: string - response_xml: string has_error: boolean + id: string + medipost_external_order_id: string | null + medipost_private_message_id: string | null + medusa_order_id: string | null + response_xml: string | null + updated_at: string | null + xml: string | null } Insert: { action: string - xml: string - has_analysis_results: boolean - medipost_external_order_id: string - medipost_private_message_id: string - medusa_order_id: string - response_xml: string - has_error: boolean + created_at?: string | null + has_analysis_results?: boolean + has_error?: boolean + id?: string + medipost_external_order_id?: string | null + medipost_private_message_id?: string | null + medusa_order_id?: string | null + response_xml?: string | null + updated_at?: string | null + xml?: string | null } + Update: { + action?: string + created_at?: string | null + has_analysis_results?: boolean + has_error?: boolean + id?: string + medipost_external_order_id?: string | null + medipost_private_message_id?: string | null + medusa_order_id?: string | null + response_xml?: string | null + updated_at?: string | null + xml?: string | null + } + Relationships: [] } medreport_product_groups: { Row: { @@ -1957,6 +2033,25 @@ export type Database = { personal_code: string }[] } + get_latest_analysis_response_elements_for_current_user: { + Args: { p_user_id: string } + Returns: { + analysis_name: string + analysis_name_lab: string + norm_lower: number + norm_status: number + norm_upper: number + response_time: string + response_value: number + }[] + } + get_latest_medipost_dispatch_state_for_order: { + Args: { medusa_order_id: string } + Returns: { + action_date: string + has_success: boolean + }[] + } get_medipost_dispatch_tries: { Args: { p_medusa_order_id: string } Returns: number @@ -2049,9 +2144,9 @@ export type Database = { Args: { account_id: string; user_id: string } Returns: boolean } - medipost_retry_dispatch: { - Args: { order_id: string } - Returns: Json + order_has_medipost_dispatch_error: { + Args: { medusa_order_id: string } + Returns: boolean } revoke_nonce: { Args: { p_id: string; p_reason?: string } @@ -2078,16 +2173,26 @@ export type Database = { Returns: undefined } update_account: { - Args: { - p_city: string - p_has_consent_personal_data: boolean - p_last_name: string - p_name: string - p_personal_code: string - p_phone: string - p_uid: string - p_email: string - } + Args: + | { + p_city: string + p_email: string + p_has_consent_personal_data: boolean + p_last_name: string + p_name: string + p_personal_code: string + p_phone: string + p_uid: string + } + | { + p_city: string + p_has_consent_personal_data: boolean + p_last_name: string + p_name: string + p_personal_code: string + p_phone: string + p_uid: string + } Returns: undefined } update_analysis_order_status: { diff --git a/packages/ui/src/shadcn/skeleton.tsx b/packages/ui/src/shadcn/skeleton.tsx index 9f09b6c..5b0ac1e 100644 --- a/packages/ui/src/shadcn/skeleton.tsx +++ b/packages/ui/src/shadcn/skeleton.tsx @@ -2,13 +2,23 @@ import { cn } from '../lib/utils'; function Skeleton({ className, + children, ...props }: React.HTMLAttributes) { return (
+ > +
+ {children ?? } +
+ +
+
); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4837d26..e7411cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + openai: + specifier: ^5.20.3 + version: 5.20.3(ws@8.18.2)(zod@4.1.5) react: specifier: 19.1.0 version: 19.1.0 @@ -13284,6 +13287,18 @@ packages: } engines: { node: '>=6' } + openai@5.20.3: + resolution: {integrity: sha512-8V0KgAcPFppDIP8uMBOkhRrhDBuxNQYQxb9IovP4NN4VyaYGISAzYexyYYuAwVul2HB75Wpib0xDboYJqRMNow==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + opener@1.5.2: resolution: { @@ -27604,6 +27619,11 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openai@5.20.3(ws@8.18.2)(zod@4.1.5): + optionalDependencies: + ws: 8.18.2 + zod: 4.1.5 + opener@1.5.2: {} optionator@0.9.4: diff --git a/public/locales/en/dashboard.json b/public/locales/en/dashboard.json index 5598aba..c92f438 100644 --- a/public/locales/en/dashboard.json +++ b/public/locales/en/dashboard.json @@ -18,5 +18,8 @@ "title": "Order analysis", "description": "Select an analysis to get started" } + }, + "recommendations": { + "title": "Medreport recommends" } } diff --git a/public/locales/et/dashboard.json b/public/locales/et/dashboard.json index 916038d..d18c230 100644 --- a/public/locales/et/dashboard.json +++ b/public/locales/et/dashboard.json @@ -18,5 +18,8 @@ "title": "Telli analüüs", "description": "Telli endale sobiv analüüs" } + }, + "recommendations": { + "title": "Medreport soovitab teile" } } diff --git a/public/locales/ru/dashboard.json b/public/locales/ru/dashboard.json index 1e5685b..9c0a7d6 100644 --- a/public/locales/ru/dashboard.json +++ b/public/locales/ru/dashboard.json @@ -18,5 +18,8 @@ "title": "Заказать анализ", "description": "Закажите подходящий для вас анализ" } + }, + "recommendations": { + "title": "Medreport recommends" } } diff --git a/supabase/migrations/20250920184500_update_ai_responses.sql b/supabase/migrations/20250920184500_update_ai_responses.sql new file mode 100644 index 0000000..0752227 --- /dev/null +++ b/supabase/migrations/20250920184500_update_ai_responses.sql @@ -0,0 +1,68 @@ +ALTER TABLE medreport.ai_responses ENABLE ROW LEVEL SECURITY; + +create policy "ai_responses_select" +on medreport.ai_responses +for select +to authenticated +using (account_id = auth.uid()); + +create policy "ai_responses_insert" +on medreport.ai_responses +for insert +to authenticated +with check (account_id = auth.uid()); + + +grant select, insert, update, delete on table medreport.ai_responses to authenticated; + +ALTER TABLE medreport.ai_responses +ALTER COLUMN prompt_id TYPE text +USING prompt_name::text; + +ALTER TABLE medreport.ai_responses +ALTER COLUMN prompt_name TYPE text +USING prompt_name::text; + +ALTER TABLE medreport.ai_responses +ADD CONSTRAINT ai_responses_id_pkey PRIMARY KEY (id); + +create or replace function medreport.get_latest_analysis_response_elements_for_current_user(p_user_id uuid) +returns table ( + analysis_name medreport.analysis_response_elements.analysis_name%type, + response_time medreport.analysis_response_elements.response_time%type, + norm_upper medreport.analysis_response_elements.norm_upper%type, + norm_lower medreport.analysis_response_elements.norm_lower%type, + norm_status medreport.analysis_response_elements.norm_status%type, + response_value medreport.analysis_response_elements.response_value%type, + analysis_name_lab medreport.analysis_elements.analysis_name_lab%type +) +language sql +as $$ + WITH ranked AS ( + SELECT + are.analysis_name, + are.response_time, + are.norm_upper, + are.norm_lower, + are.norm_status, + are.response_value, + ae.analysis_name_lab, + ROW_NUMBER() OVER ( + PARTITION BY are.analysis_name + ORDER BY are.response_time DESC, are.id DESC + ) AS rn + FROM medreport.analysis_responses ar + JOIN medreport.analysis_response_elements are + ON are.analysis_response_id = ar.id + JOIN medreport.analysis_elements ae + ON are.analysis_element_original_id = ae.analysis_id_original + WHERE ar.user_id = '9ec20b5a-a939-4e5d-9148-6733e36047f3' -- 👈 your user id + AND ar.order_status = 'COMPLETED' + ) + SELECT analysis_name, response_time, norm_upper, norm_lower, norm_status, response_value, analysis_name_lab + FROM ranked + WHERE rn = 1 + ORDER BY analysis_name; +$$; + +grant execute on function medreport.get_latest_analysis_response_elements_for_current_user(uuid) to authenticated, service_role;