From 1b29cb222b9acfa887b5ce8b0fbc0157ede0a3d7 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:56:55 +0300 Subject: [PATCH 01/26] prefer pathsConfig urls --- app/(marketing)/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 4f39285..1034428 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { MedReportLogo } from '@kit/shared/components/med-report-logo'; +import { pathsConfig } from '@kit/shared/config'; import { ArrowRightIcon } from 'lucide-react'; import { CtaButton, Hero } from '@kit/ui/marketing'; @@ -32,7 +33,7 @@ function MainCallToActionButton() { return (
- + From a89d8d3153c1f0597211ead23c1fdd472e859d94 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:57:18 +0300 Subject: [PATCH 02/26] fix whitespace --- app/auth/confirm/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts index db0ef3f..5586c79 100644 --- a/app/auth/confirm/route.ts +++ b/app/auth/confirm/route.ts @@ -5,7 +5,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { pathsConfig } from '@kit/shared/config'; - export async function GET(request: NextRequest) { const service = createAuthCallbackService(getSupabaseServerClient()); From ab2176bc6915180e08ebb4dc6738f3602f7c6125 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:57:28 +0300 Subject: [PATCH 03/26] fix analyses loading --- app/home/(user)/_components/order-analyses-cards.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx index 5ef16ea..5cb4d31 100644 --- a/app/home/(user)/_components/order-analyses-cards.tsx +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -94,7 +94,7 @@ export default function OrderAnalysesCards({ className="px-2 text-black" onClick={() => handleSelect(variant.id)} > - {variantAddingToCart ? : } + {variantAddingToCart === variant.id ? : }
)} From a9612ad992975e6bd202078eb8e30bf7ddc7084f Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:57:38 +0300 Subject: [PATCH 04/26] remove useless await --- app/home/(user)/(dashboard)/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 7eba847..dc52fc1 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -27,7 +27,7 @@ async function UserHomePage() { const client = getSupabaseServerClient(); const account = await loadCurrentUserAccount(); - const api = await createAccountsApi(client); + const api = createAccountsApi(client); const bmiThresholds = await api.fetchBmiThresholds(); if (!account) { From 6495d1c4a3989db04a9a03d1bd364a08baa38e6b Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:58:02 +0300 Subject: [PATCH 05/26] fix toTitleCase --- lib/utils.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/utils.ts b/lib/utils.ts index 3448c2b..392a129 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -15,11 +15,12 @@ export function toArray(input?: T | T[] | null): T[] { } export function toTitleCase(str?: string) { - if (!str) return ''; - return str.replace( - /\w\S*/g, - (text: string) => - text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(), + return ( + str + ?.toLowerCase() + .replace(/[^-'’\s]+/g, (match) => + match.replace(/^./, (first) => first.toUpperCase()), + ) ?? "" ); } From 96eea95fb9fc7dfb20a03049f4212ff36957e36a Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:58:34 +0300 Subject: [PATCH 06/26] fix password signup not redirecting to update-account view --- .../features/auth/src/components/sign-up-methods-container.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx index c10054d..8b9661c 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -44,7 +44,7 @@ export function SignUpMethodsContainer(props: { emailRedirectTo={props.paths.callback} defaultValues={defaultValues} displayTermsCheckbox={props.displayTermsCheckbox} - onSignUp={() => redirect(redirectUrl)} + //onSignUp={() => redirect(redirectUrl)} /> From 7815a1c0118320b14d6f1b7cd154ad9e8e1da9f4 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:59:17 +0300 Subject: [PATCH 07/26] add phone number validation to update account form --- .../_lib/schemas/update-account.schema.ts | 16 +++++- package.json | 1 + pnpm-lock.yaml | 57 +++++++------------ public/locales/en/common.json | 3 + public/locales/et/common.json | 3 + 5 files changed, 42 insertions(+), 38 deletions(-) diff --git a/app/auth/update-account/_lib/schemas/update-account.schema.ts b/app/auth/update-account/_lib/schemas/update-account.schema.ts index e9213b1..a3e4f29 100644 --- a/app/auth/update-account/_lib/schemas/update-account.schema.ts +++ b/app/auth/update-account/_lib/schemas/update-account.schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import parsePhoneNumber from 'libphonenumber-js/min'; export const UpdateAccountSchema = z.object({ firstName: z @@ -23,7 +24,20 @@ export const UpdateAccountSchema = z.object({ .string({ error: 'Phone number is required', }) - .nonempty(), + .nonempty() + .refine( + (phone) => { + try { + const phoneNumber = parsePhoneNumber(phone); + return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE'; + } catch { + return false; + } + }, + { + message: 'common:formFieldError.invalidPhoneNumber', + } + ), city: z.string().optional(), weight: z .number({ diff --git a/package.json b/package.json index e449db7..18d2a21 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "fast-xml-parser": "^5.2.5", "isikukood": "3.1.7", "jsonwebtoken": "9.0.2", + "libphonenumber-js": "^1.12.15", "lodash": "^4.17.21", "lucide-react": "^0.510.0", "next": "15.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7b691e..b4634f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: jsonwebtoken: specifier: 9.0.2 version: 9.0.2 + libphonenumber-js: + specifier: ^1.12.15 + version: 1.12.15 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -475,10 +478,10 @@ importers: dependencies: '@keystatic/core': specifier: 0.5.47 - version: 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@keystatic/next': specifier: ^5.0.4 - version: 5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@markdoc/markdoc': specifier: ^0.5.1 version: 0.5.4(@types/react@19.1.4)(react@19.1.0) @@ -1269,7 +1272,7 @@ importers: dependencies: '@sentry/nextjs': specifier: ^9.19.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3) import-in-the-middle: specifier: 1.13.2 version: 1.13.2 @@ -8174,6 +8177,9 @@ packages: engines: {node: '>=16'} hasBin: true + libphonenumber-js@1.12.15: + resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -11455,7 +11461,7 @@ snapshots: '@juggle/resize-observer@3.4.0': {} - '@keystar/ui@0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@keystar/ui@0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.6 '@emotion/css': 11.13.5 @@ -11548,18 +11554,18 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - supports-color - '@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.6 '@braintree/sanitize-url': 6.0.4 '@emotion/weak-memoize': 0.3.1 '@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@internationalized/string': 3.2.7 - '@keystar/ui': 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@keystar/ui': 0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0) '@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -11630,13 +11636,13 @@ snapshots: - next - supports-color - '@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.6 - '@keystatic/core': 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@keystatic/core': 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/react': 19.1.4 chokidar: 3.6.0 - next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) server-only: 0.0.1 @@ -17230,7 +17236,7 @@ snapshots: '@sentry/core@9.46.0': {} - '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)': + '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.34.0 @@ -17243,7 +17249,7 @@ snapshots: '@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) '@sentry/webpack-plugin': 3.5.0(webpack@5.101.3) chalk: 3.0.0 - next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) resolve: 1.22.8 rollup: 4.35.0 stacktrace-parser: 0.1.11 @@ -20630,6 +20636,8 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + libphonenumber-js@1.12.15: {} + lightningcss-darwin-arm64@1.30.1: optional: true @@ -21265,31 +21273,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - '@next/env': 15.5.2 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001723 - postcss: 8.4.31 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@19.1.0) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.2 - '@next/swc-darwin-x64': 15.5.2 - '@next/swc-linux-arm64-gnu': 15.5.2 - '@next/swc-linux-arm64-musl': 15.5.2 - '@next/swc-linux-x64-gnu': 15.5.2 - '@next/swc-linux-x64-musl': 15.5.2 - '@next/swc-win32-arm64-msvc': 15.5.2 - '@next/swc-win32-x64-msvc': 15.5.2 - '@opentelemetry/api': 1.9.0 - babel-plugin-react-compiler: 19.1.0-rc.2 - sharp: 0.34.3 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - no-case@3.0.4: dependencies: lower-case: 2.0.2 diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ed8d175..b26211f 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -128,6 +128,9 @@ "amount": "Amount", "selectDate": "Select date" }, + "formFieldError": { + "invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)" + }, "wallet": { "balance": "Your MedReport account balance", "expiredAt": "Valid until {{expiredAt}}" diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 3a8f55c..792cc3a 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -128,6 +128,9 @@ "amount": "Summa", "selectDate": "Vali kuupäev" }, + "formFieldError": { + "invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)" + }, "wallet": { "balance": "Sinu MedReporti konto saldo", "expiredAt": "Kehtiv kuni {{expiredAt}}" From f01829de9690db2a79e18a8b09df0f2a85794e7c Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 01:02:10 +0300 Subject: [PATCH 08/26] update keycloak signup / login --- .../site-header-account-section.tsx | 17 +- app/auth/callback/route.ts | 55 ++++- .../sign-in/components/PasswordOption.tsx | 54 +++++ .../components/SignInPageClientRedirect.tsx | 37 ++++ app/auth/sign-in/page.tsx | 54 +---- app/auth/sign-up/page.tsx | 5 + .../_lib/server/update-account.ts | 14 +- .../(user)/_lib/server/load-user-account.ts | 8 +- lib/actions/sign-out.tsx | 21 +- .../components/personal-account-dropdown.tsx | 18 +- packages/features/accounts/src/server/api.ts | 35 ++++ .../auth/src/components/oauth-providers.tsx | 15 +- .../components/sign-in-methods-container.tsx | 3 + .../components/sign-up-methods-container.tsx | 3 + .../src/lib/data/customer.ts | 157 ++++++++------ .../account/components/account-nav/index.tsx | 4 +- packages/shared/src/config/auth.config.ts | 2 +- .../supabase/src/auth-callback.service.ts | 191 ++++++++++-------- .../supabase/src/clients/browser-client.ts | 8 +- .../supabase/src/clients/middleware-client.ts | 5 + .../supabase/src/clients/server-client.ts | 5 + .../hooks/use-sign-in-with-email-password.ts | 1 + .../src/hooks/use-sign-in-with-provider.ts | 8 +- packages/supabase/src/hooks/use-sign-out.ts | 19 +- .../hooks/use-sign-up-with-email-password.ts | 1 + 25 files changed, 501 insertions(+), 239 deletions(-) create mode 100644 app/auth/sign-in/components/PasswordOption.tsx create mode 100644 app/auth/sign-in/components/SignInPageClientRedirect.tsx diff --git a/app/(marketing)/_components/site-header-account-section.tsx b/app/(marketing)/_components/site-header-account-section.tsx index c4c388f..ff7ad03 100644 --- a/app/(marketing)/_components/site-header-account-section.tsx +++ b/app/(marketing)/_components/site-header-account-section.tsx @@ -13,10 +13,7 @@ import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; -import { featureFlagsConfig } from '@kit/shared/config'; - -import { pathsConfig } from '@kit/shared/config'; - +import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config'; const ModeToggle = dynamic(() => import('@kit/ui/mode-toggle').then((mod) => ({ @@ -75,11 +72,13 @@ function AuthButtons() { - + {authConfig.providers.password && ( + + )} ); diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 0786a08..b5ce0c0 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -1,19 +1,62 @@ import { redirect } from 'next/navigation'; import type { NextRequest } from 'next/server'; -import { createAuthCallbackService } from '@kit/supabase/auth'; +import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { pathsConfig } from '@kit/shared/config'; +import { createAccountsApi } from '@/packages/features/accounts/src/server/api'; +const ERROR_PATH = '/auth/callback/error'; + +const redirectOnError = (searchParams?: string) => { + return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`); +} export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const error = searchParams.get('error'); + if (error) { + const { searchParams } = getErrorURLParameters({ error }); + return redirectOnError(searchParams); + } + + const authCode = searchParams.get('code'); + if (!authCode) { + return redirectOnError(); + } + + let redirectPath = searchParams.get('next') || pathsConfig.app.home; + // if we have an invite token, we redirect to the join team page + // instead of the default next url. This is because the user is trying + // to join a team and we want to make sure they are redirected to the + // correct page. + const inviteToken = searchParams.get('invite_token'); + if (inviteToken) { + const urlParams = new URLSearchParams({ + invite_token: inviteToken, + email: searchParams.get('email') ?? '', + }); + + redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`; + } + const service = createAuthCallbackService(getSupabaseServerClient()); + const oauthResult = await service.exchangeCodeForSession(authCode); + if (!("isSuccess" in oauthResult)) { + return redirectOnError(oauthResult.searchParams); + } - const { nextPath } = await service.exchangeCodeForSession(request, { - joinTeamPath: pathsConfig.app.joinTeam, - redirectPath: pathsConfig.app.home, - }); + const api = createAccountsApi(getSupabaseServerClient()); - return redirect(nextPath); + const account = await api.getPersonalAccountByUserId( + oauthResult.user.id, + ); + + if (!account.email || !account.name || !account.last_name) { + return redirect(pathsConfig.auth.updateAccount); + } + + return redirect(redirectPath); } diff --git a/app/auth/sign-in/components/PasswordOption.tsx b/app/auth/sign-in/components/PasswordOption.tsx new file mode 100644 index 0000000..5ef56a4 --- /dev/null +++ b/app/auth/sign-in/components/PasswordOption.tsx @@ -0,0 +1,54 @@ +import Link from 'next/link'; + +import { SignInMethodsContainer } from '@kit/auth/sign-in'; +import { authConfig, pathsConfig } from '@kit/shared/config'; +import { Button } from '@kit/ui/button'; +import { Heading } from '@kit/ui/heading'; +import { Trans } from '@kit/ui/trans'; + +export default function PasswordOption({ + inviteToken, + returnPath, +}: { + inviteToken?: string; + returnPath?: string; +}) { + const signUpPath = + pathsConfig.auth.signUp + + (inviteToken ? `?invite_token=${inviteToken}` : ''); + + const paths = { + callback: pathsConfig.auth.callback, + returnPath: returnPath ?? pathsConfig.app.home, + joinTeam: pathsConfig.app.joinTeam, + updateAccount: pathsConfig.auth.updateAccount, + }; + + return ( + <> +
+ + + + +

+ +

+
+ + + +
+ +
+ + ); +} diff --git a/app/auth/sign-in/components/SignInPageClientRedirect.tsx b/app/auth/sign-in/components/SignInPageClientRedirect.tsx new file mode 100644 index 0000000..2e79df4 --- /dev/null +++ b/app/auth/sign-in/components/SignInPageClientRedirect.tsx @@ -0,0 +1,37 @@ +'use client'; + +import Loading from '@/app/home/loading'; +import { useEffect } from 'react'; +import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client'; +import { useRouter } from 'next/navigation'; + +export function SignInPageClientRedirect() { + const router = useRouter(); + + useEffect(() => { + async function signIn() { + const { data, error } = await getSupabaseBrowserClient() + .auth + .signInWithOAuth({ + provider: 'keycloak', + options: { + redirectTo: `${window.location.origin}/auth/callback`, + queryParams: { + prompt: 'login', + }, + } + }); + + if (error) { + console.error('OAuth error', error); + router.push('/'); + } else if (data.url) { + router.push(data.url); + } + } + + signIn(); + }, [router]); + + return ; +} diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index 3728b38..a82d4a7 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -1,14 +1,9 @@ -import Link from 'next/link'; - - -import { SignInMethodsContainer } from '@kit/auth/sign-in'; -import { authConfig, pathsConfig } from '@kit/shared/config'; -import { Button } from '@kit/ui/button'; -import { Heading } from '@kit/ui/heading'; -import { Trans } from '@kit/ui/trans'; +import { pathsConfig, authConfig } from '@kit/shared/config'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; +import { SignInPageClientRedirect } from './components/SignInPageClientRedirect'; +import PasswordOption from './components/PasswordOption'; interface SignInPageProps { searchParams: Promise<{ @@ -26,47 +21,14 @@ export const generateMetadata = async () => { }; async function SignInPage({ searchParams }: SignInPageProps) { - const { invite_token: inviteToken, next = pathsConfig.app.home } = + const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } = await searchParams; - const signUpPath = - pathsConfig.auth.signUp + - (inviteToken ? `?invite_token=${inviteToken}` : ''); + if (authConfig.providers.password) { + return ; + } - const paths = { - callback: pathsConfig.auth.callback, - returnPath: next ?? pathsConfig.app.home, - joinTeam: pathsConfig.app.joinTeam, - updateAccount: pathsConfig.auth.updateAccount, - }; - - return ( - <> -
- - - - -

- -

-
- - - -
- -
- - ); + return ; } export default withI18n(SignInPage); diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 5c0a4e2..0394078 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { redirect } from 'next/navigation'; import { SignUpMethodsContainer } from '@kit/auth/sign-up'; import { authConfig, pathsConfig } from '@kit/shared/config'; @@ -37,6 +38,10 @@ async function SignUpPage({ searchParams }: Props) { pathsConfig.auth.signIn + (inviteToken ? `?invite_token=${inviteToken}` : ''); + if (!authConfig.providers.password) { + return redirect('/'); + } + return ( <>
diff --git a/app/auth/update-account/_lib/server/update-account.ts b/app/auth/update-account/_lib/server/update-account.ts index e2fcd0f..ff9a80d 100644 --- a/app/auth/update-account/_lib/server/update-account.ts +++ b/app/auth/update-account/_lib/server/update-account.ts @@ -28,11 +28,15 @@ export const onUpdateAccount = enhanceAction( console.warn('On update account error: ', err); } - await updateCustomer({ - first_name: params.firstName, - last_name: params.lastName, - phone: params.phone, - }); + try { + await updateCustomer({ + first_name: params.firstName, + last_name: params.lastName, + phone: params.phone, + }); + } catch (e) { + console.error("Failed to update Medusa customer", e); + } const hasUnseenMembershipConfirmation = await api.hasUnseenMembershipConfirmation(); diff --git a/app/home/(user)/_lib/server/load-user-account.ts b/app/home/(user)/_lib/server/load-user-account.ts index 471def2..1b324ea 100644 --- a/app/home/(user)/_lib/server/load-user-account.ts +++ b/app/home/(user)/_lib/server/load-user-account.ts @@ -16,14 +16,14 @@ export const loadUserAccount = cache(accountLoader); export async function loadCurrentUserAccount() { const user = await requireUserInServerComponent(); - return user?.identities?.[0]?.id - ? await loadUserAccount(user?.identities?.[0]?.id) + return user?.id + ? await loadUserAccount(user.id) : null; } -async function accountLoader(accountId: string) { +async function accountLoader(userId: string) { const client = getSupabaseServerClient(); const api = createAccountsApi(client); - return api.getAccount(accountId); + return api.getPersonalAccountByUserId(userId); } diff --git a/lib/actions/sign-out.tsx b/lib/actions/sign-out.tsx index a168684..21bf5c0 100644 --- a/lib/actions/sign-out.tsx +++ b/lib/actions/sign-out.tsx @@ -3,9 +3,26 @@ import { redirect } from 'next/navigation'; import { createClient } from '@/utils/supabase/server'; +import { medusaLogout } from '@lib/data/customer'; export const signOutAction = async () => { - const supabase = await createClient(); - await supabase.auth.signOut(); + const client = await createClient(); + + try { + try { + await medusaLogout(); + } catch (medusaError) { + console.warn('Medusa logout failed or not available:', medusaError); + } + + const { error } = await client.auth.signOut(); + if (error) { + throw error; + } + } catch (error) { + console.error('Logout error:', error); + throw error; + } + return redirect('/'); }; diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx index 2a77099..b88eea9 100644 --- a/packages/features/accounts/src/components/personal-account-dropdown.tsx +++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx @@ -79,15 +79,9 @@ export function PersonalAccountDropdown({ }) { const { data: personalAccountData } = usePersonalAccountData(user.id); - const signedInAsLabel = useMemo(() => { - const email = user?.email ?? undefined; - const phone = user?.phone ?? undefined; - - return email ?? phone; - }, [user]); - - const displayName = - personalAccountData?.name ?? account?.name ?? user?.email ?? ''; + const { name, last_name } = personalAccountData ?? {}; + const firstNameLabel = toTitleCase(name) ?? '-'; + const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-'; const hasTotpFactor = useMemo(() => { const factors = user?.factors ?? []; @@ -128,7 +122,7 @@ export function PersonalAccountDropdown({ @@ -142,7 +136,7 @@ export function PersonalAccountDropdown({ data-test={'account-dropdown-display-name'} className={'truncate text-sm'} > - {toTitleCase(displayName)} + {firstNameLabel}
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
- {signedInAsLabel} + {fullNameLabel}
diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts index 4c9e467..f28a490 100644 --- a/packages/features/accounts/src/server/api.ts +++ b/packages/features/accounts/src/server/api.ts @@ -48,6 +48,41 @@ class AccountsApi { return data; } + /** + * @name getPersonalAccountByUserId + * @description Get the personal account data for the given user ID. + * @param userId + */ + async getPersonalAccountByUserId(userId: string): Promise { + const { data, error } = await this.client + .schema('medreport') + .from('accounts') + .select( + '*, accountParams: account_params (weight, height, isSmoker:is_smoker)', + ) + .eq('primary_owner_user_id', userId) + .eq('is_personal_account', true) + .single(); + + if (error) { + throw error; + } + + const { personal_code, ...rest } = data; + return { + ...rest, + personal_code: (() => { + if (!personal_code) { + return null; + } + if (personal_code.toLowerCase().startsWith('ee')) { + return personal_code.substring(2); + } + return personal_code; + })(), + }; + } + /** * @name getAccountWorkspace * @description Get the account workspace data. diff --git a/packages/features/auth/src/components/oauth-providers.tsx b/packages/features/auth/src/components/oauth-providers.tsx index 454b552..11dcc94 100644 --- a/packages/features/auth/src/components/oauth-providers.tsx +++ b/packages/features/auth/src/components/oauth-providers.tsx @@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button'; * @see https://supabase.com/docs/guides/auth/social-login */ const OAUTH_SCOPES: Partial> = { - azure: 'email', - keycloak: 'openid', + // azure: 'email', + // keycloak: 'openid', // add your OAuth providers here }; @@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{ queryParams.set('invite_token', props.inviteToken); } - const redirectPath = [ - props.paths.callback, - queryParams.toString(), - ].join('?'); + // signicat/keycloak will not allow redirect-uri with changing query params + const INCLUDE_QUERY_PARAMS = false as boolean; + + const redirectPath = INCLUDE_QUERY_PARAMS + ? [props.paths.callback, queryParams.toString()].join('?') + : props.paths.callback; const redirectTo = [origin, redirectPath].join(''); const scopes = OAUTH_SCOPES[provider] ?? undefined; @@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{ redirectTo, queryParams: props.queryParams, scopes, + // skipBrowserRedirect: false, }, } satisfies SignInWithOAuthCredentials; diff --git a/packages/features/auth/src/components/sign-in-methods-container.tsx b/packages/features/auth/src/components/sign-in-methods-container.tsx index 83bda02..344c040 100644 --- a/packages/features/auth/src/components/sign-in-methods-container.tsx +++ b/packages/features/auth/src/components/sign-in-methods-container.tsx @@ -108,6 +108,9 @@ export function SignInMethodsContainer(props: { callback: props.paths.callback, returnPath: props.paths.returnPath, }} + queryParams={{ + prompt: 'login', + }} /> diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx index 8b9661c..aadbfb5 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -79,6 +79,9 @@ export function SignUpMethodsContainer(props: { callback: props.paths.callback, returnPath: props.paths.appHome, }} + queryParams={{ + prompt: 'login', + }} /> diff --git a/packages/features/medusa-storefront/src/lib/data/customer.ts b/packages/features/medusa-storefront/src/lib/data/customer.ts index bf56d6e..a33a33a 100644 --- a/packages/features/medusa-storefront/src/lib/data/customer.ts +++ b/packages/features/medusa-storefront/src/lib/data/customer.ts @@ -4,7 +4,6 @@ import { sdk } from "@lib/config" import medusaError from "@lib/util/medusa-error" import { HttpTypes } from "@medusajs/types" import { revalidateTag } from "next/cache" -import { redirect } from "next/navigation" import { getAuthHeaders, getCacheOptions, @@ -127,7 +126,7 @@ export async function login(_currentState: unknown, formData: FormData) { } } -export async function signout(countryCode?: string, shouldRedirect = true) { +export async function medusaLogout(countryCode = 'ee') { await sdk.auth.logout() await removeAuthToken() @@ -139,10 +138,6 @@ export async function signout(countryCode?: string, shouldRedirect = true) { const cartCacheTag = await getCacheTag("carts") revalidateTag(cartCacheTag) - - if (shouldRedirect) { - redirect(`/${countryCode!}/account`) - } } export async function transferCart() { @@ -262,72 +257,110 @@ export const updateCustomerAddress = async ( }) } -export async function medusaLoginOrRegister(credentials: { - email: string - password?: string -}) { - const { email, password } = credentials; +async function medusaLogin(email: string, password: string) { + const token = await sdk.auth.login("customer", "emailpass", { email, password }); + await setAuthToken(token as string); try { - const token = await sdk.auth.login("customer", "emailpass", { - email, - password, + await transferCart(); + } catch (e) { + console.error("Failed to transfer cart", e); + } + + const customer = await retrieveCustomer(); + if (!customer) { + throw new Error("Customer not found for active session"); + } + + return customer.id; +} + +async function medusaRegister({ + email, + password, + name, + lastName, +}: { + email: string; + password: string; + name: string | undefined; + lastName: string | undefined; +}) { + console.info(`Creating new Medusa account for Keycloak user with email=${email}`); + + const registerToken = await sdk.auth.register("customer", "emailpass", { email, password }); + await setAuthToken(registerToken); + + console.info(`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`); + await sdk.store.customer.create( + { email, first_name: name, last_name: lastName }, + {}, + { + ...(await getAuthHeaders()), }); - await setAuthToken(token as string); +} - try { - await transferCart(); - } catch (e) { - console.error("Failed to transfer cart", e); +export async function medusaLoginOrRegister(credentials: { + email: string + supabaseUserId?: string + name?: string, + lastName?: string, +} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) { + const { email, supabaseUserId, name, lastName } = credentials; + + + const password = await (async () => { + if (credentials.isDevPasswordLogin) { + return credentials.password; } - const customerCacheTag = await getCacheTag("customers"); - revalidateTag(customerCacheTag); + return generateDeterministicPassword(email, supabaseUserId); + })(); + + try { + return await medusaLogin(email, password); + } catch (loginError) { + console.error("Failed to login customer, attempting to register", loginError); - const customer = await retrieveCustomer(); - if (!customer) { - throw new Error("Customer not found"); - } - return customer.id; - } catch (error) { - console.error("Failed to login customer, attempting to register", error); try { - const registerToken = await sdk.auth.register("customer", "emailpass", { - email: email, - password: password, - }) - - await setAuthToken(registerToken as string); - - const headers = { - ...(await getAuthHeaders()), - }; - - await sdk.store.customer.create({ email }, {}, headers); - - const loginToken = await sdk.auth.login("customer", "emailpass", { - email, - password, - }); - - await setAuthToken(loginToken as string); - - const customerCacheTag = await getCacheTag("customers"); - revalidateTag(customerCacheTag); - - try { - await transferCart(); - } catch (e) { - console.error("Failed to transfer cart", e); - } - - const customer = await retrieveCustomer(); - if (!customer) { - throw new Error("Customer not found"); - } - return customer.id; + await medusaRegister({ email, password, name, lastName }); + return await medusaLogin(email, password); } catch (registerError) { + console.error("Failed to create Medusa account for user with email=${email}", registerError); throw medusaError(registerError); } } } + +/** + * Generate a deterministic password based on user identifier + * This ensures the same user always gets the same password for Medusa + */ +async function generateDeterministicPassword(email: string, userId?: string): Promise { + // Use the user ID or email as the base for deterministic generation + const baseString = userId || email; + const secret = process.env.MEDUSA_PASSWORD_SECRET!; + + // Create a deterministic password using HMAC + const encoder = new TextEncoder(); + const keyData = encoder.encode(secret); + const messageData = encoder.encode(baseString); + + // Import key for HMAC + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + // Generate HMAC + const signature = await crypto.subtle.sign('HMAC', key, messageData); + // Convert to base64 and make it a valid password + const hashArray = Array.from(new Uint8Array(signature)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + // Take first 24 characters and add some complexity + const basePassword = hashHex.substring(0, 24); + // Add some required complexity for Medusa (uppercase, lowercase, numbers, symbols) + return `Mk${basePassword}9!`; +} diff --git a/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx b/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx index 61dd0c2..338dd22 100644 --- a/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx +++ b/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx @@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin" import Package from "@modules/common/icons/package" import LocalizedClientLink from "@modules/common/components/localized-client-link" import { HttpTypes } from "@medusajs/types" -import { signout } from "@lib/data/customer" +import { medusaLogout } from "@lib/data/customer" const AccountNav = ({ customer, @@ -21,7 +21,7 @@ const AccountNav = ({ const { countryCode } = useParams() as { countryCode: string } const handleLogout = async () => { - await signout(countryCode) + await medusaLogout(countryCode) } return ( diff --git a/packages/shared/src/config/auth.config.ts b/packages/shared/src/config/auth.config.ts index 9e73291..ab460ee 100644 --- a/packages/shared/src/config/auth.config.ts +++ b/packages/shared/src/config/auth.config.ts @@ -32,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({ providers: { password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true', magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true', - oAuth: ['google'], + oAuth: ['keycloak'], }, } satisfies z.infer); diff --git a/packages/supabase/src/auth-callback.service.ts b/packages/supabase/src/auth-callback.service.ts index 1190bc2..c1d8126 100644 --- a/packages/supabase/src/auth-callback.service.ts +++ b/packages/supabase/src/auth-callback.service.ts @@ -4,6 +4,7 @@ import { AuthError, type EmailOtpType, SupabaseClient, + User, } from '@supabase/supabase-js'; /** @@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) { * @description Service for handling auth callbacks in Supabase */ class AuthCallbackService { - constructor(private readonly client: SupabaseClient) {} + constructor(private readonly client: SupabaseClient) { } /** * @name verifyTokenHash @@ -128,89 +129,117 @@ class AuthCallbackService { /** * @name exchangeCodeForSession * @description Exchanges the auth code for a session and redirects the user to the next page or an error page - * @param request - * @param params + * @param authCode */ - async exchangeCodeForSession( - request: Request, - params: { - joinTeamPath: string; - redirectPath: string; - errorPath?: string; - }, - ): Promise<{ - nextPath: string; - }> { - const requestUrl = new URL(request.url); - const searchParams = requestUrl.searchParams; + async exchangeCodeForSession(authCode: string): Promise<{ + isSuccess: boolean; + user: User; + } | ErrorURLParameters> { + let user: User; + try { + const { data, error } = + await this.client.auth.exchangeCodeForSession(authCode); - const authCode = searchParams.get('code'); - const error = searchParams.get('error'); - const nextUrlPathFromParams = searchParams.get('next'); - const inviteToken = searchParams.get('invite_token'); - const errorPath = params.errorPath ?? '/auth/callback/error'; - - let nextUrl = nextUrlPathFromParams ?? params.redirectPath; - - // if we have an invite token, we redirect to the join team page - // instead of the default next url. This is because the user is trying - // to join a team and we want to make sure they are redirected to the - // correct page. - if (inviteToken) { - const emailParam = searchParams.get('email'); - - const urlParams = new URLSearchParams({ - invite_token: inviteToken, - email: emailParam ?? '', - }); - - nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`; - } - - if (authCode) { - try { - const { error } = - await this.client.auth.exchangeCodeForSession(authCode); - - // if we have an error, we redirect to the error page - if (error) { - return onError({ - code: error.code, - error: error.message, - path: errorPath, - }); - } - } catch (error) { - console.error( - { - error, - name: `auth.callback`, - }, - `An error occurred while exchanging code for session`, - ); - - const message = error instanceof Error ? error.message : error; - - return onError({ - code: (error as AuthError)?.code, - error: message as string, - path: errorPath, + // if we have an error, we redirect to the error page + if (error) { + return getErrorURLParameters({ + code: error.code, + error: error.message, }); } - } - if (error) { - return onError({ - error, - path: errorPath, + // Handle Keycloak users - set up Medusa integration + if (data?.user && this.isKeycloakUser(data.user)) { + await this.setupMedusaUserForKeycloak(data.user); + } + + user = data.user; + } catch (error) { + console.error( + { + error, + name: `auth.callback`, + }, + `An error occurred while exchanging code for session`, + ); + + const message = error instanceof Error ? error.message : error; + + return getErrorURLParameters({ + code: (error as AuthError)?.code, + error: message as string, }); } return { - nextPath: nextUrl, + isSuccess: true, + user, }; } + /** + * Check if user is from Keycloak provider + */ + private isKeycloakUser(user: any): boolean { + return user?.app_metadata?.provider === 'keycloak' || + user?.app_metadata?.providers?.includes('keycloak'); + } + + private async setupMedusaUserForKeycloak(user: any): Promise { + if (!user.email) { + console.warn('Keycloak user has no email, skipping Medusa setup'); + return; + } + + try { + // Check if user already has medusa_account_id + const { data: accountData, error: fetchError } = await this.client + .schema('medreport') + .from('accounts') + .select('medusa_account_id, name, last_name') + .eq('primary_owner_user_id', user.id) + .eq('is_personal_account', true) + .single(); + + if (fetchError && fetchError.code !== 'PGRST116') { + console.error('Error fetching account data for Keycloak user:', fetchError); + return; + } + + // If user already has Medusa account, we're done + if (accountData?.medusa_account_id) { + console.log('Keycloak user already has Medusa account:', accountData.medusa_account_id); + return; + } + + const { medusaLoginOrRegister } = await import('../../features/medusa-storefront/src/lib/data/customer'); + + const medusaAccountId = await medusaLoginOrRegister({ + email: user.email, + supabaseUserId: user.id, + name: accountData?.name ?? '-', + lastName: accountData?.last_name ?? '-', + }); + + // Update the account with the Medusa account ID + const { error: updateError } = await this.client + .schema('medreport') + .from('accounts') + .update({ medusa_account_id: medusaAccountId }) + .eq('primary_owner_user_id', user.id) + .eq('is_personal_account', true); + + if (updateError) { + console.error('Error updating account with Medusa ID:', updateError); + return; + } + + console.log('Successfully set up Medusa account for Keycloak user:', medusaAccountId); + } catch (error) { + console.error('Error setting up Medusa account for Keycloak user:', error); + } + } + private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) { if (this.isLocalhost(url.host) && !this.isLocalhost(host)) { url.host = host as string; @@ -231,15 +260,19 @@ class AuthCallbackService { } } -function onError({ +interface ErrorURLParameters { + error: string; + code?: string; + searchParams: string; +} + +export function getErrorURLParameters({ error, - path, code, }: { error: string; - path: string; code?: string; -}) { +}): ErrorURLParameters { const errorMessage = getAuthErrorMessage({ error, code }); console.error( @@ -255,10 +288,10 @@ function onError({ code: code ?? '', }); - const nextPath = `${path}?${searchParams.toString()}`; - return { - nextPath, + error: errorMessage, + code: code ?? '', + searchParams: searchParams.toString(), }; } diff --git a/packages/supabase/src/clients/browser-client.ts b/packages/supabase/src/clients/browser-client.ts index 747945e..69bb463 100644 --- a/packages/supabase/src/clients/browser-client.ts +++ b/packages/supabase/src/clients/browser-client.ts @@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys'; export function getSupabaseBrowserClient() { const keys = getSupabaseClientKeys(); - return createBrowserClient(keys.url, keys.anonKey); + return createBrowserClient(keys.url, keys.anonKey, { + auth: { + flowType: 'pkce', + autoRefreshToken: true, + persistSession: true, + }, + }); } diff --git a/packages/supabase/src/clients/middleware-client.ts b/packages/supabase/src/clients/middleware-client.ts index 608dc3b..e9c0a31 100644 --- a/packages/supabase/src/clients/middleware-client.ts +++ b/packages/supabase/src/clients/middleware-client.ts @@ -20,6 +20,11 @@ export function createMiddlewareClient( const keys = getSupabaseClientKeys(); return createServerClient(keys.url, keys.anonKey, { + auth: { + flowType: 'pkce', + autoRefreshToken: true, + persistSession: true, + }, cookies: { getAll() { return request.cookies.getAll(); diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts index cd4c82c..c9b8d7e 100644 --- a/packages/supabase/src/clients/server-client.ts +++ b/packages/supabase/src/clients/server-client.ts @@ -15,6 +15,11 @@ export function getSupabaseServerClient() { const keys = getSupabaseClientKeys(); return createServerClient(keys.url, keys.anonKey, { + auth: { + flowType: 'pkce', + autoRefreshToken: true, + persistSession: true, + }, cookies: { async getAll() { const cookieStore = await cookies(); diff --git a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts index 6ed91c9..549bc1b 100644 --- a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts @@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() { const medusaAccountId = await medusaLoginOrRegister({ email: credentials.email, password: credentials.password, + isDevPasswordLogin: true, }); await client .schema('medreport').from('accounts') diff --git a/packages/supabase/src/hooks/use-sign-in-with-provider.ts b/packages/supabase/src/hooks/use-sign-in-with-provider.ts index d68700b..7361549 100644 --- a/packages/supabase/src/hooks/use-sign-in-with-provider.ts +++ b/packages/supabase/src/hooks/use-sign-in-with-provider.ts @@ -9,7 +9,13 @@ export function useSignInWithProvider() { const mutationKey = ['auth', 'sign-in-with-provider']; const mutationFn = async (credentials: SignInWithOAuthCredentials) => { - const response = await client.auth.signInWithOAuth(credentials); + const response = await client.auth.signInWithOAuth({ + ...credentials, + options: { + ...credentials.options, + redirectTo: `${window.location.origin}/auth/callback`, + }, + }); if (response.error) { throw response.error.message; diff --git a/packages/supabase/src/hooks/use-sign-out.ts b/packages/supabase/src/hooks/use-sign-out.ts index fbe65ee..c354cee 100644 --- a/packages/supabase/src/hooks/use-sign-out.ts +++ b/packages/supabase/src/hooks/use-sign-out.ts @@ -1,15 +1,28 @@ import { useMutation } from '@tanstack/react-query'; import { useSupabase } from './use-supabase'; -import { signout } from '../../../features/medusa-storefront/src/lib/data/customer'; export function useSignOut() { const client = useSupabase(); return useMutation({ mutationFn: async () => { - await signout(undefined, false); - return client.auth.signOut(); + try { + try { + const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer'); + await medusaLogout(); + } catch (medusaError) { + console.warn('Medusa logout failed or not available:', medusaError); + } + + const { error } = await client.auth.signOut(); + if (error) { + throw error; + } + } catch (error) { + console.error('Logout error:', error); + throw error; + } }, }); } diff --git a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts index f6dc21f..59a864c 100644 --- a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts @@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() { const medusaAccountId = await medusaLoginOrRegister({ email: credentials.email, password: credentials.password, + isDevPasswordLogin: true, }); await client .schema('medreport').from('accounts') From 57a998d215e0a33dc971b175e7dac2171a716f8e Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 01:02:42 +0300 Subject: [PATCH 09/26] fix NaN for bmi when divide-by-zero --- app/home/(user)/_components/dashboard.tsx | 2 +- lib/utils.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index 4d6d260..53722d9 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -84,7 +84,7 @@ const cards = ({ }, { title: 'dashboard:bmi', - description: bmiFromMetric(weight || 0, height || 0).toString(), + description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-', icon: , iconBg: getBmiBackgroundColor(bmiStatus), }, diff --git a/lib/utils.ts b/lib/utils.ts index 392a129..36307b7 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -41,8 +41,12 @@ export function sortByDate( export const bmiFromMetric = (kg: number, cm: number) => { const m = cm / 100; - const bmi = kg / (m * m); - return bmi ? Math.round(bmi) : NaN; + const m2 = m * m; + if (m2 === 0) { + return null; + } + const bmi = kg / m2; + return !Number.isNaN(bmi) ? Math.round(bmi) : null; }; export function getBmiStatus( @@ -59,7 +63,9 @@ export function getBmiStatus( ) || null; const bmi = bmiFromMetric(params.weight, params.height); - if (!thresholdByAge || Number.isNaN(bmi)) return null; + if (!thresholdByAge || bmi === null) { + return null; + } if (bmi > thresholdByAge.obesity_min) return BmiCategory.OBESE; if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT; From 077aaee181dc333ab45eee4723a53e237b70277f Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 01:03:17 +0300 Subject: [PATCH 10/26] handle keycloak user prefills in update-account form --- .../_components/update-account-form.tsx | 54 +++++++++++++------ app/auth/update-account/page.tsx | 23 +++++++- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/app/auth/update-account/_components/update-account-form.tsx b/app/auth/update-account/_components/update-account-form.tsx index 58887f0..bdda351 100644 --- a/app/auth/update-account/_components/update-account-form.tsx +++ b/app/auth/update-account/_components/update-account-form.tsx @@ -2,8 +2,6 @@ import Link from 'next/link'; -import { User } from '@supabase/supabase-js'; - import { ExternalLink } from '@/public/assets/external-link'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -23,31 +21,52 @@ import { Trans } from '@kit/ui/trans'; import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema'; import { onUpdateAccount } from '../_lib/server/update-account'; +import { z } from 'zod'; -export function UpdateAccountForm({ user }: { user: User }) { +type UpdateAccountFormValues = z.infer; + +export function UpdateAccountForm({ + defaultValues, +}: { + defaultValues: UpdateAccountFormValues, +}) { const form = useForm({ resolver: zodResolver(UpdateAccountSchema), mode: 'onChange', - defaultValues: { - firstName: '', - lastName: '', - personalCode: '', - email: user.email, - phone: '', - city: '', - weight: 0, - height: 0, - userConsent: false, - }, + defaultValues, }); + + const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues; + + const hasFirstName = !!firstName; + const hasLastName = !!lastName; + const hasPersonalCode = !!personalCode; + const hasEmail = !!email; + const hasWeight = !!weight; + const hasHeight = !!height; + const hasUserConsent = !!userConsent; + + const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => + onUpdateAccount({ + ...values, + ...(hasFirstName && { firstName }), + ...(hasLastName && { lastName }), + ...(hasPersonalCode && { personalCode }), + ...(hasEmail && { email }), + ...(hasWeight && { weight: values.weight ?? weight }), + ...(hasHeight && { height: values.height ?? height }), + ...(hasUserConsent && { userConsent: values.userConsent ?? userConsent }), + }); + return (
( @@ -63,6 +82,7 @@ export function UpdateAccountForm({ user }: { user: User }) { ( @@ -78,6 +98,7 @@ export function UpdateAccountForm({ user }: { user: User }) { ( @@ -93,13 +114,14 @@ export function UpdateAccountForm({ user }: { user: User }) { ( - + diff --git a/app/auth/update-account/page.tsx b/app/auth/update-account/page.tsx index 28a6395..fc94740 100644 --- a/app/auth/update-account/page.tsx +++ b/app/auth/update-account/page.tsx @@ -11,18 +11,39 @@ import { Trans } from '@kit/ui/trans'; import { withI18n } from '~/lib/i18n/with-i18n'; import { UpdateAccountForm } from './_components/update-account-form'; +import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; +import { toTitleCase } from '~/lib/utils'; async function UpdateAccount() { const client = getSupabaseServerClient(); + const account = await loadCurrentUserAccount(); const { data: { user }, } = await client.auth.getUser(); + const isKeycloakUser = user?.app_metadata?.provider === 'keycloak'; if (!user) { redirect(pathsConfig.auth.signIn); } + const defaultValues = { + firstName: account?.name ? toTitleCase(account.name) : '', + lastName: account?.last_name ? toTitleCase(account.last_name) : '', + personalCode: account?.personal_code ?? '', + email: (() => { + if (isKeycloakUser) { + return account?.email ?? ''; + } + return account?.email ?? user?.email ?? ''; + })(), + phone: account?.phone ?? '', + city: account?.city ?? '', + weight: account?.accountParams?.weight ?? 0, + height: account?.accountParams?.height ?? 0, + userConsent: account?.has_consent_personal_data ?? false, + }; + return (
@@ -34,7 +55,7 @@ async function UpdateAccount() {

- +
From c882a2441548dd73fd1c1eeaae6440e17e55fd53 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 01:04:37 +0300 Subject: [PATCH 11/26] update envs for keycloak --- .env | 5 ++++- .env.development | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.env b/.env index cfe6997..e58a457 100644 --- a/.env +++ b/.env @@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff" NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a" # AUTH -NEXT_PUBLIC_AUTH_PASSWORD=true +NEXT_PUBLIC_AUTH_PASSWORD=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_CAPTCHA_SITE_KEY= @@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= + +# Configure Medusa password secret for Keycloak users +MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg== diff --git a/.env.development b/.env.development index 962cb9d..c92a206 100644 --- a/.env.development +++ b/.env.development @@ -3,6 +3,7 @@ # SITE NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_AUTH_PASSWORD=true # SUPABASE DEVELOPMENT From aa441d4055fceea6f3dce8de6694cc9e9820e0df Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 01:05:02 +0300 Subject: [PATCH 12/26] update medreport.accounts with keycloak data when supabase auth.user is created --- ...07000001_update_keycloak_user_creation.sql | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 supabase/migrations/20250907000001_update_keycloak_user_creation.sql diff --git a/supabase/migrations/20250907000001_update_keycloak_user_creation.sql b/supabase/migrations/20250907000001_update_keycloak_user_creation.sql new file mode 100644 index 0000000..ccc7834 --- /dev/null +++ b/supabase/migrations/20250907000001_update_keycloak_user_creation.sql @@ -0,0 +1,94 @@ +-- Update the user creation trigger to properly handle Keycloak user metadata +CREATE OR REPLACE FUNCTION kit.setup_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO '' +AS $$ +DECLARE + user_name text; + picture_url text; + personal_code text; + full_name text; + given_name text; + family_name text; + preferred_username text; +BEGIN + -- Extract data from Keycloak user metadata + -- Check raw_user_meta_data first (this is where Keycloak data is stored) + IF new.raw_user_meta_data IS NOT NULL THEN + -- Try full_name first, then name field + full_name := new.raw_user_meta_data ->> 'full_name'; + IF full_name IS NULL THEN + full_name := new.raw_user_meta_data ->> 'name'; + END IF; + + -- Extract individual name components + given_name := new.raw_user_meta_data -> 'custom_claims' ->> 'given_name'; + family_name := new.raw_user_meta_data -> 'custom_claims' ->> 'family_name'; + preferred_username := new.raw_user_meta_data -> 'custom_claims' ->> 'preferred_username'; + + -- Use given_name (first name) for the name field + IF given_name IS NOT NULL THEN + user_name := given_name; + ELSIF full_name IS NOT NULL THEN + user_name := full_name; + ELSIF preferred_username IS NOT NULL THEN + user_name := preferred_username; + END IF; + + -- Extract personal code from preferred_username (Keycloak provides Estonian personal codes here) + IF preferred_username IS NOT NULL THEN + personal_code := preferred_username; + END IF; + + -- Also try personalCode field as fallback + IF personal_code IS NULL THEN + personal_code := new.raw_user_meta_data ->> 'personalCode'; + END IF; + END IF; + + -- Fall back to email if no name found + IF user_name IS NULL AND new.email IS NOT NULL THEN + user_name := split_part(new.email, '@', 1); + END IF; + + -- Default empty string if still no name + IF user_name IS NULL THEN + user_name := ''; + END IF; + + -- Extract picture URL + IF new.raw_user_meta_data ->> 'avatar_url' IS NOT NULL THEN + picture_url := new.raw_user_meta_data ->> 'avatar_url'; + ELSE + picture_url := null; + END IF; + + -- Insert into medreport.accounts + INSERT INTO medreport.accounts ( + id, + primary_owner_user_id, + name, + last_name, + is_personal_account, + picture_url, + email, + personal_code, + application_role + ) + VALUES ( + new.id, + new.id, + user_name, + family_name, + true, + picture_url, + NULL, -- Keycloak email !== customer personal email, they will set this later + personal_code, + 'user' -- Default role for new users + ); + + RETURN new; +END; +$$; From ccdfd5872b094122a2b9598877f291f2dafefc34 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 13:37:47 +0300 Subject: [PATCH 13/26] retry pipeline From 0081e8948b3234738eac73813103b7da99842020 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 23:42:50 +0300 Subject: [PATCH 14/26] move most isikukood.js usage to utils --- app/home/(user)/_components/dashboard.tsx | 21 +++---- .../_lib/server/load-analysis-packages.ts | 25 +------- lib/templates/medipost-order.ts | 8 +-- lib/utils.ts | 61 +++++++++++++++++-- packages/features/accounts/src/server/api.ts | 11 +--- 5 files changed, 75 insertions(+), 51 deletions(-) diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index 53722d9..356688b 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -16,7 +16,6 @@ import { } from 'lucide-react'; import { pathsConfig } from '@kit/shared/config'; -import { getPersonParameters } from '@kit/shared/utils'; import { Button } from '@kit/ui/button'; import { Card, @@ -30,7 +29,7 @@ import { cn } from '@kit/ui/utils'; import { isNil } from 'lodash'; import { BmiCategory } from '~/lib/types/bmi'; -import { +import PersonalCode, { bmiFromMetric, getBmiBackgroundColor, getBmiStatus, @@ -145,21 +144,19 @@ export default function Dashboard({ 'id' >[]; }) { - const params = getPersonParameters(account.personal_code!); - const bmiStatus = getBmiStatus(bmiThresholds, { - age: params?.age || 0, - height: account.accountParams?.height || 0, - weight: account.accountParams?.weight || 0, - }); + const height = account.accountParams?.height || 0; + const weight = account.accountParams?.weight || 0; + const { age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!); + const bmiStatus = getBmiStatus(bmiThresholds, { age, height, weight }); return ( <>
{cards({ - gender: params?.gender, - age: params?.age, - height: account.accountParams?.height, - weight: account.accountParams?.weight, + gender, + age, + height, + weight, bmiStatus, smoking: account.accountParams?.isSmoker, }).map( diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index ca3fd5b..3fe0291 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -1,5 +1,4 @@ import { cache } from 'react'; -import Isikukood, { Gender } from 'isikukood'; import { listProductTypes, listProducts } from "@lib/data/products"; import { listRegions } from '@lib/data/regions'; @@ -8,6 +7,7 @@ import type { StoreProduct } from '@medusajs/types'; import { loadCurrentUserAccount } from './load-user-account'; import { AccountWithParams } from '@/packages/features/accounts/src/server/api'; import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package'; +import PersonalCode from '~/lib/utils'; async function countryCodesLoader() { const countryCodes = await listRegions().then((regions) => @@ -32,27 +32,8 @@ function userSpecificVariantLoader({ if (!personalCode) { throw new Error('Personal code not found'); } - const parsed = new Isikukood(personalCode); - const ageRange = (() => { - const age = parsed.getAge(); - if (age >= 18 && age <= 29) { - return '18-29'; - } - if (age >= 30 && age <= 39) { - return '30-39'; - } - if (age >= 40 && age <= 49) { - return '40-49'; - } - if (age >= 50 && age <= 59) { - return '50-59'; - } - if (age >= 60) { - return '60'; - } - throw new Error('Age range not supported'); - })(); - const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F'; + + const { gender, ageRange } = PersonalCode.parsePersonalCode(personalCode); return ({ product, diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 10ce573..818e57d 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -1,7 +1,7 @@ import { format } from 'date-fns'; -import Isikukood, { Gender } from 'isikukood'; import { Tables } from '@/packages/supabase/src/database.types'; import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants'; +import PersonalCode from '../utils'; const isProd = process.env.NODE_ENV === 'production'; @@ -73,15 +73,15 @@ export const getPatient = ({ lastName: string, firstName: string, }) => { - const isikukood = new Isikukood(idCode); + const { dob, gender } = PersonalCode.parsePersonalCode(idCode); return ` 1.3.6.1.4.1.28284.6.2.2.1 ${idCode} ${lastName} ${firstName} - ${format(isikukood.getBirthday(), DATE_FORMAT)} + ${format(dob, DATE_FORMAT)} 1.3.6.1.4.1.28284.6.2.3.16.2 - ${isikukood.getGender() === Gender.MALE ? 'M' : 'N'} + ${gender === 'M' ? 'M' : 'N'} `; }; diff --git a/lib/utils.ts b/lib/utils.ts index 36307b7..90442fa 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -91,8 +91,61 @@ export function getBmiBackgroundColor(bmiStatus: BmiCategory | null): string { } export function getGenderStringFromPersonalCode(personalCode: string) { - const person = new Isikukood(personalCode); - if (person.getGender() === Gender.FEMALE) return 'common:female'; - if (person.getGender() === Gender.MALE) return 'common:male'; - return 'common:unknown'; + switch (PersonalCode.parsePersonalCode(personalCode).gender) { + case 'F': + return 'common:female'; + case 'M': + return 'common:male'; + default: + return 'common:unknown'; + } +} + +type AgeRange = '18-29' | '30-39' | '40-49' | '50-59' | '60'; +export default class PersonalCode { + static getPersonalCode(personalCode: string | null) { + if (!personalCode) { + return null; + } + if (personalCode.toLowerCase().startsWith('ee')) { + return personalCode.substring(2); + } + return personalCode; + } + + static parsePersonalCode(personalCode: string): { + ageRange: AgeRange; + gender: 'M' | 'F'; + dob: Date; + age: number; + } { + const parsed = new Isikukood(personalCode); + const ageRange = (() => { + const age = parsed.getAge(); + if (age >= 18 && age <= 29) { + return '18-29'; + } + if (age >= 30 && age <= 39) { + return '30-39'; + } + if (age >= 40 && age <= 49) { + return '40-49'; + } + if (age >= 50 && age <= 59) { + return '50-59'; + } + if (age >= 60) { + return '60'; + } + throw new Error('Age range not supported'); + })(); + const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F'; + + return { + ageRange, + gender, + dob: parsed.getBirthday(), + age: parsed.getAge(), + } + } } diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts index f28a490..d1faaef 100644 --- a/packages/features/accounts/src/server/api.ts +++ b/packages/features/accounts/src/server/api.ts @@ -3,6 +3,7 @@ import { SupabaseClient } from '@supabase/supabase-js'; import { Database } from '@kit/supabase/database'; import { AnalysisResultDetails, UserAnalysis } from '../types/accounts'; +import PersonalCode from '~/lib/utils'; export type AccountWithParams = Database['medreport']['Tables']['accounts']['Row'] & { @@ -71,15 +72,7 @@ class AccountsApi { const { personal_code, ...rest } = data; return { ...rest, - personal_code: (() => { - if (!personal_code) { - return null; - } - if (personal_code.toLowerCase().startsWith('ee')) { - return personal_code.substring(2); - } - return personal_code; - })(), + personal_code: PersonalCode.getPersonalCode(personal_code), }; } From 2c638758066fe2e6f3c0a4bd0d9f1b3e0f778588 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 23:43:03 +0300 Subject: [PATCH 15/26] fix typo --- .../server/load-team-account-health-details.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/home/[account]/_lib/server/load-team-account-health-details.ts b/app/home/[account]/_lib/server/load-team-account-health-details.ts index 0c4ff72..1705770 100644 --- a/app/home/[account]/_lib/server/load-team-account-health-details.ts +++ b/app/home/[account]/_lib/server/load-team-account-health-details.ts @@ -31,11 +31,11 @@ export const getAccountHealthDetailsFields = ( >[], members: Database['medreport']['Functions']['get_account_members']['Returns'], ): AccountHealthDetailsField[] => { - const avarageWeight = + const averageWeight = memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length; - const avarageHeight = + const averageHeight = memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length; - const avarageAge = + const averageAge = members.reduce((sum, r) => { const person = new Isikukood(r.personal_code); return sum + person.getAge(); @@ -48,11 +48,11 @@ export const getAccountHealthDetailsFields = ( const person = new Isikukood(r.personal_code); return person.getGender() === 'female'; }).length; - const averageBMI = bmiFromMetric(avarageWeight, avarageHeight); + const averageBMI = bmiFromMetric(averageWeight, averageHeight); const bmiStatus = getBmiStatus(bmiThresholds, { - age: avarageAge, - height: avarageHeight, - weight: avarageWeight, + age: averageAge, + height: averageHeight, + weight: averageWeight, }); const malePercentage = members.length ? (numberOfMaleMembers / members.length) * 100 @@ -76,7 +76,7 @@ export const getAccountHealthDetailsFields = ( }, { title: 'teams:healthDetails.avgAge', - value: avarageAge.toFixed(0), + value: averageAge.toFixed(0), Icon: Clock, iconBg: 'bg-success', }, From 0e063cd5dc115d7db52b50e5bf980338c98124be Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 23:43:24 +0300 Subject: [PATCH 16/26] 'medreport.update_account' should also update email --- packages/features/auth/src/server/api.ts | 1 + packages/supabase/src/database.types.ts | 1 + ...08145900_update_account_email_keycloak.sql | 28 +++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 supabase/migrations/20250908145900_update_account_email_keycloak.sql diff --git a/packages/features/auth/src/server/api.ts b/packages/features/auth/src/server/api.ts index 6f8ead2..223e82c 100644 --- a/packages/features/auth/src/server/api.ts +++ b/packages/features/auth/src/server/api.ts @@ -68,6 +68,7 @@ class AuthApi { p_name: data.firstName, p_last_name: data.lastName, p_personal_code: data.personalCode, + p_email: data.email || '', p_phone: data.phone || '', p_city: data.city || '', p_has_consent_personal_data: data.userConsent, diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index a4f8cc1..047f243 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -2053,6 +2053,7 @@ export type Database = { p_personal_code: string p_phone: string p_uid: string + p_email: string } Returns: undefined } diff --git a/supabase/migrations/20250908145900_update_account_email_keycloak.sql b/supabase/migrations/20250908145900_update_account_email_keycloak.sql new file mode 100644 index 0000000..9e44e06 --- /dev/null +++ b/supabase/migrations/20250908145900_update_account_email_keycloak.sql @@ -0,0 +1,28 @@ +CREATE OR REPLACE FUNCTION medreport.update_account(p_name character varying, p_last_name text, p_personal_code text, p_phone text, p_city text, p_has_consent_personal_data boolean, p_uid uuid, p_email character varying) + RETURNS void + LANGUAGE plpgsql +AS $function$begin + update medreport.accounts + set name = coalesce(p_name, name), + last_name = coalesce(p_last_name, last_name), + personal_code = coalesce(p_personal_code, personal_code), + phone = coalesce(p_phone, phone), + city = coalesce(p_city, city), + has_consent_personal_data = coalesce(p_has_consent_personal_data, + has_consent_personal_data), + email = coalesce(p_email, email) + where id = p_uid; +end;$function$ +; + +grant +execute on function medreport.update_account( + p_name character varying, + p_last_name text, + p_personal_code text, + p_phone text, + p_city text, + p_has_consent_personal_data boolean, + p_uid uuid, + p_email character varying) to authenticated, +service_role; From 5b91ece1ec02baf9855c86a19b5e9c3bd411e608 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 00:08:57 +0300 Subject: [PATCH 17/26] sort compare packages modal analyses by title --- app/home/(user)/_components/compare-packages-modal.tsx | 2 +- app/home/(user)/_lib/server/load-analysis-packages.ts | 1 + .../features/medusa-storefront/src/lib/data/products.ts | 7 ++++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/home/(user)/_components/compare-packages-modal.tsx b/app/home/(user)/_components/compare-packages-modal.tsx index 7c163bd..90ed81c 100644 --- a/app/home/(user)/_components/compare-packages-modal.tsx +++ b/app/home/(user)/_components/compare-packages-modal.tsx @@ -128,7 +128,7 @@ const ComparePackagesModal = async ({ return ( - + {title}{' '} {description && (} />)} diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index 3fe0291..ca4dab3 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -70,6 +70,7 @@ async function analysisPackageElementsLoader({ queryParams: { id: analysisElementMedusaProductIds, limit: 100, + order: "title", }, }); diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index a8ea25d..4b1e250 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -14,7 +14,12 @@ export const listProducts = async ({ regionId, }: { pageParam?: number - queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_id?: string } + queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { + "type_id[0]"?: string; + id?: string[], + category_id?: string; + order?: 'title'; + } countryCode?: string regionId?: string }): Promise<{ From 3bdc1cfefc46e4e3b24f2c1dcc5480b1e1d9b5a7 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 00:09:15 +0300 Subject: [PATCH 18/26] update personal code util --- app/home/(user)/_components/dashboard.tsx | 4 ++-- lib/utils.ts | 25 +++++++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index 356688b..d2f8007 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -59,7 +59,7 @@ const cards = ({ }) => [ { title: 'dashboard:gender', - description: gender ?? 'dashboard:male', + description: gender ?? '-', icon: , iconBg: 'bg-success', }, @@ -153,7 +153,7 @@ export default function Dashboard({ <>
{cards({ - gender, + gender: gender.label, age, height, weight, diff --git a/lib/utils.ts b/lib/utils.ts index 90442fa..d8fa393 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -90,17 +90,6 @@ export function getBmiBackgroundColor(bmiStatus: BmiCategory | null): string { } } -export function getGenderStringFromPersonalCode(personalCode: string) { - switch (PersonalCode.parsePersonalCode(personalCode).gender) { - case 'F': - return 'common:female'; - case 'M': - return 'common:male'; - default: - return 'common:unknown'; - } -} - type AgeRange = '18-29' | '30-39' | '40-49' | '50-59' | '60'; export default class PersonalCode { static getPersonalCode(personalCode: string | null) { @@ -115,7 +104,7 @@ export default class PersonalCode { static parsePersonalCode(personalCode: string): { ageRange: AgeRange; - gender: 'M' | 'F'; + gender: { label: string; value: string }; dob: Date; age: number; } { @@ -139,7 +128,17 @@ export default class PersonalCode { } throw new Error('Age range not supported'); })(); - const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F'; + const gender = (() => { + const gender = parsed.getGender(); + switch (gender) { + case Gender.FEMALE: + return { label: 'common:female', value: 'F' }; + case Gender.MALE: + return { label: 'common:male', value: 'M' }; + default: + throw new Error('Gender not supported'); + } + })(); return { ageRange, From 353e5c3c4b3ef07013174d4587f71f296a5bbc89 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 00:31:55 +0300 Subject: [PATCH 19/26] update types --- packages/supabase/src/database.types.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 047f243..a09d6b8 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -1257,6 +1257,26 @@ export type Database = { }, ] } + medipost_actions: { + Row: { + created_at: string + id: number + action: string + xml: string + has_analysis_results: boolean + medusa_order_id: string + response_xml: string + has_error: boolean + } + Insert: { + action: string + xml: string + has_analysis_results: boolean + medusa_order_id: string + response_xml: string + has_error: boolean + } + } medreport_product_groups: { Row: { created_at: string From c2896d77b0d7a7f6630ea7dc9f0c67d6c18e6fe6 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 00:09:38 +0300 Subject: [PATCH 20/26] prefill phone field in update-account form --- app/auth/update-account/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/auth/update-account/page.tsx b/app/auth/update-account/page.tsx index fc94740..a40b13e 100644 --- a/app/auth/update-account/page.tsx +++ b/app/auth/update-account/page.tsx @@ -37,7 +37,7 @@ async function UpdateAccount() { } return account?.email ?? user?.email ?? ''; })(), - phone: account?.phone ?? '', + phone: account?.phone ?? '+372', city: account?.city ?? '', weight: account?.accountParams?.weight ?? 0, height: account?.accountParams?.height ?? 0, From f00899c4563852198105dcfd0bb5356a9d64c89c Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 00:33:43 +0300 Subject: [PATCH 21/26] move medipost xml to separate service to be unit tested --- lib/services/analyses.service.ts | 10 +- lib/services/medipost.service.ts | 152 ++++------------------------ lib/services/medipostXML.service.ts | 136 +++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 134 deletions(-) create mode 100644 lib/services/medipostXML.service.ts diff --git a/lib/services/analyses.service.ts b/lib/services/analyses.service.ts index 0127e09..790b201 100644 --- a/lib/services/analyses.service.ts +++ b/lib/services/analyses.service.ts @@ -2,7 +2,7 @@ import type { Tables } from '@/packages/supabase/src/database.types'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import type { IUuringElement } from "./medipost.types"; -type AnalysesWithGroupsAndElements = ({ +export type AnalysesWithGroupsAndElements = ({ analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & { analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>; }; @@ -105,7 +105,13 @@ export const createMedusaSyncSuccessEntry = async () => { }); } -export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise { +export async function getAnalyses({ + ids, + originalIds, +}: { + ids?: number[]; + originalIds?: string[]; +}): Promise { const query = getSupabaseServerAdminClient() .schema('medreport') .from('analyses') diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index b6aec5d..4171977 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -5,23 +5,11 @@ import { createClient as createCustomClient, } from '@supabase/supabase-js'; -import { - getAnalysisGroup, - getClientInstitution, - getClientPerson, - getConfidentiality, - getOrderEnteredPerson, - getPais, - getPatient, - getProviderInstitution, - getSpecimen, -} from '@/lib/templates/medipost-order'; import { SyncStatus } from '@/lib/types/audit'; import { AnalysisOrderStatus, GetMessageListResponse, IMedipostResponseXMLBase, - MaterjalideGrupp, MedipostAction, MedipostOrderResponse, MedipostPublicMessageResponse, @@ -32,7 +20,6 @@ import { import { toArray } from '@/lib/utils'; import axios from 'axios'; import { XMLParser } from 'fast-xml-parser'; -import { uniqBy } from 'lodash'; import { Tables } from '@kit/supabase/database'; import { createAnalysisGroup } from './analysis-group.service'; @@ -47,6 +34,7 @@ import { listRegions } from '@lib/data/regions'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { MedipostValidationError } from './medipost/MedipostValidationError'; import { logMedipostDispatch } from './audit.service'; +import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service'; const BASE_URL = process.env.MEDIPOST_URL!; const USER = process.env.MEDIPOST_USER!; @@ -451,122 +439,6 @@ export async function syncPublicMessage( } } -export async function composeOrderXML({ - person, - orderedAnalysisElementsIds, - orderedAnalysesIds, - orderId, - orderCreatedAt, - comment, -}: { - person: { - idCode: string; - firstName: string; - lastName: string; - phone: string; - }; - orderedAnalysisElementsIds: number[]; - orderedAnalysesIds: number[]; - orderId: string; - orderCreatedAt: Date; - comment?: string; -}) { - const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); - if (analysisElements.length !== orderedAnalysisElementsIds.length) { - throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`); - } - - const analyses = await getAnalyses({ ids: orderedAnalysesIds }); - if (analyses.length !== orderedAnalysesIds.length) { - throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`); - } - - const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] = - uniqBy( - ( - analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ?? - [] - ).concat( - analyses?.flatMap( - ({ analysis_elements }) => analysis_elements.analysis_groups, - ) ?? [], - ), - 'id', - ); - - const specimenSection = []; - const analysisSection = []; - let order = 1; - for (const currentGroup of analysisGroups) { - let relatedAnalysisElement = analysisElements?.find( - (element) => element.analysis_groups.id === currentGroup.id, - ); - const relatedAnalyses = analyses?.filter((analysis) => { - return analysis.analysis_elements.analysis_groups.id === currentGroup.id; - }); - - if (!relatedAnalysisElement) { - relatedAnalysisElement = relatedAnalyses?.find( - (relatedAnalysis) => - relatedAnalysis.analysis_elements.analysis_groups.id === - currentGroup.id, - )?.analysis_elements; - } - - if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) { - throw new Error( - `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`, - ); - } - - for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) { - const materials = toArray(group.Materjal); - const specimenXml = materials.flatMap( - ({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => { - return toArray(Konteiner).map((container) => - getSpecimen( - MaterjaliTyypOID, - MaterjaliTyyp, - MaterjaliNimi, - order, - container.ProovinouKoodOID, - container.ProovinouKood, - ), - ); - }, - ); - - specimenSection.push(...specimenXml); - } - - const groupXml = getAnalysisGroup( - currentGroup.original_id, - currentGroup.name, - order, - relatedAnalysisElement, - ); - order++; - analysisSection.push(groupXml); - } - - return ` - - ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)} - - ${orderId} - ${getClientInstitution()} - ${getProviderInstitution()} - ${getClientPerson()} - ${getOrderEnteredPerson()} - ${comment ?? ''} - ${getPatient(person)} - ${getConfidentiality()} - ${specimenSection.join('')} - ${analysisSection?.join('')} - -`; -} - function getLatestMessage({ messages, excludedMessageIds, @@ -714,20 +586,36 @@ export async function sendOrderToMedipost({ orderedAnalysisElements, }: { medusaOrderId: string; - orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[]; + orderedAnalysisElements: OrderedAnalysisElement[]; }) { const medreportOrder = await getOrder({ medusaOrderId }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); + const orderedAnalysesIds = orderedAnalysisElements + .map(({ analysisId }) => analysisId) + .filter(Boolean) as number[]; + const orderedAnalysisElementsIds = orderedAnalysisElements + .map(({ analysisElementId }) => analysisElementId) + .filter(Boolean) as number[]; + + const analyses = await getAnalyses({ ids: orderedAnalysesIds }); + if (analyses.length !== orderedAnalysesIds.length) { + throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`); + } + const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); + if (analysisElements.length !== orderedAnalysisElementsIds.length) { + throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`); + } + const orderXml = await composeOrderXML({ + analyses, + analysisElements, person: { idCode: account.personal_code!, firstName: account.name ?? '', lastName: account.last_name ?? '', phone: account.phone ?? '', }, - orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[], - orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[], orderId: medusaOrderId, orderCreatedAt: new Date(medreportOrder.created_at), comment: '', diff --git a/lib/services/medipostXML.service.ts b/lib/services/medipostXML.service.ts new file mode 100644 index 0000000..ad6fb04 --- /dev/null +++ b/lib/services/medipostXML.service.ts @@ -0,0 +1,136 @@ +'use server'; + +import { + getAnalysisGroup, + getClientInstitution, + getClientPerson, + getConfidentiality, + getOrderEnteredPerson, + getPais, + getPatient, + getProviderInstitution, + getSpecimen, +} from '@/lib/templates/medipost-order'; +import { + MaterjalideGrupp, +} from '@/lib/types/medipost'; +import { toArray } from '@/lib/utils'; +import { uniqBy } from 'lodash'; + +import { Tables } from '@kit/supabase/database'; +import { AnalysisElement } from './analysis-element.service'; +import { AnalysesWithGroupsAndElements } from './analyses.service'; + +const USER = process.env.MEDIPOST_USER!; +const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; + +export type OrderedAnalysisElement = { + analysisElementId?: number; + analysisId?: number; +} + +export async function composeOrderXML({ + analyses, + analysisElements, + person, + orderId, + orderCreatedAt, + comment, +}: { + analyses: AnalysesWithGroupsAndElements; + analysisElements: AnalysisElement[]; + person: { + idCode: string; + firstName: string; + lastName: string; + phone: string; + }; + orderId: string; + orderCreatedAt: Date; + comment?: string; +}) { + const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] = + uniqBy( + ( + analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ?? + [] + ).concat( + analyses?.flatMap( + ({ analysis_elements }) => analysis_elements.analysis_groups, + ) ?? [], + ), + 'id', + ); + + const specimenSection = []; + const analysisSection = []; + let order = 1; + for (const currentGroup of analysisGroups) { + let relatedAnalysisElement = analysisElements?.find( + (element) => element.analysis_groups.id === currentGroup.id, + ); + const relatedAnalyses = analyses?.filter((analysis) => { + return analysis.analysis_elements.analysis_groups.id === currentGroup.id; + }); + + if (!relatedAnalysisElement) { + relatedAnalysisElement = relatedAnalyses?.find( + (relatedAnalysis) => + relatedAnalysis.analysis_elements.analysis_groups.id === + currentGroup.id, + )?.analysis_elements; + } + + if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) { + throw new Error( + `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`, + ); + } + + for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) { + const materials = toArray(group.Materjal); + const specimenXml = materials.flatMap( + ({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => { + return toArray(Konteiner).map((container) => + getSpecimen( + MaterjaliTyypOID, + MaterjaliTyyp, + MaterjaliNimi, + order, + container.ProovinouKoodOID, + container.ProovinouKood, + ), + ); + }, + ); + + specimenSection.push(...specimenXml); + } + + const groupXml = getAnalysisGroup( + currentGroup.original_id, + currentGroup.name, + order, + relatedAnalysisElement, + ); + order++; + analysisSection.push(groupXml); + } + + return ` + + ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)} + + ${orderId} + ${getClientInstitution()} + ${getProviderInstitution()} + ${getClientPerson()} + ${getOrderEnteredPerson()} + ${comment ?? ''} + ${getPatient(person)} + ${getConfidentiality()} + ${specimenSection.join('')} + ${analysisSection?.join('')} + +`; +} From 06154f24bfcedf827d52c2cb56172464c9aa79de Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 00:35:21 +0300 Subject: [PATCH 22/26] fix: deduplicate specimen elements in medipost XML generation - Fix duplicate elements when multiple analysis elements use same material type - Ensure analysis elements reference correct specimen order numbers - Move XML composition logic to separate service for better separation of concerns --- lib/services/medipostXML.service.ts | 99 ++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/lib/services/medipostXML.service.ts b/lib/services/medipostXML.service.ts index ad6fb04..3b55506 100644 --- a/lib/services/medipostXML.service.ts +++ b/lib/services/medipostXML.service.ts @@ -62,9 +62,19 @@ export async function composeOrderXML({ 'id', ); - const specimenSection = []; - const analysisSection = []; - let order = 1; + // First, collect all unique materials across all analysis groups + const uniqueMaterials = new Map(); + + let specimenOrder = 1; + + // Collect all materials from all analysis groups for (const currentGroup of analysisGroups) { let relatedAnalysisElement = analysisElements?.find( (element) => element.analysis_groups.id === currentGroup.id, @@ -89,31 +99,86 @@ export async function composeOrderXML({ for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) { const materials = toArray(group.Materjal); - const specimenXml = materials.flatMap( - ({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => { - return toArray(Konteiner).map((container) => - getSpecimen( + for (const material of materials) { + const { MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner } = material; + const containers = toArray(Konteiner); + + for (const container of containers) { + // Use MaterialTyyp as the key for deduplication + const materialKey = MaterjaliTyyp; + + if (!uniqueMaterials.has(materialKey)) { + uniqueMaterials.set(materialKey, { MaterjaliTyypOID, MaterjaliTyyp, MaterjaliNimi, - order, - container.ProovinouKoodOID, - container.ProovinouKood, - ), - ); - }, - ); + ProovinouKoodOID: container.ProovinouKoodOID, + ProovinouKood: container.ProovinouKood, + order: specimenOrder++, + }); + } + } + } + } + } - specimenSection.push(...specimenXml); + // Generate specimen section from unique materials + const specimenSection = Array.from(uniqueMaterials.values()).map(material => + getSpecimen( + material.MaterjaliTyypOID, + material.MaterjaliTyyp, + material.MaterjaliNimi, + material.order, + material.ProovinouKoodOID, + material.ProovinouKood, + ) + ); + + // Generate analysis section with correct specimen references + const analysisSection = []; + for (const currentGroup of analysisGroups) { + let relatedAnalysisElement = analysisElements?.find( + (element) => element.analysis_groups.id === currentGroup.id, + ); + const relatedAnalyses = analyses?.filter((analysis) => { + return analysis.analysis_elements.analysis_groups.id === currentGroup.id; + }); + + if (!relatedAnalysisElement) { + relatedAnalysisElement = relatedAnalyses?.find( + (relatedAnalysis) => + relatedAnalysis.analysis_elements.analysis_groups.id === + currentGroup.id, + )?.analysis_elements; + } + + if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) { + throw new Error( + `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`, + ); + } + + // Find the specimen order number for this analysis group + let specimenOrderNumber = 1; + for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) { + const materials = toArray(group.Materjal); + for (const material of materials) { + const materialKey = material.MaterjaliTyyp; + const uniqueMaterial = uniqueMaterials.get(materialKey); + if (uniqueMaterial) { + specimenOrderNumber = uniqueMaterial.order; + break; // Use the first material's order number + } + } + if (specimenOrderNumber > 1) break; // Found a specimen, use it } const groupXml = getAnalysisGroup( currentGroup.original_id, currentGroup.name, - order, + specimenOrderNumber, relatedAnalysisElement, ); - order++; analysisSection.push(groupXml); } From 1d641211b6a1622f47456d1bd828ecfda0f57e8f Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 01:13:35 +0300 Subject: [PATCH 23/26] update for new type --- app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts | 1 - lib/templates/medipost-order.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index 9ef4799..11eca6f 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -112,7 +112,6 @@ export async function processMontonioCallback(orderToken: string) { throw new Error("Cart not found"); } - const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); const orderId = await createOrder({ medusaOrder, orderedAnalysisElements }); diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 818e57d..19e5b79 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -81,7 +81,7 @@ export const getPatient = ({ ${firstName} ${format(dob, DATE_FORMAT)} 1.3.6.1.4.1.28284.6.2.3.16.2 - ${gender === 'M' ? 'M' : 'N'} + ${gender.value === 'M' ? 'M' : 'N'} `; }; From 596d0e9eee1ca7c9c27997ed9b3b0486b58328d2 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 01:13:47 +0300 Subject: [PATCH 24/26] fix missing month for DoB in medipost xml --- lib/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/constants.ts b/lib/constants.ts index 7092724..93f3bca 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,2 +1,2 @@ export const DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; -export const DATE_FORMAT = "yyyy-mm-dd"; +export const DATE_FORMAT = "yyyy-MM-dd"; From fd943202955c5dfb503c7df5c74c49e9af2e081d Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 01:16:23 +0300 Subject: [PATCH 25/26] use `analysisElementMedusaProductIds` from order product selected variant if it exists --- lib/services/medipost.service.ts | 17 +++++++++++------ utils/medusa-product.ts | 18 ++++++++++++++---- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 4171977..82db51d 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -566,7 +566,7 @@ async function syncPrivateMessage({ ); } - const { data: allOrderResponseElements} = await supabase + const { data: allOrderResponseElements } = await supabase .schema('medreport') .from('analysis_response_elements') .select('*') @@ -714,7 +714,12 @@ export async function getOrderedAnalysisIds({ throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`); } - const ids = getAnalysisElementMedusaProductIds(orderedPackagesProducts); + const ids = getAnalysisElementMedusaProductIds( + orderedPackagesProducts.map(({ id, metadata }) => ({ + metadata, + variant: orderedPackages.find(({ product }) => product?.id === id)?.variant, + })), + ); if (ids.length === 0) { return []; } @@ -755,10 +760,10 @@ export async function createMedipostActionLog({ hasError = false, }: { action: - | 'send_order_to_medipost' - | 'sync_analysis_results_from_medipost' - | 'send_fake_analysis_results_to_medipost' - | 'send_analysis_results_to_medipost'; + | 'send_order_to_medipost' + | 'sync_analysis_results_from_medipost' + | 'send_fake_analysis_results_to_medipost' + | 'send_analysis_results_to_medipost'; xml: string; hasAnalysisResults?: boolean; medusaOrderId?: string | null; diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts index 6cbbb3b..c505608 100644 --- a/utils/medusa-product.ts +++ b/utils/medusa-product.ts @@ -1,17 +1,27 @@ -export const getAnalysisElementMedusaProductIds = (products: ({ +import { StoreProduct } from "@medusajs/types"; + +type Product = { metadata?: { analysisElementMedusaProductIds?: string; } | null; -} | null)[]) => { + variant?: { + metadata?: { + analysisElementMedusaProductIds?: string; + } | null; + } | null; +} | null; + +export const getAnalysisElementMedusaProductIds = (products: Pick[]) => { if (!products) { return []; } const mapped = products .flatMap((product) => { - const value = product?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"'); + const value = (product as Product)?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"'); + const value_variant = (product as Product)?.variant?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"'); try { - return JSON.parse(value as string); + return [...JSON.parse(value as string), ...JSON.parse(value_variant as string)]; } catch (e) { console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e); return []; From c0e9cf5e25a0ea5f1d897cf40e1399eeee734316 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 01:39:46 +0300 Subject: [PATCH 26/26] fix value with new types --- app/home/(user)/_lib/server/load-analysis-packages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index ca4dab3..597b95f 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -33,7 +33,7 @@ function userSpecificVariantLoader({ throw new Error('Personal code not found'); } - const { gender, ageRange } = PersonalCode.parsePersonalCode(personalCode); + const { ageRange, gender: { value: gender } } = PersonalCode.parsePersonalCode(personalCode); return ({ product,