From 1b29cb222b9acfa887b5ce8b0fbc0157ede0a3d7 Mon Sep 17 00:00:00 2001 From: Karli Date: Mon, 8 Sep 2025 00:56:55 +0300 Subject: [PATCH 01/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] '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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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, From ca13e9e30ac774896421bc2bc533e5e056930379 Mon Sep 17 00:00:00 2001 From: Helena <37183360+helenarebane@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:37:22 +0300 Subject: [PATCH 27/58] MED-82: add patient notification emails (#74) * MED-82: add patient notification emails * remove console.log * clean up * remove extra paragraph from email --- app/api/job/send-open-jobs-emails/route.ts | 4 +- .../analysis-results/[id]/page.tsx | 4 +- lib/services/account.service.ts | 14 +- .../audit/notificationEntries.service.ts | 9 +- lib/services/audit/pageView.service.ts | 1 - lib/services/mailer.service.ts | 2 +- .../database-webhook-router.service.ts | 68 +---- .../src/emails/account-delete.email.tsx | 11 +- .../src/emails/all-results-received.email.tsx | 10 +- .../src/emails/company-offer.email.tsx | 10 +- .../emails/doctor-summary-received.email.tsx | 45 ++- .../emails/first-results-received.email.tsx | 7 +- .../src/emails/invite.email.tsx | 15 +- .../src/emails/new-jobs-available.email.tsx | 9 +- .../src/emails/order-processing.email.tsx | 90 ++++++ .../email-templates/src/emails/otp.email.tsx | 11 +- .../patient-first-results-received.email.tsx | 81 ++++++ .../patient-full-results-received.email.tsx | 82 ++++++ .../src/emails/synlab.email.tsx | 11 +- packages/email-templates/src/index.ts | 3 + .../en/doctor-summary-received-email.json | 14 +- .../locales/en/order-processing-email.json | 13 + .../patient-first-results-received-email.json | 8 + .../patient-full-results-received-email.json | 7 + .../src/locales/et/common.json | 4 +- .../et/doctor-summary-received-email.json | 12 +- .../locales/et/order-processing-email.json | 13 + .../patient-first-results-received-email.json | 8 + .../patient-full-results-received-email.json | 7 + .../ru/doctor-summary-received-email.json | 12 +- .../locales/ru/order-processing-email.json | 13 + .../patient-first-results-received-email.json | 8 + .../patient-full-results-received-email.json | 7 + .../server/actions/doctor-server-actions.ts | 4 +- .../server/schema/doctor-analysis.schema.ts | 4 +- packages/features/notifications/package.json | 3 +- .../analysis-order-notifications.service.ts | 273 ++++++++++++++++++ 37 files changed, 718 insertions(+), 179 deletions(-) create mode 100644 packages/email-templates/src/emails/order-processing.email.tsx create mode 100644 packages/email-templates/src/emails/patient-first-results-received.email.tsx create mode 100644 packages/email-templates/src/emails/patient-full-results-received.email.tsx create mode 100644 packages/email-templates/src/locales/en/order-processing-email.json create mode 100644 packages/email-templates/src/locales/en/patient-first-results-received-email.json create mode 100644 packages/email-templates/src/locales/en/patient-full-results-received-email.json create mode 100644 packages/email-templates/src/locales/et/order-processing-email.json create mode 100644 packages/email-templates/src/locales/et/patient-first-results-received-email.json create mode 100644 packages/email-templates/src/locales/et/patient-full-results-received-email.json create mode 100644 packages/email-templates/src/locales/ru/order-processing-email.json create mode 100644 packages/email-templates/src/locales/ru/patient-first-results-received-email.json create mode 100644 packages/email-templates/src/locales/ru/patient-full-results-received-email.json create mode 100644 packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts diff --git a/app/api/job/send-open-jobs-emails/route.ts b/app/api/job/send-open-jobs-emails/route.ts index c2083bf..939f3b7 100644 --- a/app/api/job/send-open-jobs-emails/route.ts +++ b/app/api/job/send-open-jobs-emails/route.ts @@ -23,7 +23,7 @@ export const POST = async (request: NextRequest) => { 'Successfully sent out open job notification emails to doctors.', ); await createNotificationLog({ - action: NotificationAction.NEW_JOBS_ALERT, + action: NotificationAction.DOCTOR_NEW_JOBS, status: 'SUCCESS', }); return NextResponse.json( @@ -39,7 +39,7 @@ export const POST = async (request: NextRequest) => { e, ); await createNotificationLog({ - action: NotificationAction.NEW_JOBS_ALERT, + action: NotificationAction.DOCTOR_NEW_JOBS, status: 'FAIL', comment: e?.message, }); diff --git a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx index a568eed..9ce91e8 100644 --- a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx @@ -24,9 +24,9 @@ export default async function AnalysisResultsPage({ }) { const account = await loadCurrentUserAccount(); - const { id: analysisResponseId } = await params; + const { id: analysisOrderId } = await params; - const analysisResponse = await loadUserAnalysis(Number(analysisResponseId)); + const analysisResponse = await loadUserAnalysis(Number(analysisOrderId)); if (!account?.id || !analysisResponse) { return null; diff --git a/lib/services/account.service.ts b/lib/services/account.service.ts index f958c72..87eac94 100644 --- a/lib/services/account.service.ts +++ b/lib/services/account.service.ts @@ -1,6 +1,5 @@ import type { Tables } from '@/packages/supabase/src/database.types'; -import { AccountWithParams } from '@kit/accounts/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; @@ -26,6 +25,19 @@ export async function getAccount(id: string): Promise { return data as unknown as AccountWithMemberships; } +export async function getUserContactAdmin(userId: string) { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('accounts') + .select('name, last_name, email, preferred_locale') + .eq('primary_owner_user_id', userId) + .eq('is_personal_account', true) + .single() + .throwOnError(); + + return data; +} + export async function getAccountAdmin({ primaryOwnerUserId, }: { diff --git a/lib/services/audit/notificationEntries.service.ts b/lib/services/audit/notificationEntries.service.ts index f83a736..980de2e 100644 --- a/lib/services/audit/notificationEntries.service.ts +++ b/lib/services/audit/notificationEntries.service.ts @@ -2,9 +2,12 @@ import { Database } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; export enum NotificationAction { - DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED', - NEW_JOBS_ALERT = 'NEW_JOBS_ALERT', - PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT', + DOCTOR_NEW_JOBS = 'DOCTOR_NEW_JOBS', + DOCTOR_PATIENT_RESULTS_RECEIVED = 'DOCTOR_PATIENT_RESULTS_RECEIVED', + PATIENT_DOCTOR_FEEDBACK_RECEIVED = 'PATIENT_DOCTOR_FEEDBACK_RECEIVED', + PATIENT_ORDER_PROCESSING = 'PATIENT_ORDER_PROCESSING', + PATIENT_FIRST_RESULTS_RECEIVED = 'PATIENT_FIRST_RESULTS_RECEIVED', + PATIENT_FULL_RESULTS_RECEIVED = 'PATIENT_FULL_RESULTS_RECEIVED', } export const createNotificationLog = async ({ diff --git a/lib/services/audit/pageView.service.ts b/lib/services/audit/pageView.service.ts index efac5db..aa06aec 100644 --- a/lib/services/audit/pageView.service.ts +++ b/lib/services/audit/pageView.service.ts @@ -37,7 +37,6 @@ export const createPageViewLog = async ({ account_id: accountId, action, changed_by: user.id, - extra_data: extraData, }) .throwOnError(); } catch (error) { diff --git a/lib/services/mailer.service.ts b/lib/services/mailer.service.ts index b4a7ecc..8e2a7ea 100644 --- a/lib/services/mailer.service.ts +++ b/lib/services/mailer.service.ts @@ -13,7 +13,7 @@ type EmailTemplate = { subject: string; }; -type EmailRenderer = (params: T) => Promise; +export type EmailRenderer = (params: T) => Promise; export const sendEmailFromTemplate = async ( renderer: EmailRenderer, diff --git a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts index 5abe3de..9299ae1 100644 --- a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts +++ b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts @@ -1,20 +1,7 @@ import { SupabaseClient } from '@supabase/supabase-js'; -import { - renderAllResultsReceivedEmail, - renderFirstResultsReceivedEmail, -} from '@kit/email-templates'; import { Database } from '@kit/supabase/database'; -import { - getAssignedDoctorAccount, - getDoctorAccounts, -} from '../../../../../lib/services/account.service'; -import { - NotificationAction, - createNotificationLog, -} from '../../../../../lib/services/audit/notificationEntries.service'; -import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service'; import { RecordChange, Tables } from '../record-change.type'; export function createDatabaseWebhookRouterService( @@ -113,58 +100,13 @@ class DatabaseWebhookRouterService { return; } - let action; - try { - const data = { - analysisOrderId: record.id, - language: 'et', - }; + const { createAnalysisOrderWebhooksService } = await import( + '@kit/notifications/webhooks/analysis-order-notifications.service' + ); - if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') { - action = NotificationAction.NEW_JOBS_ALERT; + const service = createAnalysisOrderWebhooksService(); - const doctorAccounts = await getDoctorAccounts(); - const doctorEmails: string[] = doctorAccounts - .map(({ email }) => email) - .filter((email): email is string => !!email); - - await sendEmailFromTemplate( - renderFirstResultsReceivedEmail, - data, - doctorEmails, - ); - } else if (record.status === 'FULL_ANALYSIS_RESPONSE') { - action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT; - const doctorAccount = await getAssignedDoctorAccount(record.id); - const assignedDoctorEmail = doctorAccount?.email; - - if (!assignedDoctorEmail) { - return; - } - - await sendEmailFromTemplate( - renderAllResultsReceivedEmail, - data, - assignedDoctorEmail, - ); - } - - if (action) { - await createNotificationLog({ - action, - status: 'SUCCESS', - relatedRecordId: record.id, - }); - } - } catch (e: any) { - if (action) - await createNotificationLog({ - action, - status: 'FAIL', - comment: e?.message, - relatedRecordId: record.id, - }); - } + return service.handleStatusChangeWebhook(record); } } } diff --git a/packages/email-templates/src/emails/account-delete.email.tsx b/packages/email-templates/src/emails/account-delete.email.tsx index 78fea85..a98c682 100644 --- a/packages/email-templates/src/emails/account-delete.email.tsx +++ b/packages/email-templates/src/emails/account-delete.email.tsx @@ -49,33 +49,28 @@ export async function renderAccountDeleteEmail(props: Props) { - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`, { displayName: props.userDisplayName, })} - {t(`${namespace}:paragraph1`, { productName: props.productName, })} - {t(`${namespace}:paragraph2`)} - {t(`${namespace}:paragraph3`, { productName: props.productName, })} - {t(`${namespace}:paragraph4`, { productName: props.productName, diff --git a/packages/email-templates/src/emails/all-results-received.email.tsx b/packages/email-templates/src/emails/all-results-received.email.tsx index 0243fc4..0083376 100644 --- a/packages/email-templates/src/emails/all-results-received.email.tsx +++ b/packages/email-templates/src/emails/all-results-received.email.tsx @@ -5,7 +5,7 @@ import { Preview, Tailwind, Text, - render + render, } from '@react-email/components'; import { BodyStyle } from '../components/body-style'; @@ -46,11 +46,10 @@ export async function renderAllResultsReceivedEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`)} @@ -62,7 +61,6 @@ export async function renderAllResultsReceivedEmail({ > {t(`${namespace}:linkText`)} - {t(`${namespace}:ifLinksDisabled`)}{' '} {`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`} diff --git a/packages/email-templates/src/emails/company-offer.email.tsx b/packages/email-templates/src/emails/company-offer.email.tsx index f13308c..68a5adc 100644 --- a/packages/email-templates/src/emails/company-offer.email.tsx +++ b/packages/email-templates/src/emails/company-offer.email.tsx @@ -55,23 +55,19 @@ export async function renderCompanyOfferEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:companyName`)} {companyData.companyName} - {t(`${namespace}:contactPerson`)} {companyData.contactPerson} - {t(`${namespace}:email`)} {companyData.email} - {t(`${namespace}:phone`)} {companyData.phone || 'N/A'} diff --git a/packages/email-templates/src/emails/doctor-summary-received.email.tsx b/packages/email-templates/src/emails/doctor-summary-received.email.tsx index 69ce37e..19b2b65 100644 --- a/packages/email-templates/src/emails/doctor-summary-received.email.tsx +++ b/packages/email-templates/src/emails/doctor-summary-received.email.tsx @@ -2,6 +2,7 @@ import { Body, Head, Html, + Link, Preview, Tailwind, Text, @@ -11,7 +12,6 @@ import { import { BodyStyle } from '../components/body-style'; import CommonFooter from '../components/common-footer'; import { EmailContent } from '../components/content'; -import { EmailButton } from '../components/email-button'; import { EmailHeader } from '../components/header'; import { EmailHeading } from '../components/heading'; import { EmailWrapper } from '../components/wrapper'; @@ -20,12 +20,10 @@ import { initializeEmailI18n } from '../lib/i18n'; export async function renderDoctorSummaryReceivedEmail({ language, recipientName, - orderNr, analysisOrderId, }: { - language?: string; + language: string; recipientName: string; - orderNr: string; analysisOrderId: number; }) { const namespace = 'doctor-summary-received-email'; @@ -35,13 +33,9 @@ export async function renderDoctorSummaryReceivedEmail({ namespace: [namespace, 'common'], }); - const previewText = t(`${namespace}:previewText`, { - orderNr, - }); + const previewText = t(`${namespace}:previewText`); - const subject = t(`${namespace}:subject`, { - orderNr, - }); + const subject = t(`${namespace}:subject`); const html = await render( @@ -54,29 +48,26 @@ export async function renderDoctorSummaryReceivedEmail({ - - {previewText} - - + + {previewText} + - {t(`${namespace}:hello`, { - displayName: recipientName, - })} - - - {t(`${namespace}:summaryReceivedForOrder`, { orderNr })} + {t(`common:helloName`, { name: recipientName })} - - {t(`${namespace}:linkText`, { orderNr })} - - {t(`${namespace}:ifButtonDisabled`)}{' '} - {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + {t(`${namespace}:p1`)}{' '} + + {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + + {t(`${namespace}:p2`)} + {t(`${namespace}:p3`)} + {t(`${namespace}:p4`)} + diff --git a/packages/email-templates/src/emails/first-results-received.email.tsx b/packages/email-templates/src/emails/first-results-received.email.tsx index 4f9f371..40ba596 100644 --- a/packages/email-templates/src/emails/first-results-received.email.tsx +++ b/packages/email-templates/src/emails/first-results-received.email.tsx @@ -46,11 +46,10 @@ export async function renderFirstResultsReceivedEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`)} diff --git a/packages/email-templates/src/emails/invite.email.tsx b/packages/email-templates/src/emails/invite.email.tsx index e59ba72..cd91424 100644 --- a/packages/email-templates/src/emails/invite.email.tsx +++ b/packages/email-templates/src/emails/invite.email.tsx @@ -74,20 +74,17 @@ export async function renderInviteEmail(props: Props) { - - {heading} - - + + {heading} + {hello} - - {props.teamLogo && (
@@ -102,20 +99,16 @@ export async function renderInviteEmail(props: Props) {
)} - -
+
{joinTeam}
- {t(`${namespace}:copyPasteLink`)}{' '} {props.link} -
- {t(`${namespace}:invitationIntendedFor`, { invitedUserEmail: props.invitedUserEmail, diff --git a/packages/email-templates/src/emails/new-jobs-available.email.tsx b/packages/email-templates/src/emails/new-jobs-available.email.tsx index 23ca3f4..34fb7d9 100644 --- a/packages/email-templates/src/emails/new-jobs-available.email.tsx +++ b/packages/email-templates/src/emails/new-jobs-available.email.tsx @@ -6,7 +6,7 @@ import { Preview, Tailwind, Text, - render + render, } from '@react-email/components'; import { BodyStyle } from '../components/body-style'; @@ -50,11 +50,10 @@ export async function renderNewJobsAvailableEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`)} diff --git a/packages/email-templates/src/emails/order-processing.email.tsx b/packages/email-templates/src/emails/order-processing.email.tsx new file mode 100644 index 0000000..8a7afb0 --- /dev/null +++ b/packages/email-templates/src/emails/order-processing.email.tsx @@ -0,0 +1,90 @@ +import { + Body, + Head, + Html, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import CommonFooter from '../components/common-footer'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderOrderProcessingEmail({ + language, + recipientName, + partnerLocation, + isUrine, +}: { + language: string; + recipientName: string; + partnerLocation: string; + isUrine?: boolean; +}) { + const namespace = 'order-processing-email'; + + const { t } = await initializeEmailI18n({ + language, + namespace: [namespace, 'common'], + }); + + const previewText = t(`${namespace}:previewText`); + + const subject = t(`${namespace}:subject`); + + const p2 = t(`${namespace}:p2`); + const p4 = t(`${namespace}:p4`); + const p1Urine = t(`${namespace}:p1Urine`); + + const html = await render( + + + + + + {previewText} + + + + + + + {previewText} + + + {t(`common:helloName`, { name: recipientName })} + + + {t(`${namespace}:heading`)} + + {t(`${namespace}:p1`, { partnerLocation })} + + {t(`${namespace}:p3`)} + + {isUrine && ( + <> + + {t(`${namespace}:p2Urine`)} + + )} + {t(`${namespace}:p5`)} + {t(`${namespace}:p6`)} + + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/otp.email.tsx b/packages/email-templates/src/emails/otp.email.tsx index ae6db76..04a8b49 100644 --- a/packages/email-templates/src/emails/otp.email.tsx +++ b/packages/email-templates/src/emails/otp.email.tsx @@ -60,18 +60,17 @@ export async function renderOtpEmail(props: Props) { - - {heading} - - + + {heading} + {mainText} {otpText} -
+
diff --git a/packages/email-templates/src/emails/patient-first-results-received.email.tsx b/packages/email-templates/src/emails/patient-first-results-received.email.tsx new file mode 100644 index 0000000..adeac31 --- /dev/null +++ b/packages/email-templates/src/emails/patient-first-results-received.email.tsx @@ -0,0 +1,81 @@ +import { + Body, + Head, + Html, + Link, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import CommonFooter from '../components/common-footer'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderPatientFirstResultsReceivedEmail({ + language, + recipientName, + analysisOrderId, +}: { + language: string; + recipientName: string; + analysisOrderId: number; +}) { + const namespace = 'patient-first-results-received-email'; + + const { t } = await initializeEmailI18n({ + language, + namespace: [namespace, 'common'], + }); + + const previewText = t(`${namespace}:previewText`); + + const subject = t(`${namespace}:subject`); + + const html = await render( + + + + + + {previewText} + + + + + + + {previewText} + + + {t(`common:helloName`, { name: recipientName })} + + + {t(`${namespace}:p1`)}{' '} + + {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + + + {t(`${namespace}:p2`)} + {t(`${namespace}:p3`)} + {t(`${namespace}:p4`)} + + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/patient-full-results-received.email.tsx b/packages/email-templates/src/emails/patient-full-results-received.email.tsx new file mode 100644 index 0000000..6f15224 --- /dev/null +++ b/packages/email-templates/src/emails/patient-full-results-received.email.tsx @@ -0,0 +1,82 @@ +import { + Body, + Head, + Html, + Link, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import CommonFooter from '../components/common-footer'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderPatientFullResultsReceivedEmail({ + language, + recipientName, + analysisOrderId, +}: { + language: string; + recipientName: string; + analysisOrderId: number; +}) { + const namespace = 'patient-full-results-received-email'; + + const { t } = await initializeEmailI18n({ + language, + namespace: [namespace, 'common'], + }); + + const previewText = t(`${namespace}:previewText`); + + const subject = t(`${namespace}:subject`); + + const html = await render( + + + + + + {previewText} + + + + + + + {previewText} + + + {t(`common:helloName`, { name: recipientName })} + + + + {t(`${namespace}:p1`)}{' '} + + {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + + + {t(`${namespace}:p2`)} + {t(`${namespace}:p3`)} + + + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/synlab.email.tsx b/packages/email-templates/src/emails/synlab.email.tsx index 29ff7d5..3605ac7 100644 --- a/packages/email-templates/src/emails/synlab.email.tsx +++ b/packages/email-templates/src/emails/synlab.email.tsx @@ -34,7 +34,7 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) { const previewText = t(`${namespace}:previewText`, { analysisPackageName: props.analysisPackageName, }); - + const subject = t(`${namespace}:subject`, { analysisPackageName: props.analysisPackageName, }); @@ -70,15 +70,13 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) { - - {heading} - - + + {heading} + {hello} - {lines.map((line, index) => ( ))} - diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts index 83e3021..cae4d3f 100644 --- a/packages/email-templates/src/index.ts +++ b/packages/email-templates/src/index.ts @@ -7,3 +7,6 @@ export * from './emails/doctor-summary-received.email'; export * from './emails/new-jobs-available.email'; export * from './emails/first-results-received.email'; export * from './emails/all-results-received.email'; +export * from './emails/order-processing.email'; +export * from './emails/patient-first-results-received.email'; +export * from './emails/patient-full-results-received.email'; diff --git a/packages/email-templates/src/locales/en/doctor-summary-received-email.json b/packages/email-templates/src/locales/en/doctor-summary-received-email.json index ebefe9b..ed17242 100644 --- a/packages/email-templates/src/locales/en/doctor-summary-received-email.json +++ b/packages/email-templates/src/locales/en/doctor-summary-received-email.json @@ -1,8 +1,8 @@ { - "subject": "Doctor feedback to order {{orderNr}} received", - "previewText": "A doctor has submitted feedback on your analysis results.", - "hello": "Hello {{displayName}},", - "summaryReceivedForOrder": "A doctor has submitted feedback to your analysis results from order {{orderNr}}.", - "linkText": "View summary", - "ifButtonDisabled": "If clicking the button does not work, copy this link to your browser's url field:" -} \ No newline at end of file + "subject": "Doctor's summary has arrived", + "previewText": "The doctor has prepared a summary of the test results.", + "p1": "The doctor's summary has arrived:", + "p2": "It is recommended to have a comprehensive health check-up regularly, at least once a year, if you wish to maintain an active and fulfilling lifestyle.", + "p3": "MedReport makes it easy, convenient, and fast to view health data in one place and order health check-ups.", + "p4": "SYNLAB customer support phone: 17123" +} diff --git a/packages/email-templates/src/locales/en/order-processing-email.json b/packages/email-templates/src/locales/en/order-processing-email.json new file mode 100644 index 0000000..b3472f0 --- /dev/null +++ b/packages/email-templates/src/locales/en/order-processing-email.json @@ -0,0 +1,13 @@ +{ + "subject": "The referral has been sent to the laboratory. Please go to give samples.", + "heading": "Thank you for your order!", + "previewText": "The referral for tests has been sent to the laboratory.", + "p1": "The referral for tests has been sent to the laboratory digitally. Please go to give samples: {{partnerLocation}}.", + "p2": "If you are unable to go to the selected location to give samples, you may visit any other sampling point convenient for you - see locations and opening hours.", + "p3": "It is recommended to give samples preferably in the morning (before 12:00) and on an empty stomach without drinking or eating (you may drink water).", + "p4": "At the sampling point, please choose in the queue system: under referrals select specialist referral.", + "p5": "If you have any additional questions, please do not hesitate to contact us.", + "p6": "SYNLAB customer support phone: 17123", + "p1Urine": "The tests include a urine test. For the urine test, please collect the first morning urine.", + "p2Urine": "You can buy a sample container at the pharmacy and bring the sample with you (procedure performed at home), or ask for one at the sampling point (procedure performed in the point’s restroom)." +} diff --git a/packages/email-templates/src/locales/en/patient-first-results-received-email.json b/packages/email-templates/src/locales/en/patient-first-results-received-email.json new file mode 100644 index 0000000..1a77006 --- /dev/null +++ b/packages/email-templates/src/locales/en/patient-first-results-received-email.json @@ -0,0 +1,8 @@ +{ + "subject": "The first ordered test results have arrived", + "previewText": "The first test results have arrived.", + "p1": "The first test results have arrived:", + "p2": "We will send the next notification once all test results have been received in the system.", + "p3": "If you have any additional questions, please feel free to contact us.", + "p4": "SYNLAB customer support phone: 17123" +} diff --git a/packages/email-templates/src/locales/en/patient-full-results-received-email.json b/packages/email-templates/src/locales/en/patient-full-results-received-email.json new file mode 100644 index 0000000..8fd6ed2 --- /dev/null +++ b/packages/email-templates/src/locales/en/patient-full-results-received-email.json @@ -0,0 +1,7 @@ +{ + "subject": "All ordered test results have arrived. Awaiting doctor's summary.", + "previewText": "All test results have arrived.", + "p1": "All test results have arrived:", + "p2": "We will send the next notification once the doctor's summary has been prepared.", + "p3": "SYNLAB customer support phone: 17123" +} diff --git a/packages/email-templates/src/locales/et/common.json b/packages/email-templates/src/locales/et/common.json index fc58e08..8b41d33 100644 --- a/packages/email-templates/src/locales/et/common.json +++ b/packages/email-templates/src/locales/et/common.json @@ -4,5 +4,7 @@ "lines2": "E-mail: info@medreport.ee", "lines3": "Klienditugi: +372 5887 1517", "lines4": "www.medreport.ee" - } + }, + "helloName": "Tere, {{name}}", + "hello": "Tere" } \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/doctor-summary-received-email.json b/packages/email-templates/src/locales/et/doctor-summary-received-email.json index e7efdc3..9e81ab5 100644 --- a/packages/email-templates/src/locales/et/doctor-summary-received-email.json +++ b/packages/email-templates/src/locales/et/doctor-summary-received-email.json @@ -1,8 +1,8 @@ { - "subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}", - "previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.", - "hello": "Tere, {{displayName}}", - "summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.", - "linkText": "Vaata kokkuvõtet", - "ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:" + "subject": "Arsti kokkuvõte on saabunud", + "previewText": "Arst on koostanud kokkuvõte analüüsitulemustele.", + "p1": "Arsti kokkuvõte on saabunud:", + "p2": "Põhjalikul terviseuuringul on soovituslik käia regulaarselt, aga vähemalt üks kord aastas, kui soovite säilitada aktiivset ja täisväärtuslikku elustiili.", + "p3": "MedReport aitab lihtsalt, mugavalt ja kiirelt terviseandmeid ühest kohast vaadata ning tellida terviseuuringuid.", + "p4": "SYNLAB klienditoe telefon: 17123" } \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/order-processing-email.json b/packages/email-templates/src/locales/et/order-processing-email.json new file mode 100644 index 0000000..a9e57c0 --- /dev/null +++ b/packages/email-templates/src/locales/et/order-processing-email.json @@ -0,0 +1,13 @@ +{ + "subject": "Saatekiri on saadetud laborisse. Palun mine proove andma.", + "heading": "Täname tellimuse eest!", + "previewText": "Saatekiri uuringute tegemiseks on saadetud laborisse.", + "p1": "Saatekiri uuringute tegemiseks on saadetud laborisse digitaalselt. Palun mine proove andma: {{partnerLocation}}.", + "p2": "Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - vaata asukohti ja lahtiolekuaegasid.", + "p3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).", + "p4": "Proovivõtupunktis valige järjekorrasüsteemis: saatekirjad alt eriarsti saatekiri", + "p5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.", + "p6": "SYNLAB klienditoe telefon: 17123", + "p1Urine": "Analüüsides on ette nähtud uriinianalüüs. Uriinianalüüsiks võta hommikune esmane uriin.", + "p2Urine": "Proovitopsi võib soetada apteegist ja analüüsi kaasa võtta (teostada protseduur kodus) või küsida proovivõtupunktist (teostada protseduur proovipunkti wc-s)." +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/patient-first-results-received-email.json b/packages/email-templates/src/locales/et/patient-first-results-received-email.json new file mode 100644 index 0000000..7d87e78 --- /dev/null +++ b/packages/email-templates/src/locales/et/patient-first-results-received-email.json @@ -0,0 +1,8 @@ +{ + "subject": "Saabusid tellitud uuringute esimesed tulemused", + "previewText": "Esimesed uuringute tulemused on saabunud.", + "p1": "Esimesed uuringute tulemused on saabunud:", + "p2": "Saadame järgmise teavituse, kui kõik uuringute vastused on saabunud süsteemi.", + "p3": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.", + "p4": "SYNLAB klienditoe telefon: 17123" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/patient-full-results-received-email.json b/packages/email-templates/src/locales/et/patient-full-results-received-email.json new file mode 100644 index 0000000..4a1de1a --- /dev/null +++ b/packages/email-templates/src/locales/et/patient-full-results-received-email.json @@ -0,0 +1,7 @@ +{ + "subject": "Kõikide tellitud uuringute tulemused on saabunud. Ootab arsti kokkuvõtet.", + "previewText": "Kõikide uuringute tulemused on saabunud.", + "p1": "Kõikide uuringute tulemused on saabunud:", + "p2": "Saadame järgmise teavituse kui arsti kokkuvõte on koostatud.", + "p3": "SYNLAB klienditoe telefon: 17123" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/ru/doctor-summary-received-email.json b/packages/email-templates/src/locales/ru/doctor-summary-received-email.json index 09beb43..e233f55 100644 --- a/packages/email-templates/src/locales/ru/doctor-summary-received-email.json +++ b/packages/email-templates/src/locales/ru/doctor-summary-received-email.json @@ -1,8 +1,8 @@ { - "subject": "Получено заключение врача по заказу {{orderNr}}", - "previewText": "Врач отправил заключение по вашим результатам анализа.", - "hello": "Здравствуйте, {{displayName}}", - "summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.", - "linkText": "Посмотреть заключение", - "ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:" + "subject": "Заключение врача готово", + "previewText": "Врач подготовил заключение по результатам анализов.", + "p1": "Заключение врача готово:", + "p2": "Рекомендуется проходить комплексное обследование регулярно, но как минимум один раз в год, если вы хотите сохранить активный и полноценный образ жизни.", + "p3": "MedReport позволяет легко, удобно и быстро просматривать медицинские данные в одном месте и заказывать обследования.", + "p4": "Телефон службы поддержки SYNLAB: 17123" } \ No newline at end of file diff --git a/packages/email-templates/src/locales/ru/order-processing-email.json b/packages/email-templates/src/locales/ru/order-processing-email.json new file mode 100644 index 0000000..3a5d6ac --- /dev/null +++ b/packages/email-templates/src/locales/ru/order-processing-email.json @@ -0,0 +1,13 @@ +{ + "subject": "Направление отправлено в лабораторию. Пожалуйста, сдайте анализы.", + "heading": "Спасибо за заказ!", + "previewText": "Направление на обследование отправлено в лабораторию.", + "p1": "Направление на обследование было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: {{partnerLocation}}.", + "p2": "Если у вас нет возможности прийти в выбранный пункт сдачи анализов, вы можете обратиться в любой удобный для вас пункт – посмотреть адреса и часы работы.", + "p3": "Рекомендуется сдавать анализы утром (до 12:00) натощак, без еды и напитков (разрешается пить воду).", + "p4": "В пункте сдачи анализов выберите в системе очереди: в разделе направлениянаправление от специалиста.", + "p5": "Если у вас возникли дополнительные вопросы, пожалуйста, свяжитесь с нами.", + "p6": "Телефон службы поддержки SYNLAB: 17123", + "p1Urine": "В обследование входит анализ мочи. Для анализа необходимо собрать первую утреннюю мочу.", + "p2Urine": "Контейнер можно приобрести в аптеке и принести образец с собой (процедура проводится дома) или взять контейнер в пункте сдачи (процедура проводится в туалете пункта)." +} diff --git a/packages/email-templates/src/locales/ru/patient-first-results-received-email.json b/packages/email-templates/src/locales/ru/patient-first-results-received-email.json new file mode 100644 index 0000000..975934f --- /dev/null +++ b/packages/email-templates/src/locales/ru/patient-first-results-received-email.json @@ -0,0 +1,8 @@ +{ + "subject": "Поступили первые результаты заказанных исследований", + "previewText": "Первые результаты исследований поступили.", + "p1": "Первые результаты исследований поступили:", + "p2": "Мы отправим следующее уведомление, когда все результаты исследований будут получены в системе.", + "p3": "Если у вас возникнут дополнительные вопросы, пожалуйста, свяжитесь с нами.", + "p4": "Телефон службы поддержки SYNLAB: 17123" +} diff --git a/packages/email-templates/src/locales/ru/patient-full-results-received-email.json b/packages/email-templates/src/locales/ru/patient-full-results-received-email.json new file mode 100644 index 0000000..e47f161 --- /dev/null +++ b/packages/email-templates/src/locales/ru/patient-full-results-received-email.json @@ -0,0 +1,7 @@ +{ + "subject": "Все заказанные результаты исследований поступили. Ожидается заключение врача.", + "previewText": "Все результаты исследований поступили.", + "p1": "Все результаты исследований поступили:", + "p2": "Мы отправим следующее уведомление, когда заключение врача будет подготовлено.", + "p3": "Телефон службы поддержки SYNLAB: 17123" +} \ No newline at end of file diff --git a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts index 4553578..610b70a 100644 --- a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts +++ b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts @@ -126,7 +126,7 @@ export const giveFeedbackAction = doctorAction( if (isCompleted) { await createNotificationLog({ - action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED, + action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED, status: 'SUCCESS', relatedRecordId: analysisOrderId, }); @@ -136,7 +136,7 @@ export const giveFeedbackAction = doctorAction( } catch (e: any) { if (isCompleted) { await createNotificationLog({ - action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED, + action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED, status: 'FAIL', comment: e?.message, relatedRecordId: analysisOrderId, diff --git a/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts b/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts index 329d846..db8e2be 100644 --- a/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts +++ b/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts @@ -1,9 +1,9 @@ -import z from 'zod/v3'; import { Database } from '@kit/supabase/database'; +import z from 'zod'; export const doctorJobSelectSchema = z.object({ - userId: z.string().uuid(), + userId: z.uuid(), analysisOrderId: z.number(), }); export type DoctorJobSelect = z.infer; diff --git a/packages/features/notifications/package.json b/packages/features/notifications/package.json index 5355d69..df31c57 100644 --- a/packages/features/notifications/package.json +++ b/packages/features/notifications/package.json @@ -11,7 +11,8 @@ "exports": { "./api": "./src/server/api.ts", "./components": "./src/components/index.ts", - "./hooks": "./src/hooks/index.ts" + "./hooks": "./src/hooks/index.ts", + "./webhooks/*": "./src/server/services/webhooks/*.ts" }, "devDependencies": { "@kit/eslint-config": "workspace:*", diff --git a/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts b/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts new file mode 100644 index 0000000..643b1b7 --- /dev/null +++ b/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts @@ -0,0 +1,273 @@ +import { + renderAllResultsReceivedEmail, + renderFirstResultsReceivedEmail, + renderOrderProcessingEmail, + renderPatientFirstResultsReceivedEmail, + renderPatientFullResultsReceivedEmail, +} from '@kit/email-templates'; +import { getLogger } from '@kit/shared/logger'; +import { getFullName } from '@kit/shared/utils'; +import { Database } from '@kit/supabase/database'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +import { + getAssignedDoctorAccount, + getDoctorAccounts, + getUserContactAdmin, +} from '~/lib/services/account.service'; +import { + NotificationAction, + createNotificationLog, +} from '~/lib/services/audit/notificationEntries.service'; +import { + EmailRenderer, + sendEmailFromTemplate, +} from '~/lib/services/mailer.service'; + +type AnalysisOrder = Database['medreport']['Tables']['analysis_orders']['Row']; + +export function createAnalysisOrderWebhooksService() { + return new AnalysisOrderWebhooksService(); +} + +class AnalysisOrderWebhooksService { + private readonly namespace = 'analysis_orders.webhooks'; + + async handleStatusChangeWebhook(analysisOrder: AnalysisOrder) { + const logger = await getLogger(); + + const ctx = { + analysisOrderId: analysisOrder.id, + namespace: this.namespace, + }; + + logger.info(ctx, 'Received status change update. Processing...'); + let actions: NotificationAction[] = []; + try { + if (analysisOrder.status === 'PROCESSING') { + actions = [NotificationAction.PATIENT_ORDER_PROCESSING]; + await this.sendProcessingNotification(analysisOrder); + } + + if (analysisOrder.status === 'PARTIAL_ANALYSIS_RESPONSE') { + actions = [ + NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED, + NotificationAction.DOCTOR_NEW_JOBS, + ]; + + await this.sendPartialAnalysisResultsNotifications(analysisOrder); + } + + if (analysisOrder.status === 'FULL_ANALYSIS_RESPONSE') { + actions = [ + NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED, + NotificationAction.PATIENT_FULL_RESULTS_RECEIVED, + ]; + await this.sendFullAnalysisResultsNotifications(analysisOrder); + } + + if (actions.length) { + return logger.info(ctx, 'Status change notifications sent.'); + } + + logger.info(ctx, 'Status change processed. No notifications to send.'); + } catch (e: any) { + if (actions.length) + await Promise.all( + actions.map((action) => + createNotificationLog({ + action, + status: 'FAIL', + comment: e?.message, + relatedRecordId: analysisOrder.id, + }), + ), + ); + logger.error( + ctx, + `Error while processing status change: ${JSON.stringify(e)}`, + ); + } + } + + async sendProcessingNotification(analysisOrder: AnalysisOrder) { + const logger = await getLogger(); + const supabase = getSupabaseServerAdminClient(); + + const userContact = await getUserContactAdmin(analysisOrder.user_id); + + if (!userContact?.email) { + await createNotificationLog({ + action: NotificationAction.PATIENT_ORDER_PROCESSING, + status: 'FAIL', + comment: 'No email found for ' + analysisOrder.user_id, + relatedRecordId: analysisOrder.id, + }); + logger.warn( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'No email found ', + ); + return; + } + + const [{ data: medusaOrder }, { data: analysisElements }] = + await Promise.all([ + supabase + .from('order') + .select('id,metadata') + .eq('id', analysisOrder.medusa_order_id) + .single() + .throwOnError(), + supabase + .schema('medreport') + .from('analysis_elements') + .select('materialGroups:material_groups') + .in('id', analysisOrder.analysis_element_ids ?? []) + .throwOnError(), + ]); + + let isUrine = false; + for (const analysisElement of analysisElements ?? []) { + logger.info({ group: analysisElement.materialGroups ?? [] }); + + const containsUrineSample = (analysisElement.materialGroups ?? [])?.some( + (element) => + (element as { Materjal?: { MaterjaliNimi: string } })?.Materjal + ?.MaterjaliNimi === 'Uriin', + ); + + if (containsUrineSample) { + isUrine = true; + break; + } + } + + const orderMetadata = medusaOrder.metadata as { + partner_location_name?: string; + }; + + await sendEmailFromTemplate( + renderOrderProcessingEmail, + { + language: userContact.preferred_locale ?? 'et', + recipientName: getFullName(userContact.name, userContact.last_name), + partnerLocation: orderMetadata.partner_location_name ?? 'SYNLAB', + isUrine, + }, + userContact.email, + ); + + return createNotificationLog({ + action: NotificationAction.PATIENT_ORDER_PROCESSING, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + } + + async sendPatientUpdateNotification( + analysisOrder: AnalysisOrder, + template: EmailRenderer, + action: NotificationAction, + ) { + const logger = await getLogger(); + + const userContact = await getUserContactAdmin(analysisOrder.user_id); + + if (userContact?.email) { + await sendEmailFromTemplate( + template, + { + analysisOrderId: analysisOrder.id, + recipientName: getFullName(userContact.name, userContact.last_name), + language: userContact.preferred_locale ?? 'et', + }, + userContact.email, + ); + await createNotificationLog({ + action, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + logger.info( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'Sent notification email', + ); + } else { + await createNotificationLog({ + action, + status: 'FAIL', + comment: 'No email found for ' + analysisOrder.user_id, + relatedRecordId: analysisOrder.id, + }); + logger.warn( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'No email found ', + ); + } + } + + async sendPartialAnalysisResultsNotifications(analysisOrder: AnalysisOrder) { + const logger = await getLogger(); + + await this.sendPatientUpdateNotification( + analysisOrder, + renderPatientFirstResultsReceivedEmail, + NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED, + ); + + const doctorAccounts = await getDoctorAccounts(); + const doctorEmails: string[] = doctorAccounts + .map(({ email }) => email) + .filter((email): email is string => !!email); + + await sendEmailFromTemplate( + renderFirstResultsReceivedEmail, + { + analysisOrderId: analysisOrder.id, + language: 'et', + }, + doctorEmails, + ); + + logger.info( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'Sent out partial analysis results notifications for doctors', + ); + + await createNotificationLog({ + action: NotificationAction.DOCTOR_NEW_JOBS, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + } + + async sendFullAnalysisResultsNotifications(analysisOrder: AnalysisOrder) { + await this.sendPatientUpdateNotification( + analysisOrder, + renderPatientFullResultsReceivedEmail, + NotificationAction.PATIENT_FULL_RESULTS_RECEIVED, + ); + + const doctorAccount = await getAssignedDoctorAccount(analysisOrder.id); + const assignedDoctorEmail = doctorAccount?.email; + + if (!assignedDoctorEmail) { + return; + } + + await sendEmailFromTemplate( + renderAllResultsReceivedEmail, + { + analysisOrderId: analysisOrder.id, + language: 'et', + }, + assignedDoctorEmail, + ); + + return createNotificationLog({ + action: NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + } +} From 1283094a91458334fa93a2b7ce518e02ce2278fe Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 11:56:52 +0300 Subject: [PATCH 28/58] newer pino-pretty@13.1.1 has new strip-json-comments version that conflicts with others and causes error --- package.json | 2 +- pnpm-lock.yaml | 75 +++++++++++++++++++++++++++++++------------------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 18d2a21..33ec639 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "babel-plugin-react-compiler": "19.1.0-rc.2", "cssnano": "^7.0.7", "dotenv": "^16.5.0", - "pino-pretty": "^13.0.0", + "pino-pretty": "13.0.0", "prettier": "^3.5.3", "supabase": "^2.30.4", "tailwindcss": "4.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4634f0..77d0440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,8 +220,8 @@ importers: specifier: ^16.5.0 version: 16.6.1 pino-pretty: - specifier: ^13.0.0 - version: 13.1.1 + specifier: 13.0.0 + version: 13.0.0 prettier: specifier: ^3.5.3 version: 3.6.2 @@ -478,10 +478,10 @@ importers: dependencies: '@keystatic/core': specifier: 0.5.47 - 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) + 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) '@keystatic/next': specifier: ^5.0.4 - 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) + 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) '@markdoc/markdoc': specifier: ^0.5.1 version: 0.5.4(@types/react@19.1.4)(react@19.1.0) @@ -1272,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.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) + 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) import-in-the-middle: specifier: 1.13.2 version: 1.13.2 @@ -8893,8 +8893,8 @@ packages: pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} - pino-pretty@13.1.1: - resolution: {integrity: sha512-TNNEOg0eA0u+/WuqH0MH0Xui7uqVk9D74ESOpjtebSQYbNWJk/dIxCXIxFsNfeN53JmtWqYHP2OrIZjT/CBEnA==} + pino-pretty@13.0.0: + resolution: {integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==} hasBin: true pino-std-serializers@7.0.0: @@ -9595,8 +9595,8 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - secure-json-parse@4.0.0: - resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -9820,10 +9820,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - stripe@18.5.0: resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} engines: {node: '>=12.*'} @@ -11461,7 +11457,7 @@ snapshots: '@juggle/resize-observer@3.4.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)': + '@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)': dependencies: '@babel/runtime': 7.27.6 '@emotion/css': 11.13.5 @@ -11554,18 +11550,18 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - 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) + 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) transitivePeerDependencies: - supports-color - '@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)': + '@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)': 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.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) + '@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) '@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) @@ -11636,13 +11632,13 @@ snapshots: - next - supports-color - '@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)': + '@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)': dependencies: '@babel/runtime': 7.27.6 - '@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) + '@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) '@types/react': 19.1.4 chokidar: 3.6.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) + 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 react-dom: 19.1.0(react@19.1.0) server-only: 0.0.1 @@ -17236,7 +17232,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.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)': + '@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)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.34.0 @@ -17249,7 +17245,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.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) + 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) resolve: 1.22.8 rollup: 4.35.0 stacktrace-parser: 0.1.11 @@ -21273,6 +21269,31 @@ 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 @@ -21506,7 +21527,7 @@ snapshots: dependencies: split2: 4.2.0 - pino-pretty@13.1.1: + pino-pretty@13.0.0: dependencies: colorette: 2.0.20 dateformat: 4.6.3 @@ -21518,9 +21539,9 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 2.0.0 pump: 3.0.3 - secure-json-parse: 4.0.0 + secure-json-parse: 2.7.0 sonic-boom: 4.2.0 - strip-json-comments: 5.0.3 + strip-json-comments: 3.1.1 pino-std-serializers@7.0.0: {} @@ -22474,7 +22495,7 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 - secure-json-parse@4.0.0: {} + secure-json-parse@2.7.0: {} selderee@0.11.0: dependencies: @@ -22796,8 +22817,6 @@ snapshots: strip-json-comments@3.1.1: {} - strip-json-comments@5.0.3: {} - stripe@18.5.0(@types/node@24.3.0): dependencies: qs: 6.14.0 From 85c72e777ba7433adff8d331a29d986262ce7cf0 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 12:05:03 +0300 Subject: [PATCH 29/58] add missing translations --- public/locales/en/cart.json | 3 ++- public/locales/et/cart.json | 3 ++- public/locales/ru/cart.json | 11 +++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 84221a4..0c9af9a 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -64,7 +64,8 @@ "orderDate": "Order date", "orderNumber": "Order number", "orderStatus": "Order status", - "paymentStatus": "Payment status" + "paymentStatus": "Payment status", + "discount": "Discount" }, "montonioCallback": { "title": "Montonio checkout", diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index a7bcb19..fe87f7b 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -68,7 +68,8 @@ "orderDate": "Tellimuse kuupäev", "orderNumber": "Tellimuse number", "orderStatus": "Tellimuse olek", - "paymentStatus": "Makse olek" + "paymentStatus": "Makse olek", + "discount": "Soodus" }, "montonioCallback": { "title": "Montonio makseprotsess", diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 9aaeb3f..7f3c536 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -28,7 +28,13 @@ "label": "Добавить промокод", "apply": "Применить", "subtitle": "Если хотите, можете добавить промокод", - "placeholder": "Введите промокод" + "placeholder": "Введите промокод", + "remove": "Удалить промокод", + "appliedCodes": "Примененные промокоды:", + "removeError": "Не удалось удалить промокод", + "removeSuccess": "Промокод удален", + "addError": "Не удалось применить промокод", + "addSuccess": "Промокод применен" }, "items": { "synlabAnalyses": { @@ -61,7 +67,8 @@ "orderDate": "Дата заказа", "orderNumber": "Номер заказа", "orderStatus": "Статус заказа", - "paymentStatus": "Статус оплаты" + "paymentStatus": "Статус оплаты", + "discount": "Скидка" }, "montonioCallback": { "title": "Процесс оплаты Montonio", From 514cb3bf7bbd027c897a60f81d55968f56abc9e0 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 12:36:19 +0300 Subject: [PATCH 30/58] redirect homepage to website in production. support lang param --- middleware.ts | 12 ++++++++++++ packages/ui/src/makerkit/language-selector.tsx | 1 + 2 files changed, 13 insertions(+) diff --git a/middleware.ts b/middleware.ts index 1507e71..0c0e3b3 100644 --- a/middleware.ts +++ b/middleware.ts @@ -27,6 +27,8 @@ const getUser = (request: NextRequest, response: NextResponse) => { export async function middleware(request: NextRequest) { const secureHeaders = await createResponseWithSecureHeaders(); const response = NextResponse.next(secureHeaders); + const url = new URL(request.url); + const lang = url.searchParams.get('lang'); // set a unique request ID for each request // this helps us log and trace requests @@ -35,6 +37,10 @@ export async function middleware(request: NextRequest) { // apply CSRF protection for mutating requests const csrfResponse = await withCsrfMiddleware(request, response); + if (lang) { + csrfResponse.cookies.set('lang', lang); + } + // handle patterns for specific routes const handlePattern = matchUrlPattern(request.url); @@ -176,6 +182,12 @@ function getPatterns() { return NextResponse.redirect( new URL(pathsConfig.app.home, req.nextUrl.origin).href, ); + } else { + if (process.env.NODE_ENV === 'production') { + return NextResponse.redirect( + new URL('https://medreport.ee', req.nextUrl.origin).href, + ); + } } }, }, diff --git a/packages/ui/src/makerkit/language-selector.tsx b/packages/ui/src/makerkit/language-selector.tsx index f7542f0..6ac5855 100644 --- a/packages/ui/src/makerkit/language-selector.tsx +++ b/packages/ui/src/makerkit/language-selector.tsx @@ -53,6 +53,7 @@ export function LanguageSelector({ } if (!userId) { + localStorage.setItem('lang', locale); return i18n.changeLanguage(locale); } From ebab0556ba465c6eda1906feb6106f2d9f3b3a2c Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 12:39:00 +0300 Subject: [PATCH 31/58] change else to else if --- middleware.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/middleware.ts b/middleware.ts index 0c0e3b3..0c04a83 100644 --- a/middleware.ts +++ b/middleware.ts @@ -182,12 +182,10 @@ function getPatterns() { return NextResponse.redirect( new URL(pathsConfig.app.home, req.nextUrl.origin).href, ); - } else { - if (process.env.NODE_ENV === 'production') { - return NextResponse.redirect( - new URL('https://medreport.ee', req.nextUrl.origin).href, - ); - } + } else if (process.env.NODE_ENV === 'production') { + return NextResponse.redirect( + new URL('https://medreport.ee', req.nextUrl.origin).href, + ); } }, }, From 7d208b41f2adbfdeba904a495ebb97e9c7f8b931 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 13:42:44 +0300 Subject: [PATCH 32/58] update naming to be clearer --- app/api/order/medipost-test-response/route.ts | 8 ++++---- .../(dashboard)/cart/montonio-callback/actions.ts | 4 ++-- .../(dashboard)/order/[orderId]/confirmed/page.tsx | 4 ++-- app/home/(user)/(dashboard)/order/[orderId]/page.tsx | 4 ++-- lib/services/medipost.service.ts | 12 ++++++------ lib/services/order.service.ts | 8 ++++---- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts index 2302631..9ce8c41 100644 --- a/app/api/order/medipost-test-response/route.ts +++ b/app/api/order/medipost-test-response/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getOrder } from "~/lib/services/order.service"; +import { getAnalysisOrder } from "~/lib/services/order.service"; import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service"; import { retrieveOrder } from "@lib/data"; import { getAccountAdmin } from "~/lib/services/account.service"; @@ -14,9 +14,9 @@ export async function POST(request: Request) { const { medusaOrderId } = await request.json(); const medusaOrder = await retrieveOrder(medusaOrderId) - const medreportOrder = await getOrder({ medusaOrderId }); + const analysisOrder = await getAnalysisOrder({ medusaOrderId }); - const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); + const account = await getAccountAdmin({ primaryOwnerUserId: analysisOrder.user_id }); const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder }); console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`); @@ -30,7 +30,7 @@ export async function POST(request: Request) { orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[], orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[], orderId: medusaOrderId, - orderCreatedAt: new Date(medreportOrder.created_at), + orderCreatedAt: new Date(analysisOrder.created_at), }); try { diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index 11eca6f..def72dd 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -7,7 +7,7 @@ import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user- import { listProductTypes } from "@lib/data/products"; import { placeOrder, retrieveCart } from "@lib/data/cart"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; -import { createOrder } from '~/lib/services/order.service'; +import { createAnalysisOrder } from '~/lib/services/order.service'; import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -114,7 +114,7 @@ export async function processMontonioCallback(orderToken: string) { const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); - const orderId = await createOrder({ medusaOrder, orderedAnalysisElements }); + const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements }); const { productTypes } = await listProductTypes(); const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); diff --git a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx index 21e1829..d72c530 100644 --- a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx +++ b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx @@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; -import { getOrder } from '~/lib/services/order.service'; +import { getAnalysisOrder } from '~/lib/services/order.service'; import { retrieveOrder } from '@lib/data/orders'; import { pathsConfig } from '@kit/shared/config'; @@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: { }) { const params = await props.params; - const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null); + const order = await getAnalysisOrder({ orderId: Number(params.orderId) }).catch(() => null); if (!order) { redirect(pathsConfig.app.myOrders); } diff --git a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx index 4b717d5..c84fb99 100644 --- a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx +++ b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx @@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; -import { getOrder } from '~/lib/services/order.service'; +import { getAnalysisOrder } from '~/lib/services/order.service'; import { retrieveOrder } from '@lib/data/orders'; import { pathsConfig } from '@kit/shared/config'; @@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: { }) { const params = await props.params; - const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null); + const order = await getAnalysisOrder({ orderId: Number(params.orderId) }).catch(() => null); if (!order) { redirect(pathsConfig.app.myOrders); } diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 82db51d..a096083 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -24,7 +24,7 @@ import { XMLParser } from 'fast-xml-parser'; import { Tables } from '@kit/supabase/database'; import { createAnalysisGroup } from './analysis-group.service'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; -import { getOrder, updateOrderStatus } from './order.service'; +import { getAnalysisOrder, updateAnalysisOrderStatus } from './order.service'; import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalyses } from './analyses.service'; import { getAccountAdmin } from './account.service'; @@ -242,7 +242,7 @@ export async function readPrivateMessageResponse({ let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; try { - order = await getOrder({ medusaOrderId }); + order = await getAnalysisOrder({ medusaOrderId }); } catch (e) { await deletePrivateMessage(privateMessage.messageId); throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`); @@ -251,11 +251,11 @@ export async function readPrivateMessageResponse({ const status = await syncPrivateMessage({ messageResponse, order }); if (status.isPartial) { - await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); hasAnalysisResponse = true; hasPartialAnalysisResponse = true; } else if (status.isCompleted) { - await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); await deletePrivateMessage(privateMessage.messageId); hasAnalysisResponse = true; hasFullAnalysisResponse = true; @@ -588,7 +588,7 @@ export async function sendOrderToMedipost({ medusaOrderId: string; orderedAnalysisElements: OrderedAnalysisElement[]; }) { - const medreportOrder = await getOrder({ medusaOrderId }); + const medreportOrder = await getAnalysisOrder({ medusaOrderId }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const orderedAnalysesIds = orderedAnalysisElements @@ -668,7 +668,7 @@ export async function sendOrderToMedipost({ hasAnalysisResults: false, medusaOrderId, }); - await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); } export async function getOrderedAnalysisIds({ diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 487153a..f1eae85 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -5,7 +5,7 @@ import type { StoreOrder } from '@medusajs/types'; export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; -export async function createOrder({ +export async function createAnalysisOrder({ medusaOrder, orderedAnalysisElements, }: { @@ -38,7 +38,7 @@ export async function createOrder({ return orderResult.data.id; } -export async function updateOrder({ +export async function updateAnalysisOrder({ orderId, orderStatus, }: { @@ -56,7 +56,7 @@ export async function updateOrder({ .throwOnError(); } -export async function updateOrderStatus({ +export async function updateAnalysisOrderStatus({ orderId, medusaOrderId, orderStatus, @@ -80,7 +80,7 @@ export async function updateOrderStatus({ .throwOnError(); } -export async function getOrder({ +export async function getAnalysisOrder({ medusaOrderId, orderId, }: { From 165d44b13fed45ee39d00ae1cd4c562077c53208 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 14:02:34 +0300 Subject: [PATCH 33/58] prepare montonio callback logic to send email for individual analysis order - skip confusing error log for orders without analysis packages --- .../cart/montonio-callback/actions.ts | 141 +++++++++++++----- 1 file changed, 102 insertions(+), 39 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index def72dd..89c8dc3 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -7,13 +7,15 @@ import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user- import { listProductTypes } from "@lib/data/products"; import { placeOrder, retrieveCart } from "@lib/data/cart"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; -import { createAnalysisOrder } from '~/lib/services/order.service'; +import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import { AccountWithParams } from '@kit/accounts/api'; +import type { AccountWithParams } from '@kit/accounts/api'; +import type { StoreOrder } from '@medusajs/types'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; +const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; const MONTONIO_PAID_STATUS = 'PAID'; const env = () => z @@ -38,14 +40,12 @@ const sendEmail = async ({ account, email, analysisPackageName, - personName, partnerLocationName, language, }: { - account: AccountWithParams, + account: Pick, email: string, analysisPackageName: string, - personName: string, partnerLocationName: string, language: string, }) => { @@ -58,7 +58,7 @@ const sendEmail = async ({ const { html, subject } = await renderSynlabAnalysisPackageEmail({ analysisPackageName, - personName, + personName: account.name, partnerLocationName, language, }); @@ -83,9 +83,7 @@ const sendEmail = async ({ } } -export async function processMontonioCallback(orderToken: string) { - const { language } = await createI18nServerInstance(); - +async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; const decoded = jwt.verify(orderToken, secretKey, { @@ -96,50 +94,115 @@ export async function processMontonioCallback(orderToken: string) { throw new Error("Payment not successful"); } + return decoded; +} + +async function getCartByOrderToken(decoded: MontonioOrderToken) { + const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); + if (!cartId) { + throw new Error("Cart ID not found"); + } + const cart = await retrieveCart(cartId); + if (!cart) { + throw new Error("Cart not found"); + } + return cart; +} + +async function getOrderResultParameters(medusaOrder: StoreOrder) { + const { productTypes } = await listProductTypes(); + const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); + const analysisType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE); + + const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); + const analysisItems = medusaOrder.items?.filter(({ product_type_id }) => product_type_id === analysisType?.id); + + return { + medusaOrderId: medusaOrder.id, + email: medusaOrder.email, + analysisPackageOrder: analysisPackageOrderItem + ? { + partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', + analysisPackageName: analysisPackageOrderItem?.title ?? '', + } + : null, + analysisItemsOrder: Array.isArray(analysisItems) && analysisItems.length > 0 + ? analysisItems.map(({ product }) => ({ + analysisName: product?.title ?? '', + analysisId: product?.metadata?.analysisIdOriginal as string ?? '', + })) + : null, + }; +} + +async function sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, +}: { + account: AccountWithParams, + email: string, + analysisPackageOrder: { + partnerLocationName: string, + analysisPackageName: string, + }, +}) { + const { language } = await createI18nServerInstance(); + const { analysisPackageName, partnerLocationName } = analysisPackageOrder; + try { + await sendEmail({ + account: { id: account.id, name: account.name }, + email, + analysisPackageName, + partnerLocationName, + language, + }); + } catch (error) { + console.error("Failed to send email", error); + } +} + +export async function processMontonioCallback(orderToken: string) { const account = await loadCurrentUserAccount(); if (!account) { throw new Error("Account not found in context"); } try { - const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); - if (!cartId) { - throw new Error("Cart ID not found"); - } + const decoded = await decodeOrderToken(orderToken); + const cart = await getCartByOrderToken(decoded); - const cart = await retrieveCart(cartId); - if (!cart) { - throw new Error("Cart not found"); - } - - const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); + const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); + + try { + const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id }); + console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`); + return { success: true, orderId: existingAnalysisOrder.id }; + } catch { + // ignored + } + const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements }); + const orderResult = await getOrderResultParameters(medusaOrder); - const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); - const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; - const orderResult = { - medusaOrderId: medusaOrder.id, - email: medusaOrder.email, - partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - orderedAnalysisElements, - }; + if (email) { + if (analysisPackageOrder) { + await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder }); + } else { + console.info(`Order has no analysis package, skipping email.`); + } - const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult; - const personName = account.name; - - if (email && analysisPackageName) { - try { - await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language }); - } catch (error) { - console.error("Failed to send email", error); + if (analysisItemsOrder) { + // @TODO send email for separate analyses + console.warn(`Order has analysis items, but no email template exists yet`); + } else { + console.info(`Order has no analysis items, skipping email.`); } } else { - // @TODO send email for separate analyses - console.error("Missing email or analysisPackageName", orderResult); + console.error("Missing email to send order result email", orderResult); } await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); From 86a5931b66e4a802bec55ff61ffddfbb3bebed21 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 15:25:31 +0300 Subject: [PATCH 34/58] update condition for production redirect --- middleware.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/middleware.ts b/middleware.ts index 0c04a83..b000a1c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -182,7 +182,11 @@ function getPatterns() { return NextResponse.redirect( new URL(pathsConfig.app.home, req.nextUrl.origin).href, ); - } else if (process.env.NODE_ENV === 'production') { + } else if ( + !['test', 'localhost'].some((pathString) => + process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString), + ) + ) { return NextResponse.redirect( new URL('https://medreport.ee', req.nextUrl.origin).href, ); From 831e60c3c1a1e927d6ad155544fa4e62c9dde6f3 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 15:48:02 +0300 Subject: [PATCH 35/58] fix case when variant has no metadata and no package elements are displayed --- utils/medusa-product.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts index c505608..51ab2d6 100644 --- a/utils/medusa-product.ts +++ b/utils/medusa-product.ts @@ -20,12 +20,24 @@ export const getAnalysisElementMedusaProductIds = (products: Pick { const value = (product as Product)?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"'); const value_variant = (product as Product)?.variant?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"'); + + const result: string[] = []; try { - return [...JSON.parse(value as string), ...JSON.parse(value_variant as string)]; + if (value) { + result.push(...JSON.parse(value as string)); + } + } catch (e) { + console.error("Failed to parse analysisElementMedusaProductIds from analysis package variant, possibly invalid format", e); + } + try { + if (value_variant) { + result.push(...JSON.parse(value_variant as string)); + } } catch (e) { console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e); - return []; } + + return result; }) .filter(Boolean) as string[]; From 95452de88b6656df589e70866ab7076e580fbf5c Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:31:23 +0300 Subject: [PATCH 36/58] prefer using providers conf from supabase instead of env --- .../site-header-account-section.tsx | 37 +++-- .../sign-in/components/PasswordOption.tsx | 8 +- app/auth/sign-in/page.tsx | 18 ++- app/auth/sign-up/page.tsx | 6 +- app/home/(user)/_components/dashboard.tsx | 11 +- lib/utils.ts | 2 +- .../components/sign-in-methods-container.tsx | 12 +- .../components/sign-up-methods-container.tsx | 2 - .../src/config/auth-providers.service.ts | 137 ++++++++++++++++++ .../shared/src/config/dynamic-auth.config.ts | 102 +++++++++++++ packages/shared/src/config/index.ts | 3 + packages/shared/src/hooks/index.ts | 1 + packages/shared/src/hooks/use-auth-config.ts | 76 ++++++++++ 13 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 packages/shared/src/config/auth-providers.service.ts create mode 100644 packages/shared/src/config/dynamic-auth.config.ts create mode 100644 packages/shared/src/hooks/use-auth-config.ts diff --git a/app/(marketing)/_components/site-header-account-section.tsx b/app/(marketing)/_components/site-header-account-section.tsx index ff7ad03..dd62722 100644 --- a/app/(marketing)/_components/site-header-account-section.tsx +++ b/app/(marketing)/_components/site-header-account-section.tsx @@ -13,7 +13,8 @@ import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; -import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config'; +import { featureFlagsConfig, pathsConfig } from '@kit/shared/config'; +import { useAuthConfig } from '@kit/shared/hooks'; const ModeToggle = dynamic(() => import('@kit/ui/mode-toggle').then((mod) => ({ @@ -57,6 +58,8 @@ export function SiteHeaderAccountSection({ } function AuthButtons() { + const { config } = useAuthConfig(); + return (
@@ -65,21 +68,25 @@ function AuthButtons() {
-
- + {config && ( +
+ {(config.providers.password || config.providers.oAuth.length > 0) && ( + + )} - {authConfig.providers.password && ( - - )} -
+ {config.providers.password && ( + + )} +
+ )}
); } diff --git a/app/auth/sign-in/components/PasswordOption.tsx b/app/auth/sign-in/components/PasswordOption.tsx index 5ef56a4..4f0b59d 100644 --- a/app/auth/sign-in/components/PasswordOption.tsx +++ b/app/auth/sign-in/components/PasswordOption.tsx @@ -1,7 +1,7 @@ import Link from 'next/link'; -import { SignInMethodsContainer } from '@kit/auth/sign-in'; -import { authConfig, pathsConfig } from '@kit/shared/config'; +import { Providers, SignInMethodsContainer } from '@kit/auth/sign-in'; +import { pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { Trans } from '@kit/ui/trans'; @@ -9,9 +9,11 @@ import { Trans } from '@kit/ui/trans'; export default function PasswordOption({ inviteToken, returnPath, + providers, }: { inviteToken?: string; returnPath?: string; + providers: Providers; }) { const signUpPath = pathsConfig.auth.signUp + @@ -39,7 +41,7 @@ export default function PasswordOption({
diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index a82d4a7..4799f7d 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -1,4 +1,4 @@ -import { pathsConfig, authConfig } from '@kit/shared/config'; +import { getServerAuthConfig, pathsConfig } from '@kit/shared/config'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; @@ -24,11 +24,23 @@ async function SignInPage({ searchParams }: SignInPageProps) { const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } = await searchParams; + const authConfig = await getServerAuthConfig(); + if (authConfig.providers.password) { - return ; + return ( + + ); } - return ; + if (authConfig.providers.oAuth.includes('keycloak')) { + return ; + } + + return null; } export default withI18n(SignInPage); diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 0394078..52d77e9 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { SignUpMethodsContainer } from '@kit/auth/sign-up'; -import { authConfig, pathsConfig } from '@kit/shared/config'; +import { getServerAuthConfig, pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { Trans } from '@kit/ui/trans'; @@ -38,6 +38,8 @@ async function SignUpPage({ searchParams }: Props) { pathsConfig.auth.signIn + (inviteToken ? `?invite_token=${inviteToken}` : ''); + const authConfig = await getServerAuthConfig(); + if (!authConfig.providers.password) { return redirect('/'); } @@ -56,9 +58,9 @@ async function SignUpPage({ searchParams }: Props) {
diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index d2f8007..f8b2e79 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -146,14 +146,21 @@ export default function Dashboard({ }) { const height = account.accountParams?.height || 0; const weight = account.accountParams?.weight || 0; - const { age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!); + + let age: number = 0; + let gender: { label: string; value: string } | null = null; + try { + ({ age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!)); + } catch (e) { + console.error("Failed to parse personal code", e); + } const bmiStatus = getBmiStatus(bmiThresholds, { age, height, weight }); return ( <>
{cards({ - gender: gender.label, + gender: gender?.label, age, height, weight, diff --git a/lib/utils.ts b/lib/utils.ts index d8fa393..233042a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -126,7 +126,7 @@ export default class PersonalCode { if (age >= 60) { return '60'; } - throw new Error('Age range not supported'); + throw new Error('Age range not supported, age=' + age); })(); const gender = (() => { const gender = parsed.getGender(); 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 344c040..6b1e8c7 100644 --- a/packages/features/auth/src/components/sign-in-methods-container.tsx +++ b/packages/features/auth/src/components/sign-in-methods-container.tsx @@ -15,6 +15,12 @@ import { MagicLinkAuthContainer } from './magic-link-auth-container'; import { OauthProviders } from './oauth-providers'; import { PasswordSignInContainer } from './password-sign-in-container'; +export type Providers = { + password: boolean; + magicLink: boolean; + oAuth: Provider[]; +}; + export function SignInMethodsContainer(props: { inviteToken?: string; @@ -25,11 +31,7 @@ export function SignInMethodsContainer(props: { updateAccount: string; }; - providers: { - password: boolean; - magicLink: boolean; - oAuth: Provider[]; - }; + providers: Providers; }) { const client = useSupabase(); const router = useRouter(); 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 aadbfb5..8759e0c 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -1,7 +1,5 @@ 'use client'; -import { redirect } from 'next/navigation'; - import type { Provider } from '@supabase/supabase-js'; import { isBrowser } from '@kit/shared/utils'; diff --git a/packages/shared/src/config/auth-providers.service.ts b/packages/shared/src/config/auth-providers.service.ts new file mode 100644 index 0000000..5cdb599 --- /dev/null +++ b/packages/shared/src/config/auth-providers.service.ts @@ -0,0 +1,137 @@ +import type { Provider } from '@supabase/supabase-js'; +import authConfig from './auth.config'; + +type SupabaseExternalProvider = Provider | 'email'; +interface SupabaseAuthSettings { + external: Record; + disable_signup: boolean; +} + +export class AuthProvidersService { + private supabaseUrl: string; + private cache: Map = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + constructor(supabaseUrl: string) { + this.supabaseUrl = supabaseUrl; + } + + async fetchAuthSettings(): Promise { + try { + const cacheKey = 'auth-settings'; + const cached = this.cache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached.data; + } + + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!anonKey) { + throw new Error('NEXT_PUBLIC_SUPABASE_ANON_KEY is required'); + } + + const response = await fetch(`${this.supabaseUrl}/auth/v1/settings?apikey=${anonKey}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.warn('Failed to fetch auth settings from Supabase:', response.status); + return null; + } + + const settings: SupabaseAuthSettings = await response.json(); + + this.cache.set(cacheKey, { data: settings, timestamp: Date.now() }); + + return settings; + } catch (error) { + console.warn('Error fetching auth settings from Supabase:', error); + return null; + } + } + + isPasswordEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean { + if (settings) { + return settings.external.email === true && !settings.disable_signup; + } + + return process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true'; + } + + isMagicLinkEnabled(): boolean { + return process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true'; + } + + isOAuthProviderEnabled({ + provider, + settings, + }: { + provider: SupabaseExternalProvider; + settings: SupabaseAuthSettings | null; + }): boolean { + if (settings && settings.external) { + return settings.external[provider] === true; + } + + return false; + } + + getEnabledOAuthProviders({ settings }: { settings: SupabaseAuthSettings | null }): SupabaseExternalProvider[] { + const enabledProviders: SupabaseExternalProvider[] = []; + + if (settings && settings.external) { + for (const [providerName, isEnabled] of Object.entries(settings.external)) { + if (isEnabled && providerName !== 'email') { + enabledProviders.push(providerName as SupabaseExternalProvider); + } + } + return enabledProviders; + } + + const potentialProviders: SupabaseExternalProvider[] = ['keycloak']; + const enabledFallback: SupabaseExternalProvider[] = []; + + for (const provider of potentialProviders) { + if (provider !== 'email' && this.isOAuthProviderEnabled({ provider, settings })) { + enabledFallback.push(provider); + } + } + + return enabledFallback; + } + + async getAuthConfig() { + const settings = await this.fetchAuthSettings(); + const [passwordEnabled, magicLinkEnabled, oAuthProviders] = await Promise.all([ + this.isPasswordEnabled({ settings }), + this.isMagicLinkEnabled(), + this.getEnabledOAuthProviders({ settings }), + ]); + + return { + providers: { + password: passwordEnabled, + magicLink: magicLinkEnabled, + oAuth: oAuthProviders, + }, + displayTermsCheckbox: authConfig.displayTermsCheckbox, + }; + } + + clearCache(): void { + this.cache.clear(); + } +} + +export function createAuthProvidersService(): AuthProvidersService { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + + if (!supabaseUrl) { + throw new Error('NEXT_PUBLIC_SUPABASE_URL is required'); + } + + return new AuthProvidersService(supabaseUrl); +} diff --git a/packages/shared/src/config/dynamic-auth.config.ts b/packages/shared/src/config/dynamic-auth.config.ts new file mode 100644 index 0000000..428be33 --- /dev/null +++ b/packages/shared/src/config/dynamic-auth.config.ts @@ -0,0 +1,102 @@ +import type { Provider } from '@supabase/supabase-js'; +import { z } from 'zod'; +import { createAuthProvidersService } from './auth-providers.service'; + +const providers: z.ZodType = getProviders(); + +const DynamicAuthConfigSchema = z.object({ + providers: z.object({ + password: z.boolean().describe('Enable password authentication.'), + magicLink: z.boolean().describe('Enable magic link authentication.'), + oAuth: providers.array(), + }), + displayTermsCheckbox: z.boolean().describe('Whether to display the terms checkbox during sign-up.'), +}); + +export async function getDynamicAuthConfig() { + const authService = createAuthProvidersService(); + const dynamicProviders = await authService.getAuthConfig(); + + const config = { + providers: dynamicProviders.providers, + displayTermsCheckbox: dynamicProviders.displayTermsCheckbox, + }; + + return DynamicAuthConfigSchema.parse(config); +} + +export async function getCachedAuthConfig() { + if (typeof window !== 'undefined') { + const cached = sessionStorage.getItem('auth-config'); + if (cached) { + try { + const { data, timestamp } = JSON.parse(cached); + // Cache for 5 minutes + if (Date.now() - timestamp < 5 * 60 * 1000) { + return data; + } + } catch (error) { + console.warn('Invalid auth config cache:', error); + } + } + } + + const config = await getDynamicAuthConfig(); + + if (typeof window !== 'undefined') { + try { + sessionStorage.setItem('auth-config', JSON.stringify({ + data: config, + timestamp: Date.now(), + })); + } catch (error) { + console.warn('Failed to cache auth config:', error); + } + } + + return config; +} + +export async function getServerAuthConfig() { + return getDynamicAuthConfig(); +} + +export async function isProviderEnabled(provider: 'password' | 'magicLink' | Provider): Promise { + const authService = createAuthProvidersService(); + const settings = await authService.fetchAuthSettings(); + + switch (provider) { + case 'password': + return authService.isPasswordEnabled({ settings }); + case 'magicLink': + return authService.isMagicLinkEnabled(); + default: + return authService.isOAuthProviderEnabled({ provider, settings }); + } +} + +function getProviders() { + return z.enum([ + 'apple', + 'azure', + 'bitbucket', + 'discord', + 'facebook', + 'figma', + 'github', + 'gitlab', + 'google', + 'kakao', + 'keycloak', + 'linkedin', + 'linkedin_oidc', + 'notion', + 'slack', + 'spotify', + 'twitch', + 'twitter', + 'workos', + 'zoom', + 'fly', + ]); +} diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 516ecc7..5669259 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -8,6 +8,7 @@ import { createPath, getTeamAccountSidebarConfig, } from './team-account-navigation.config'; +import { getCachedAuthConfig, getServerAuthConfig } from './dynamic-auth.config'; export { appConfig, @@ -18,4 +19,6 @@ export { getTeamAccountSidebarConfig, pathsConfig, personalAccountNavigationConfig, + getCachedAuthConfig, + getServerAuthConfig, }; diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index 95e4bfd..b551263 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-csrf-token'; export * from './use-current-locale-language-names'; +export * from './use-auth-config'; diff --git a/packages/shared/src/hooks/use-auth-config.ts b/packages/shared/src/hooks/use-auth-config.ts new file mode 100644 index 0000000..5282554 --- /dev/null +++ b/packages/shared/src/hooks/use-auth-config.ts @@ -0,0 +1,76 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import type { Provider } from '@supabase/supabase-js'; +import { getCachedAuthConfig } from '../config/dynamic-auth.config'; +import { authConfig } from '../config'; + +interface AuthConfig { + providers: { + password: boolean; + magicLink: boolean; + oAuth: Provider[]; + }; +} + +interface UseAuthConfigResult { + config: AuthConfig | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export function useAuthConfig(): UseAuthConfigResult { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchConfig = async () => { + try { + setLoading(true); + setError(null); + const authConfig = await getCachedAuthConfig(); + setConfig(authConfig); + } catch (err) { + console.error('Failed to fetch auth config', err); + setError(err instanceof Error ? err : new Error('Failed to fetch auth config')); + setConfig(authConfig); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchConfig(); + }, []); + + return { + config, + loading, + error, + refetch: fetchConfig, + }; +} + +export function useProviderEnabled(provider: 'password' | 'magicLink' | Provider) { + const { config, loading, error } = useAuthConfig(); + + const isEnabled = (() => { + if (!config) return false; + + switch (provider) { + case 'password': + return config.providers.password; + case 'magicLink': + return config.providers.magicLink; + default: + return config.providers.oAuth.includes(provider); + } + })(); + + return { + enabled: isEnabled, + loading, + error, + }; +} From 7bc89f7c22f1c3e648a318a6653b37244d814c41 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:31:32 +0300 Subject: [PATCH 37/58] no need to show "select-package" page after onboading if it has nothing --- app/select-package/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/select-package/page.tsx b/app/select-package/page.tsx index b45f213..69bb098 100644 --- a/app/select-package/page.tsx +++ b/app/select-package/page.tsx @@ -14,6 +14,7 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import ComparePackagesModal from '../home/(user)/_components/compare-packages-modal'; import { loadAnalysisPackages } from '../home/(user)/_lib/server/load-analysis-packages'; +import { redirect } from 'next/navigation'; export const generateMetadata = async () => { const { t } = await createI18nServerInstance(); @@ -27,6 +28,10 @@ async function SelectPackagePage() { const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages(); + if (analysisPackageElements.length === 0) { + return redirect(pathsConfig.app.home); + } + return (
From be33b892f5dd40089cf1b2f87bbf1200570ba193 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:31:39 +0300 Subject: [PATCH 38/58] only show checks for analysis elements explicitly in current package --- app/home/(user)/_components/compare-packages-modal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/home/(user)/_components/compare-packages-modal.tsx b/app/home/(user)/_components/compare-packages-modal.tsx index 90ed81c..eadfd88 100644 --- a/app/home/(user)/_components/compare-packages-modal.tsx +++ b/app/home/(user)/_components/compare-packages-modal.tsx @@ -136,10 +136,10 @@ const ComparePackagesModal = async ({ {isIncludedInStandard && } - {(isIncludedInStandard || isIncludedInStandardPlus) && } + {isIncludedInStandardPlus && } - {(isIncludedInStandard || isIncludedInStandardPlus || isIncludedInPremium) && } + {isIncludedInPremium && } ); From 0aea6b80d418d7d6440bf8e14b219d69e662c29b Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:31:49 +0300 Subject: [PATCH 39/58] sent time in medipost xml can be different from order creation time --- lib/services/medipostTest.service.ts | 2 +- lib/services/medipostXML.service.ts | 2 +- lib/templates/medipost-order.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/services/medipostTest.service.ts b/lib/services/medipostTest.service.ts index f4393f7..4459d0c 100644 --- a/lib/services/medipostTest.service.ts +++ b/lib/services/medipostTest.service.ts @@ -100,7 +100,7 @@ export async function composeOrderTestResponseXML({ return ` - ${getPais(USER, RECIPIENT, orderCreatedAt, orderId, "AL")} + ${getPais(USER, RECIPIENT, orderId, "AL")} ${orderId} ${getClientInstitution({ index: 1 })} diff --git a/lib/services/medipostXML.service.ts b/lib/services/medipostXML.service.ts index 3b55506..2cac69b 100644 --- a/lib/services/medipostXML.service.ts +++ b/lib/services/medipostXML.service.ts @@ -184,7 +184,7 @@ export async function composeOrderXML({ return ` - ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)} + ${getPais(USER, RECIPIENT, orderId)} ${orderId} ${getClientInstitution()} diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 19e5b79..e226774 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -19,7 +19,7 @@ export const getPais = ( ${packageName} ${sender} ${recipient} - ${format(createdAt, DATE_TIME_FORMAT)} + ${format(new Date(), DATE_TIME_FORMAT)} ${orderId} info@medreport.ee `; From fa9895637d88bc45051df748cd987f091b6721a0 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:32:25 +0300 Subject: [PATCH 40/58] add env to turn off automatic medipost sending on montonio callback --- .env | 3 +++ .env.development | 16 ++++++++++++++++ .../cart/montonio-callback/actions.ts | 9 ++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.env b/.env index e58a457..8367ad9 100644 --- a/.env +++ b/.env @@ -68,3 +68,6 @@ NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= # Configure Medusa password secret for Keycloak users MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg== + +# False by default +MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false diff --git a/.env.development b/.env.development index c92a206..2cc0b56 100644 --- a/.env.development +++ b/.env.development @@ -26,6 +26,22 @@ EMAIL_PORT=1025 # or 465 for SSL EMAIL_TLS=false NODE_TLS_REJECT_UNAUTHORIZED=0 +# MEDIPOST + +MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet +MEDIPOST_USER=trvurgtst +MEDIPOST_PASSWORD=SRB48HZMV +MEDIPOST_RECIPIENT=trvurgtst +MEDIPOST_MESSAGE_SENDER=trvurgtst +MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true + +#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet +#MEDIPOST_USER=medreport +#MEDIPOST_PASSWORD= +#MEDIPOST_RECIPIENT=HTI +#MEDIPOST_MESSAGE_SENDER=medreport +#MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false + # MEDUSA MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000 diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index 89c8dc3..6ee8e5d 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -30,10 +30,15 @@ const env = () => z error: 'NEXT_PUBLIC_SITE_URL is required', }) .min(1), + isEnabledDispatchOnMontonioCallback: z + .boolean({ + error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', + }), }) .parse({ emailSender: process.env.EMAIL_SENDER, siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, + isEnabledDispatchOnMontonioCallback: process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', }); const sendEmail = async ({ @@ -205,7 +210,9 @@ export async function processMontonioCallback(orderToken: string) { console.error("Missing email to send order result email", orderResult); } - await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); + if (env().isEnabledDispatchOnMontonioCallback) { + await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); + } return { success: true, orderId }; } catch (error) { From cb11244d79a95fad8b66ffb905e0adf2674c0c0e Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:32:41 +0300 Subject: [PATCH 41/58] improve order analyses cards --- .../_components/order-analyses-cards.tsx | 78 +++++++++---------- app/home/(user)/_lib/server/load-analyses.ts | 10 +-- public/locales/en/order-analysis.json | 1 - public/locales/et/order-analysis.json | 1 - public/locales/ru/order-analysis.json | 1 - 5 files changed, 42 insertions(+), 49 deletions(-) diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx index 5cb4d31..2e1064d 100644 --- a/app/home/(user)/_components/order-analyses-cards.tsx +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -21,7 +21,6 @@ import { formatCurrency } from '@/packages/shared/src/utils'; export type OrderAnalysisCard = Pick< StoreProduct, 'title' | 'description' | 'subtitle' > & { - isAvailable: boolean; variant: { id: string }; price: number | null; }; @@ -64,7 +63,6 @@ export default function OrderAnalysesCards({ variant, description, subtitle, - isAvailable, price, }) => { const formattedPrice = typeof price === 'number' @@ -77,7 +75,7 @@ export default function OrderAnalysesCards({ return ( @@ -86,46 +84,44 @@ export default function OrderAnalysesCards({ >
- {isAvailable && ( -
- -
- )} +
+ +
- -
- {title} - {description && ( - <> - {' '} - - {formattedPrice} - {description} -
- } - /> - + +
+
+ {title} + {description && ( + <> + {' '} + + {formattedPrice} + {description} +
+ } + /> + + )} + + {subtitle && ( + + {subtitle} + )} - - {isAvailable && subtitle && ( - - {subtitle} - - )} - {!isAvailable && ( - - - - )} +
+
+ {formattedPrice} +
); diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts index 1cc954c..cd16e61 100644 --- a/app/home/(user)/_lib/server/load-analyses.ts +++ b/app/home/(user)/_lib/server/load-analyses.ts @@ -41,7 +41,7 @@ async function analysesLoader() { const categoryProducts = category ? await listProducts({ countryCode, - queryParams: { limit: 100, category_id: category.id }, + queryParams: { limit: 100, category_id: category.id, order: 'title' }, }) : null; @@ -51,8 +51,10 @@ async function analysesLoader() { return { analyses: - categoryProducts?.response.products.map( - ({ title, description, subtitle, variants, status, metadata }) => { + categoryProducts?.response.products + .filter(({ status, metadata }) => status === 'published' && !!metadata?.analysisIdOriginal) + .map( + ({ title, description, subtitle, variants }) => { const variant = variants![0]!; return { title, @@ -61,8 +63,6 @@ async function analysesLoader() { variant: { id: variant.id, }, - isAvailable: - status === 'published' && !!metadata?.analysisIdOriginal, price: variant.calculated_price?.calculated_amount ?? null, }; }, diff --git a/public/locales/en/order-analysis.json b/public/locales/en/order-analysis.json index 2031316..11d1145 100644 --- a/public/locales/en/order-analysis.json +++ b/public/locales/en/order-analysis.json @@ -1,7 +1,6 @@ { "title": "Select analysis", "description": "All analysis results will appear within 1-3 days after the blood test.", - "analysisNotAvailable": "Analysis is not available currently", "analysisAddedToCart": "Analysis added to cart", "analysisAddToCartError": "Adding analysis to cart failed" } \ No newline at end of file diff --git a/public/locales/et/order-analysis.json b/public/locales/et/order-analysis.json index 9c7b750..8ff008d 100644 --- a/public/locales/et/order-analysis.json +++ b/public/locales/et/order-analysis.json @@ -1,7 +1,6 @@ { "title": "Vali analüüs", "description": "Kõikide analüüside tulemused ilmuvad 1–3 tööpäeva jooksul peale vere andmist.", - "analysisNotAvailable": "Analüüsi tellimine ei ole hetkel saadaval", "analysisAddedToCart": "Analüüs lisatud ostukorvi", "analysisAddToCartError": "Analüüsi lisamine ostukorvi ebaõnnestus" } \ No newline at end of file diff --git a/public/locales/ru/order-analysis.json b/public/locales/ru/order-analysis.json index ea36b5c..c837255 100644 --- a/public/locales/ru/order-analysis.json +++ b/public/locales/ru/order-analysis.json @@ -1,7 +1,6 @@ { "title": "Выберите анализ", "description": "Результаты всех анализов будут доступны в течение 1–3 рабочих дней после сдачи крови.", - "analysisNotAvailable": "Заказ анализа в данный момент недоступен", "analysisAddedToCart": "Анализ добавлен в корзину", "analysisAddToCartError": "Не удалось добавить анализ в корзину" } \ No newline at end of file From 229b3d7c270ad221f76cfe7d69f15306b0470bf3 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:32:48 +0300 Subject: [PATCH 42/58] fix warnings on cart page refresh --- app/home/(user)/(dashboard)/cart/page.tsx | 7 ++- .../medusa-storefront/src/lib/data/cookies.ts | 56 +++++++++++-------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 250412b..1bb2675 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -8,6 +8,7 @@ import Cart from '../../_components/cart'; import { listProductTypes } from '@lib/data/products'; import CartTimer from '../../_components/cart/cart-timer'; import { Trans } from '@kit/ui/trans'; +import { withI18n } from '~/lib/i18n/with-i18n'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -17,9 +18,9 @@ export async function generateMetadata() { }; } -export default async function CartPage() { +async function CartPage() { const cart = await retrieveCart().catch((error) => { - console.error(error); + console.error("Failed to retrieve cart", error); return notFound(); }); @@ -50,3 +51,5 @@ export default async function CartPage() { ); } + +export default withI18n(CartPage); diff --git a/packages/features/medusa-storefront/src/lib/data/cookies.ts b/packages/features/medusa-storefront/src/lib/data/cookies.ts index 7694904..ede7537 100644 --- a/packages/features/medusa-storefront/src/lib/data/cookies.ts +++ b/packages/features/medusa-storefront/src/lib/data/cookies.ts @@ -1,12 +1,20 @@ import "server-only" + import { cookies as nextCookies } from "next/headers" +const CookieName = { + MEDUSA_CUSTOMER_ID: "_medusa_customer_id", + MEDUSA_JWT: "_medusa_jwt", + MEDUSA_CART_ID: "_medusa_cart_id", + MEDUSA_CACHE_ID: "_medusa_cache_id", +} + export const getAuthHeaders = async (): Promise< { authorization: string } | {} > => { try { const cookies = await nextCookies() - const token = cookies.get("_medusa_jwt")?.value + const token = cookies.get(CookieName.MEDUSA_JWT)?.value if (!token) { return {} @@ -23,7 +31,7 @@ export const getMedusaCustomerId = async (): Promise< > => { try { const cookies = await nextCookies() - const customerId = cookies.get("_medusa_customer_id")?.value + const customerId = cookies.get(CookieName.MEDUSA_CUSTOMER_ID)?.value if (!customerId) { return { customerId: null } @@ -31,14 +39,14 @@ export const getMedusaCustomerId = async (): Promise< return { customerId } } catch { - return { customerId: null} + return { customerId: null } } } export const getCacheTag = async (tag: string): Promise => { try { const cookies = await nextCookies() - const cacheId = cookies.get("_medusa_cache_id")?.value + const cacheId = cookies.get(CookieName.MEDUSA_CACHE_ID)?.value if (!cacheId) { return "" @@ -66,51 +74,51 @@ export const getCacheOptions = async ( return { tags: [`${cacheTag}`] } } +const getCookieSharedOptions = () => ({ + maxAge: 60 * 60 * 24 * 7, + httpOnly: false, + secure: process.env.NODE_ENV === "production", +}); +const getCookieResetOptions = () => ({ + maxAge: -1, +}); + export const setAuthToken = async (token: string) => { const cookies = await nextCookies() - cookies.set("_medusa_jwt", token, { - maxAge: 60 * 60 * 24 * 7, - httpOnly: true, - sameSite: "strict", - secure: process.env.NODE_ENV === "production", + cookies.set(CookieName.MEDUSA_JWT, token, { + ...getCookieSharedOptions(), }) } export const setMedusaCustomerId = async (customerId: string) => { const cookies = await nextCookies() - cookies.set("_medusa_customer_id", customerId, { - maxAge: 60 * 60 * 24 * 7, - httpOnly: true, - sameSite: "strict", - secure: process.env.NODE_ENV === "production", + cookies.set(CookieName.MEDUSA_CUSTOMER_ID, customerId, { + ...getCookieSharedOptions(), }) } export const removeAuthToken = async () => { const cookies = await nextCookies() - cookies.set("_medusa_jwt", "", { - maxAge: -1, + cookies.set(CookieName.MEDUSA_JWT, "", { + ...getCookieResetOptions(), }) } export const getCartId = async () => { const cookies = await nextCookies() - return cookies.get("_medusa_cart_id")?.value + return cookies.get(CookieName.MEDUSA_CART_ID)?.value } export const setCartId = async (cartId: string) => { const cookies = await nextCookies() - cookies.set("_medusa_cart_id", cartId, { - maxAge: 60 * 60 * 24 * 7, - httpOnly: true, - sameSite: "strict", - secure: process.env.NODE_ENV === "production", + cookies.set(CookieName.MEDUSA_CART_ID, cartId, { + ...getCookieSharedOptions(), }) } export const removeCartId = async () => { const cookies = await nextCookies() - cookies.set("_medusa_cart_id", "", { - maxAge: -1, + cookies.set(CookieName.MEDUSA_CART_ID, "", { + ...getCookieResetOptions(), }) } From 2aad0329f3c0fa44917bcc3aa8d67aa48dbc669d Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:32:55 +0300 Subject: [PATCH 43/58] update cart discount for prod build, add loading toast --- .../_components/cart/discount-code-actions.ts | 24 ++++++++ .../(user)/_components/cart/discount-code.tsx | 59 ++++++++----------- public/locales/en/cart.json | 18 ++++-- public/locales/et/cart.json | 6 +- public/locales/ru/cart.json | 5 +- 5 files changed, 70 insertions(+), 42 deletions(-) create mode 100644 app/home/(user)/_components/cart/discount-code-actions.ts diff --git a/app/home/(user)/_components/cart/discount-code-actions.ts b/app/home/(user)/_components/cart/discount-code-actions.ts new file mode 100644 index 0000000..869ec78 --- /dev/null +++ b/app/home/(user)/_components/cart/discount-code-actions.ts @@ -0,0 +1,24 @@ +"use server" + +import { applyPromotions } from "@lib/data/cart" + +export async function addPromotionCodeAction(code: string) { + try { + await applyPromotions([code]); + return { success: true, message: 'Discount code applied successfully' }; + } catch (error) { + console.error('Error applying promotion code:', error); + return { success: false, message: 'Failed to apply discount code' }; + } +} + +export async function removePromotionCodeAction(codeToRemove: string, appliedCodes: string[]) { + try { + const updatedCodes = appliedCodes.filter((appliedCode) => appliedCode !== codeToRemove); + await applyPromotions(updatedCodes); + return { success: true, message: 'Discount code removed successfully' }; + } catch (error) { + console.error('Error removing promotion code:', error); + return { success: false, message: 'Failed to remove discount code' }; + } +} diff --git a/app/home/(user)/_components/cart/discount-code.tsx b/app/home/(user)/_components/cart/discount-code.tsx index eea8ef4..9df9073 100644 --- a/app/home/(user)/_components/cart/discount-code.tsx +++ b/app/home/(user)/_components/cart/discount-code.tsx @@ -2,9 +2,8 @@ import { Badge, Text } from "@medusajs/ui" import { toast } from '@kit/ui/sonner'; -import React, { useActionState } from "react"; +import React from "react"; -import { applyPromotions, submitPromotionForm } from "@lib/data/cart" import { convertToLocale } from "@lib/util/money" import { StoreCart, StorePromotion } from "@medusajs/types" import Trash from "@modules/common/icons/trash" @@ -16,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { addPromotionCodeAction, removePromotionCodeAction } from "./discount-code-actions"; const DiscountCodeSchema = z.object({ code: z.string().min(1), @@ -31,42 +31,35 @@ export default function DiscountCode({ cart }: { const { promotions = [] } = cart; const removePromotionCode = async (code: string) => { - const validPromotions = promotions.filter( - (promotion) => promotion.code !== code, - ) + const appliedCodes = promotions + .filter((p) => p.code !== undefined) + .map((p) => p.code!) - await applyPromotions( - validPromotions.filter((p) => p.code === undefined).map((p) => p.code!), - { - onSuccess: () => { - toast.success(t('cart:discountCode.removeSuccess')); - }, - onError: () => { - toast.error(t('cart:discountCode.removeError')); - }, - } - ) + const loading = toast.loading(t('cart:discountCode.removeLoading')); + + const result = await removePromotionCodeAction(code, appliedCodes) + + toast.dismiss(loading); + if (result.success) { + toast.success(t('cart:discountCode.removeSuccess')); + } else { + toast.error(t('cart:discountCode.removeError')); + } } const addPromotionCode = async (code: string) => { - const codes = promotions - .filter((p) => p.code === undefined) - .map((p) => p.code!) - codes.push(code.toString()) - - await applyPromotions(codes, { - onSuccess: () => { - toast.success(t('cart:discountCode.addSuccess')); - }, - onError: () => { - toast.error(t('cart:discountCode.addError')); - }, - }); - - form.reset() + const loading = toast.loading(t('cart:discountCode.addLoading')); + const result = await addPromotionCodeAction(code) + + toast.dismiss(loading); + if (result.success) { + toast.success(t('cart:discountCode.addSuccess')); + form.reset() + } else { + toast.error(t('cart:discountCode.addError')); + } } - const [message, formAction] = useActionState(submitPromotionForm, null) const form = useForm>({ defaultValues: { @@ -135,7 +128,7 @@ export default function DiscountCode({ cart }: { "percentage" ? `${promotion.application_method.value}%` : convertToLocale({ - amount: promotion.application_method.value, + amount: Number(promotion.application_method.value), currency_code: promotion.application_method .currency_code, diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 0c9af9a..a78a8cb 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -25,13 +25,19 @@ "timeoutAction": "Continue" }, "discountCode": { - "title": "Gift card or promotion code", - "label": "Add Promotion Code(s)", + "title": "Gift card or promo code", + "label": "Add Promo Code(s)", "apply": "Apply", - "subtitle": "If you wish, you can add a promotion code", - "placeholder": "Enter promotion code", - "remove": "Remove promotion code", - "appliedCodes": "Promotion(s) applied:" + "subtitle": "If you wish, you can add a promo code", + "placeholder": "Enter promo code", + "remove": "Remove promo code", + "appliedCodes": "Promotions(s) applied:", + "removeError": "Failed to remove promo code", + "removeSuccess": "Promo code removed", + "removeLoading": "Removing promo code...", + "addError": "Failed to add promo code", + "addSuccess": "Promo code added", + "addLoading": "Setting promo code..." }, "items": { "synlabAnalyses": { diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index fe87f7b..e5e7376 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -4,8 +4,8 @@ "emptyCartMessage": "Sinu ostukorv on tühi", "emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.", "subtotal": "Vahesumma", - "promotionsTotal": "Soodustuse summa", "total": "Summa", + "promotionsTotal": "Soodustuse summa", "table": { "item": "Toode", "quantity": "Kogus", @@ -34,8 +34,10 @@ "appliedCodes": "Rakendatud sooduskoodid:", "removeError": "Sooduskoodi eemaldamine ebaõnnestus", "removeSuccess": "Sooduskood eemaldatud", + "removeLoading": "Sooduskoodi eemaldamine", "addError": "Sooduskoodi rakendamine ebaõnnestus", - "addSuccess": "Sooduskood rakendatud" + "addSuccess": "Sooduskood rakendatud", + "addLoading": "Rakendan sooduskoodi..." }, "items": { "synlabAnalyses": { diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 7f3c536..bac0c63 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -5,6 +5,7 @@ "emptyCartMessageDescription": "Добавьте товары в корзину, чтобы продолжить.", "subtotal": "Промежуточный итог", "total": "Сумма", + "promotionsTotal": "Скидка", "table": { "item": "Товар", "quantity": "Количество", @@ -33,8 +34,10 @@ "appliedCodes": "Примененные промокоды:", "removeError": "Не удалось удалить промокод", "removeSuccess": "Промокод удален", + "removeLoading": "Удаление промокода...", "addError": "Не удалось применить промокод", - "addSuccess": "Промокод применен" + "addSuccess": "Промокод применен", + "addLoading": "Применение промокода..." }, "items": { "synlabAnalyses": { From 312027b9edb35dddccbb2668b44eac788929405a Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 00:12:57 +0300 Subject: [PATCH 44/58] avoid too many duplicate `requireUserInServerComponent` requests for each page+layout --- .../update-account/_lib/server/update-account.ts | 1 - app/auth/update-account/page.tsx | 7 +------ .../(dashboard)/analysis-results/[id]/page.tsx | 13 +++++++++---- .../(dashboard)/cart/montonio-callback/actions.ts | 2 +- app/home/(user)/(dashboard)/order-analysis/page.tsx | 2 +- app/home/(user)/(dashboard)/page.tsx | 2 +- app/home/(user)/_components/orders/actions.ts | 2 +- .../(user)/_lib/server/load-analysis-packages.ts | 2 +- app/home/(user)/_lib/server/load-user-account.ts | 9 ++++++--- app/home/(user)/settings/page.tsx | 2 +- app/home/(user)/settings/preferences/page.tsx | 7 +------ app/home/layout.tsx | 4 +--- lib/services/medusaCart.service.ts | 12 ++++-------- 13 files changed, 28 insertions(+), 37 deletions(-) diff --git a/app/auth/update-account/_lib/server/update-account.ts b/app/auth/update-account/_lib/server/update-account.ts index ff9a80d..ebead4c 100644 --- a/app/auth/update-account/_lib/server/update-account.ts +++ b/app/auth/update-account/_lib/server/update-account.ts @@ -10,7 +10,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { pathsConfig } from '@kit/shared/config'; - import { UpdateAccountSchema } from '../schemas/update-account.schema'; export const onUpdateAccount = enhanceAction( diff --git a/app/auth/update-account/page.tsx b/app/auth/update-account/page.tsx index a40b13e..6c1030f 100644 --- a/app/auth/update-account/page.tsx +++ b/app/auth/update-account/page.tsx @@ -1,7 +1,6 @@ import { redirect } from 'next/navigation'; import { signOutAction } from '@/lib/actions/sign-out'; -import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; import { BackButton } from '@kit/shared/components/back-button'; import { MedReportLogo } from '@kit/shared/components/med-report-logo'; @@ -15,12 +14,8 @@ import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user- import { toTitleCase } from '~/lib/utils'; async function UpdateAccount() { - const client = getSupabaseServerClient(); - const account = await loadCurrentUserAccount(); + const { account, user } = await loadCurrentUserAccount(); - const { - data: { user }, - } = await client.auth.getUser(); const isKeycloakUser = user?.app_metadata?.provider === 'keycloak'; if (!user) { diff --git a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx index 9ce91e8..7422d32 100644 --- a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx @@ -22,13 +22,14 @@ export default async function AnalysisResultsPage({ id: string; }>; }) { - const account = await loadCurrentUserAccount(); - const { id: analysisOrderId } = await params; - const analysisResponse = await loadUserAnalysis(Number(analysisOrderId)); + const [{ account }, analysisResponse] = await Promise.all([ + loadCurrentUserAccount(), + loadUserAnalysis(Number(analysisOrderId)), + ]); - if (!account?.id || !analysisResponse) { + if (!account?.id) { return null; } @@ -37,6 +38,10 @@ export default async function AnalysisResultsPage({ action: PageViewAction.VIEW_ANALYSIS_RESULTS, }); + if (!analysisResponse) { + return null; + } + return ( <> diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index 6ee8e5d..f705399 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -168,7 +168,7 @@ async function sendAnalysisPackageOrderEmail({ } export async function processMontonioCallback(orderToken: string) { - const account = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error("Account not found in context"); } diff --git a/app/home/(user)/(dashboard)/order-analysis/page.tsx b/app/home/(user)/(dashboard)/order-analysis/page.tsx index 1736dc9..1269bde 100644 --- a/app/home/(user)/(dashboard)/order-analysis/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis/page.tsx @@ -18,7 +18,7 @@ export const generateMetadata = async () => { }; async function OrderAnalysisPage() { - const account = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index dc52fc1..ca9fc22 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -26,7 +26,7 @@ export const generateMetadata = async () => { async function UserHomePage() { const client = getSupabaseServerClient(); - const account = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); const api = createAccountsApi(client); const bmiThresholds = await api.fetchBmiThresholds(); diff --git a/app/home/(user)/_components/orders/actions.ts b/app/home/(user)/_components/orders/actions.ts index d201507..07bdfbc 100644 --- a/app/home/(user)/_components/orders/actions.ts +++ b/app/home/(user)/_components/orders/actions.ts @@ -4,7 +4,7 @@ import { createPageViewLog, PageViewAction } from "~/lib/services/audit/pageView import { loadCurrentUserAccount } from "../../_lib/server/load-user-account"; export async function logAnalysisResultsNavigateAction(analysisOrderId: string) { - const account = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index 597b95f..c4c1a12 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -140,7 +140,7 @@ async function analysisPackagesWithVariantLoader({ } async function analysisPackagesLoader() { - const account = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } diff --git a/app/home/(user)/_lib/server/load-user-account.ts b/app/home/(user)/_lib/server/load-user-account.ts index 1b324ea..a16108a 100644 --- a/app/home/(user)/_lib/server/load-user-account.ts +++ b/app/home/(user)/_lib/server/load-user-account.ts @@ -16,9 +16,12 @@ export const loadUserAccount = cache(accountLoader); export async function loadCurrentUserAccount() { const user = await requireUserInServerComponent(); - return user?.id - ? await loadUserAccount(user.id) - : null; + const userId = user?.id; + if (!userId) { + return { account: null, user: null }; + } + const account = await loadUserAccount(userId); + return { account, user }; } async function accountLoader(userId: string) { diff --git a/app/home/(user)/settings/page.tsx b/app/home/(user)/settings/page.tsx index c1e81e1..bf65423 100644 --- a/app/home/(user)/settings/page.tsx +++ b/app/home/(user)/settings/page.tsx @@ -17,7 +17,7 @@ export const generateMetadata = async () => { }; async function PersonalAccountSettingsPage() { - const account = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); return (
diff --git a/app/home/(user)/settings/preferences/page.tsx b/app/home/(user)/settings/preferences/page.tsx index ec55fd6..4c0faeb 100644 --- a/app/home/(user)/settings/preferences/page.tsx +++ b/app/home/(user)/settings/preferences/page.tsx @@ -1,13 +1,9 @@ -import { CardTitle } from '@kit/ui/card'; -import { LanguageSelector } from '@kit/ui/language-selector'; -import { Trans } from '@kit/ui/trans'; - import { loadCurrentUserAccount } from '../../_lib/server/load-user-account'; import AccountPreferencesForm from '../_components/account-preferences-form'; import SettingsSectionHeader from '../_components/settings-section-header'; export default async function PreferencesPage() { - const account = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); return (
@@ -16,7 +12,6 @@ export default async function PreferencesPage() { titleKey="account:preferencesTabLabel" descriptionKey="account:preferencesTabDescription" /> -
diff --git a/app/home/layout.tsx b/app/home/layout.tsx index c483aec..088a68c 100644 --- a/app/home/layout.tsx +++ b/app/home/layout.tsx @@ -1,4 +1,3 @@ -import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component'; import { createAccountsApi } from '@/packages/features/accounts/src/server/api'; import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; @@ -12,8 +11,7 @@ export default async function HomeLayout({ }) { const client = getSupabaseServerClient(); - const user = await requireUserInServerComponent(); - const account = await loadCurrentUserAccount(); + const { account, user } = await loadCurrentUserAccount(); const api = createAccountsApi(client); const hasAccountTeamMembership = await api.hasAccountTeamMembership( diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index a416e04..c33ed0d 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -38,8 +38,7 @@ export async function handleAddToCart({ countryCode: string; }) { const supabase = getSupabaseServerClient(); - const user = await requireUserInServerComponent(); - const account = await loadCurrentUserAccount(); + const { account, user } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } @@ -70,8 +69,7 @@ export async function handleDeleteCartItem({ lineId }: { lineId: string }) { const supabase = getSupabaseServerClient(); const cartId = await getCartId(); - const user = await requireUserInServerComponent(); - const account = await loadCurrentUserAccount(); + const { account, user } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } @@ -96,8 +94,7 @@ export async function handleNavigateToPayment({ paymentSessionId: string; }) { const supabase = getSupabaseServerClient(); - const user = await requireUserInServerComponent(); - const account = await loadCurrentUserAccount(); + const { account, user } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } @@ -137,8 +134,7 @@ export async function handleLineItemTimeout({ lineItem: StoreCartLineItem; }) { const supabase = getSupabaseServerClient(); - const user = await requireUserInServerComponent(); - const account = await loadCurrentUserAccount(); + const { account, user } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } From e3cdba6a7c5f5904ec7a684c3e94b8e367c10f47 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:33:56 +0300 Subject: [PATCH 45/58] show less update-account fields on email login --- .../_components/update-account-form.tsx | 168 +++++++++--------- .../_lib/schemas/update-account.schema.ts | 42 ++++- .../_lib/server/update-account.ts | 4 +- app/auth/update-account/page.tsx | 3 +- 4 files changed, 127 insertions(+), 90 deletions(-) diff --git a/app/auth/update-account/_components/update-account-form.tsx b/app/auth/update-account/_components/update-account-form.tsx index bdda351..0741489 100644 --- a/app/auth/update-account/_components/update-account-form.tsx +++ b/app/auth/update-account/_components/update-account-form.tsx @@ -19,19 +19,21 @@ import { import { Input } from '@kit/ui/input'; import { Trans } from '@kit/ui/trans'; -import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema'; +import { UpdateAccountSchemaClient } from '../_lib/schemas/update-account.schema'; import { onUpdateAccount } from '../_lib/server/update-account'; import { z } from 'zod'; -type UpdateAccountFormValues = z.infer; +type UpdateAccountFormValues = z.infer; export function UpdateAccountForm({ defaultValues, + isEmailUser, }: { defaultValues: UpdateAccountFormValues, + isEmailUser: boolean, }) { const form = useForm({ - resolver: zodResolver(UpdateAccountSchema), + resolver: zodResolver(UpdateAccountSchemaClient), mode: 'onChange', defaultValues, }); @@ -44,18 +46,18 @@ export function UpdateAccountForm({ 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 }), + firstName: hasFirstName ? firstName : values.firstName, + lastName: hasLastName ? lastName : values.lastName, + personalCode: hasPersonalCode ? personalCode : values.personalCode, + email: hasEmail ? email : values.email, + phone: values.phone, + weight: (hasWeight ? weight : values.weight) as number, + height: (hasHeight ? height : values.height) as number, + userConsent: values.userConsent ?? userConsent, + city: values.city, }); return ( @@ -66,14 +68,14 @@ export function UpdateAccountForm({ > ( - + @@ -82,14 +84,14 @@ export function UpdateAccountForm({ ( - + @@ -98,7 +100,7 @@ export function UpdateAccountForm({ ( @@ -143,72 +145,76 @@ export function UpdateAccountForm({ )} /> - ( - - - - - - - - - - )} - /> + {!isEmailUser && ( + <> + ( + + + + + + + + + + )} + /> -
- ( - - - - - - - field.onChange( - e.target.value === '' ? null : Number(e.target.value), - ) - } - /> - - - - )} - /> +
+ ( + + + + + + + field.onChange( + e.target.value === '' ? null : Number(e.target.value), + ) + } + /> + + + + )} + /> - ( - - - - - - - field.onChange( - e.target.value === '' ? null : Number(e.target.value), - ) - } - /> - - - - )} - /> -
+ ( + + + + + + + field.onChange( + e.target.value === '' ? null : Number(e.target.value), + ) + } + /> + + + + )} + /> +
+ + )} { + try { + return new Isikukood(val).validate(); + } catch { + return false; + } + }, + { + message: 'common:formFieldError.invalidPersonalCode', + }, + ), email: z.string().email({ message: 'Email is required', }), @@ -59,4 +67,26 @@ export const UpdateAccountSchema = z.object({ userConsent: z.boolean().refine((val) => val === true, { message: 'Must be true', }), +} as const; +export const UpdateAccountSchemaServer = z.object({ + firstName: updateAccountSchema.firstName, + lastName: updateAccountSchema.lastName, + personalCode: updateAccountSchema.personalCode, + email: updateAccountSchema.email, + phone: updateAccountSchema.phone, + city: updateAccountSchema.city, + weight: updateAccountSchema.weight, + height: updateAccountSchema.height, + userConsent: updateAccountSchema.userConsent, +}); +export const UpdateAccountSchemaClient = z.object({ + firstName: updateAccountSchema.firstName, + lastName: updateAccountSchema.lastName, + personalCode: updateAccountSchema.personalCode, + email: updateAccountSchema.email, + phone: updateAccountSchema.phone, + city: updateAccountSchema.city, + weight: updateAccountSchema.weight.gt(-1).gte(0).nullable(), + height: updateAccountSchema.height.gt(-1).gte(0).nullable(), + userConsent: updateAccountSchema.userConsent, }); diff --git a/app/auth/update-account/_lib/server/update-account.ts b/app/auth/update-account/_lib/server/update-account.ts index ebead4c..7e70139 100644 --- a/app/auth/update-account/_lib/server/update-account.ts +++ b/app/auth/update-account/_lib/server/update-account.ts @@ -10,7 +10,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { pathsConfig } from '@kit/shared/config'; -import { UpdateAccountSchema } from '../schemas/update-account.schema'; +import { UpdateAccountSchemaServer } from '../schemas/update-account.schema'; export const onUpdateAccount = enhanceAction( async (params: AccountSubmitData) => { @@ -47,6 +47,6 @@ export const onUpdateAccount = enhanceAction( } }, { - schema: UpdateAccountSchema, + schema: UpdateAccountSchemaServer, }, ); diff --git a/app/auth/update-account/page.tsx b/app/auth/update-account/page.tsx index 6c1030f..031120c 100644 --- a/app/auth/update-account/page.tsx +++ b/app/auth/update-account/page.tsx @@ -17,6 +17,7 @@ async function UpdateAccount() { const { account, user } = await loadCurrentUserAccount(); const isKeycloakUser = user?.app_metadata?.provider === 'keycloak'; + const isEmailUser = user?.app_metadata?.provider === 'email'; if (!user) { redirect(pathsConfig.auth.signIn); @@ -50,7 +51,7 @@ async function UpdateAccount() {

- +
From fa0bbe64fb6a390d3a5a86389c635e5b37c49d72 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:34:05 +0300 Subject: [PATCH 46/58] update account form for email login --- .../_components/update-account-form.tsx | 63 +++++++++++++------ .../_lib/schemas/update-account.schema.ts | 26 +++++--- .../_lib/server/update-account.ts | 9 +-- .../_components/account-settings-form.tsx | 14 +++-- .../settings/_lib/account-settings.schema.ts | 4 +- .../server/schema/create-company.schema.ts | 2 +- packages/shared/src/utils.ts | 2 +- public/locales/en/account.json | 5 +- public/locales/en/common.json | 4 +- public/locales/et/account.json | 5 +- public/locales/et/common.json | 4 +- public/locales/ru/account.json | 5 +- public/locales/ru/common.json | 5 ++ 13 files changed, 102 insertions(+), 46 deletions(-) diff --git a/app/auth/update-account/_components/update-account-form.tsx b/app/auth/update-account/_components/update-account-form.tsx index 0741489..a657755 100644 --- a/app/auth/update-account/_components/update-account-form.tsx +++ b/app/auth/update-account/_components/update-account-form.tsx @@ -1,6 +1,9 @@ 'use client'; import Link from 'next/link'; +import { useTranslation } from 'react-i18next'; +import { useRouter } from 'next/navigation'; +import { z } from 'zod'; import { ExternalLink } from '@/public/assets/external-link'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -21,9 +24,10 @@ import { Trans } from '@kit/ui/trans'; import { UpdateAccountSchemaClient } from '../_lib/schemas/update-account.schema'; import { onUpdateAccount } from '../_lib/server/update-account'; -import { z } from 'zod'; +import { toast } from '@kit/ui/sonner'; +import { pathsConfig } from '@/packages/shared/src/config'; -type UpdateAccountFormValues = z.infer; +type UpdateAccountFormValues = z.infer>; export function UpdateAccountForm({ defaultValues, @@ -32,33 +36,56 @@ export function UpdateAccountForm({ defaultValues: UpdateAccountFormValues, isEmailUser: boolean, }) { + const router = useRouter(); + const { t } = useTranslation('account'); + const form = useForm({ - resolver: zodResolver(UpdateAccountSchemaClient), + resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })), mode: 'onChange', defaultValues, }); - const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues; + const { firstName, lastName, personalCode, email, userConsent } = defaultValues; + + const defaultValues_weight = "weight" in defaultValues ? defaultValues.weight : null; + const defaultValues_height = "height" in defaultValues ? defaultValues.height : null; const hasFirstName = !!firstName; const hasLastName = !!lastName; const hasPersonalCode = !!personalCode; const hasEmail = !!email; - const hasWeight = !!weight; - const hasHeight = !!height; - const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => - onUpdateAccount({ - firstName: hasFirstName ? firstName : values.firstName, - lastName: hasLastName ? lastName : values.lastName, - personalCode: hasPersonalCode ? personalCode : values.personalCode, - email: hasEmail ? email : values.email, - phone: values.phone, - weight: (hasWeight ? weight : values.weight) as number, - height: (hasHeight ? height : values.height) as number, - userConsent: values.userConsent ?? userConsent, - city: values.city, - }); + const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => { + const loading = toast.loading(t('updateAccount.updateAccountLoading')); + try { + const response = await onUpdateAccount({ + firstName: hasFirstName ? firstName : values.firstName, + lastName: hasLastName ? lastName : values.lastName, + personalCode: hasPersonalCode ? personalCode : values.personalCode, + email: hasEmail ? email : values.email, + phone: values.phone, + weight: ((("weight" in values && values.weight) ?? defaultValues_weight) || null) as number, + height: ((("height" in values && values.height) ?? defaultValues_height) || null) as number, + userConsent: values.userConsent ?? userConsent, + city: values.city, + }); + if (!response) { + throw new Error('Failed to update account'); + } + toast.dismiss(loading); + toast.success(t('updateAccount.updateAccountSuccess')); + + if (response.hasUnseenMembershipConfirmation) { + router.push(pathsConfig.auth.membershipConfirmation); + } else { + router.push(pathsConfig.app.selectPackage); + } + } catch (error) { + console.info("promiseresult error", error); + toast.error(t('updateAccount.updateAccountError')); + toast.dismiss(loading); + } + }; return ( 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 ad59e59..bba388c 100644 --- a/app/auth/update-account/_lib/schemas/update-account.schema.ts +++ b/app/auth/update-account/_lib/schemas/update-account.schema.ts @@ -12,7 +12,9 @@ const updateAccountSchema = { .string({ error: 'Last name is required', }) - .nonempty(), + .nonempty({ + error: 'common:formFieldError.stringNonEmpty', + }), personalCode: z.string().refine( (val) => { try { @@ -30,7 +32,7 @@ const updateAccountSchema = { }), phone: z .string({ - error: 'Phone number is required', + error: 'error:invalidPhone', }) .nonempty() .refine( @@ -75,18 +77,26 @@ export const UpdateAccountSchemaServer = z.object({ email: updateAccountSchema.email, phone: updateAccountSchema.phone, city: updateAccountSchema.city, - weight: updateAccountSchema.weight, - height: updateAccountSchema.height, + weight: updateAccountSchema.weight.nullable(), + height: updateAccountSchema.height.nullable(), userConsent: updateAccountSchema.userConsent, }); -export const UpdateAccountSchemaClient = z.object({ +export const UpdateAccountSchemaClient = ({ isEmailUser }: { isEmailUser: boolean }) => z.object({ firstName: updateAccountSchema.firstName, lastName: updateAccountSchema.lastName, personalCode: updateAccountSchema.personalCode, email: updateAccountSchema.email, phone: updateAccountSchema.phone, - city: updateAccountSchema.city, - weight: updateAccountSchema.weight.gt(-1).gte(0).nullable(), - height: updateAccountSchema.height.gt(-1).gte(0).nullable(), + ...(isEmailUser + ? { + city: z.string().optional(), + weight: z.number().optional(), + height: z.number().optional(), + } + : { + city: updateAccountSchema.city, + weight: updateAccountSchema.weight, + height: updateAccountSchema.height, + }), userConsent: updateAccountSchema.userConsent, }); diff --git a/app/auth/update-account/_lib/server/update-account.ts b/app/auth/update-account/_lib/server/update-account.ts index 7e70139..07e415b 100644 --- a/app/auth/update-account/_lib/server/update-account.ts +++ b/app/auth/update-account/_lib/server/update-account.ts @@ -1,7 +1,5 @@ 'use server'; -import { redirect } from 'next/navigation'; - import { updateCustomer } from '@lib/data/customer'; import { AccountSubmitData, createAuthApi } from '@kit/auth/api'; @@ -39,11 +37,8 @@ export const onUpdateAccount = enhanceAction( const hasUnseenMembershipConfirmation = await api.hasUnseenMembershipConfirmation(); - - if (hasUnseenMembershipConfirmation) { - redirect(pathsConfig.auth.membershipConfirmation); - } else { - redirect(pathsConfig.app.selectPackage); + return { + hasUnseenMembershipConfirmation, } }, { diff --git a/app/home/(user)/settings/_components/account-settings-form.tsx b/app/home/(user)/settings/_components/account-settings-form.tsx index 95db6cd..8513798 100644 --- a/app/home/(user)/settings/_components/account-settings-form.tsx +++ b/app/home/(user)/settings/_components/account-settings-form.tsx @@ -7,7 +7,6 @@ import { Trans } from 'react-i18next'; import { AccountWithParams } from '@kit/accounts/api'; import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data'; import { Button } from '@kit/ui/button'; -import { Card, CardTitle } from '@kit/ui/card'; import { Form, FormControl, @@ -25,7 +24,6 @@ import { SelectValue, } from '@kit/ui/select'; import { toast } from '@kit/ui/sonner'; -import { Switch } from '@kit/ui/switch'; import { AccountSettings, @@ -131,7 +129,11 @@ export default function AccountSettingsForm({ - + @@ -150,7 +152,11 @@ export default function AccountSettingsForm({ - + diff --git a/app/home/(user)/settings/_lib/account-settings.schema.ts b/app/home/(user)/settings/_lib/account-settings.schema.ts index a6944a4..8c3cece 100644 --- a/app/home/(user)/settings/_lib/account-settings.schema.ts +++ b/app/home/(user)/settings/_lib/account-settings.schema.ts @@ -12,8 +12,8 @@ export const accountSettingsSchema = z.object({ email: z.email({ error: 'error:invalidEmail' }).nullable(), phone: z.e164({ error: 'error:invalidPhone' }), accountParams: z.object({ - height: z.coerce.number({ error: 'error:invalidNumber' }), - weight: z.coerce.number({ error: 'error:invalidNumber' }), + height: z.coerce.number({ error: 'error:invalidNumber' }).gt(0), + weight: z.coerce.number({ error: 'error:invalidNumber' }).gt(0), isSmoker: z.boolean().optional().nullable(), }), }); diff --git a/packages/features/admin/src/lib/server/schema/create-company.schema.ts b/packages/features/admin/src/lib/server/schema/create-company.schema.ts index 42ef6cb..c2d33cf 100644 --- a/packages/features/admin/src/lib/server/schema/create-company.schema.ts +++ b/packages/features/admin/src/lib/server/schema/create-company.schema.ts @@ -10,7 +10,7 @@ const personalCodeSchema = z.string().refine( } }, { - message: 'Invalid personal code', + message: 'common:formFieldError.invalidPersonalCode', }, ); diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 971a03e..877cbca 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -1,5 +1,5 @@ import { format } from 'date-fns'; -import Isikukood, { Gender } from 'isikukood'; +import Isikukood from 'isikukood'; /** * Check if the code is running in a browser environment. diff --git a/public/locales/en/account.json b/public/locales/en/account.json index 8e7020c..2872ede 100644 --- a/public/locales/en/account.json +++ b/public/locales/en/account.json @@ -130,7 +130,10 @@ "description": "Please enter your personal details to continue", "button": "Continue", "userConsentLabel": "I agree to the use of personal data on the platform", - "userConsentUrlTitle": "View privacy policy" + "userConsentUrlTitle": "View privacy policy", + "updateAccountLoading": "Updating account details...", + "updateAccountSuccess": "Account details updated", + "updateAccountError": "Updating account details error" }, "consentModal": { "title": "Before we start", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index b26211f..cf41acd 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -129,7 +129,9 @@ "selectDate": "Select date" }, "formFieldError": { - "invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)" + "invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)", + "invalidPersonalCode": "Please enter a valid Estonian personal code", + "stringNonEmpty": "This field is required" }, "wallet": { "balance": "Your MedReport account balance", diff --git a/public/locales/et/account.json b/public/locales/et/account.json index e3e824c..86b36e3 100644 --- a/public/locales/et/account.json +++ b/public/locales/et/account.json @@ -130,7 +130,10 @@ "description": "Jätkamiseks palun sisestage enda isikuandmed", "button": "Jätka", "userConsentLabel": "Nõustun isikuandmete kasutamisega platvormil", - "userConsentUrlTitle": "Vaata isikuandmete töötlemise põhimõtteid" + "userConsentUrlTitle": "Vaata isikuandmete töötlemise põhimõtteid", + "updateAccountLoading": "Konto andmed uuendatakse...", + "updateAccountSuccess": "Konto andmed uuendatud", + "updateAccountError": "Konto andmete uuendamine ebaõnnestus" }, "consentModal": { "title": "Enne alustamist", diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 792cc3a..485c009 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -129,7 +129,9 @@ "selectDate": "Vali kuupäev" }, "formFieldError": { - "invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)" + "invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)", + "invalidPersonalCode": "Palun sisesta Eesti isikukood", + "stringNonEmpty": "See väli on kohustuslik" }, "wallet": { "balance": "Sinu MedReporti konto saldo", diff --git a/public/locales/ru/account.json b/public/locales/ru/account.json index 9bfae35..bb3785b 100644 --- a/public/locales/ru/account.json +++ b/public/locales/ru/account.json @@ -130,7 +130,10 @@ "description": "Пожалуйста, введите личные данные для продолжения", "button": "Продолжить", "userConsentLabel": "Я согласен на использование персональных данных на платформе", - "userConsentUrlTitle": "Посмотреть политику конфиденциальности" + "userConsentUrlTitle": "Посмотреть политику конфиденциальности", + "updateAccountLoading": "Обновление данных аккаунта...", + "updateAccountSuccess": "Данные аккаунта обновлены", + "updateAccountError": "Не удалось обновить данные аккаунта" }, "consentModal": { "title": "Перед началом", diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json index 545b9e6..28b5d6b 100644 --- a/public/locales/ru/common.json +++ b/public/locales/ru/common.json @@ -128,6 +128,11 @@ "amount": "Сумма", "selectDate": "Выберите дату" }, + "formFieldError": { + "invalidPhoneNumber": "Пожалуйста, введите действительный номер телефона (должен включать код страны +372)", + "invalidPersonalCode": "Пожалуйста, введите действительный персональный код", + "stringNonEmpty": "Это поле обязательно" + }, "wallet": { "balance": "Баланс вашего счета MedReport", "expiredAt": "Действительно до {{expiredAt}}" From b8a8eab87c7f5df025523209dccc3a028192a042 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:34:18 +0300 Subject: [PATCH 47/58] update order details view, translations --- .../analysis-results/[id]/page.tsx | 14 +++++++++++-- app/home/(user)/_components/cart/index.tsx | 6 +++--- .../_components/order-analyses-cards.tsx | 2 +- .../(user)/_components/order/cart-totals.tsx | 12 +++++------ .../_components/order/order-details.tsx | 20 +++++++++++++------ packages/features/auth/src/server/api.ts | 4 ++-- packages/ui/src/makerkit/page.tsx | 6 +++--- public/locales/en/cart.json | 9 +++++---- public/locales/et/cart.json | 9 +++++---- public/locales/ru/cart.json | 9 +++++---- 10 files changed, 56 insertions(+), 35 deletions(-) diff --git a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx index 7422d32..c69d0b6 100644 --- a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { redirect } from 'next/navigation'; import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip'; import { pathsConfig } from '@kit/shared/config'; @@ -30,7 +31,7 @@ export default async function AnalysisResultsPage({ ]); if (!account?.id) { - return null; + return redirect("/"); } await createPageViewLog({ @@ -39,7 +40,16 @@ export default async function AnalysisResultsPage({ }); if (!analysisResponse) { - return null; + return ( + <> + } + description={} + /> + + + + ); } return ( diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index 4ab083e..afd4639 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -81,7 +81,7 @@ export default function Cart({

- +

@@ -97,7 +97,7 @@ export default function Cart({

- +

@@ -113,7 +113,7 @@ export default function Cart({

- +

diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx index 2e1064d..bd7f8a7 100644 --- a/app/home/(user)/_components/order-analyses-cards.tsx +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -57,7 +57,7 @@ export default function OrderAnalysesCards({ } return ( -
+
{analyses.map(({ title, variant, diff --git a/app/home/(user)/_components/order/cart-totals.tsx b/app/home/(user)/_components/order/cart-totals.tsx index 2df2237..fb7b030 100644 --- a/app/home/(user)/_components/order/cart-totals.tsx +++ b/app/home/(user)/_components/order/cart-totals.tsx @@ -24,7 +24,7 @@ export default function CartTotals({ medusaOrder }: {
- + {formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })} @@ -32,7 +32,7 @@ export default function CartTotals({ medusaOrder }: {
{!!discount_total && (
- +
)} -
+ {/*
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })} -
+
*/} {!!gift_card_total && (
- +
- + - - :{" "} +
+ + :{" "} + + + {order.medusa_order_id} + +
+ +
+ + :{" "} + {formatDate(order.created_at, 'dd.MM.yyyy HH:mm')} - - - : {order.medusa_order_id} - +
) } diff --git a/packages/features/auth/src/server/api.ts b/packages/features/auth/src/server/api.ts index 223e82c..8462006 100644 --- a/packages/features/auth/src/server/api.ts +++ b/packages/features/auth/src/server/api.ts @@ -9,8 +9,8 @@ export interface AccountSubmitData { email: string; phone?: string; city?: string; - weight: number | null; - height: number | null; + weight?: number | null | undefined; + height?: number | null | undefined; userConsent: boolean; } diff --git a/packages/ui/src/makerkit/page.tsx b/packages/ui/src/makerkit/page.tsx index 7a4ee42..6ca7573 100644 --- a/packages/ui/src/makerkit/page.tsx +++ b/packages/ui/src/makerkit/page.tsx @@ -119,8 +119,8 @@ export function PageNavigation(props: React.PropsWithChildren) { export function PageDescription(props: React.PropsWithChildren) { return ( -
-
+
+
{props.children}
@@ -168,7 +168,7 @@ export function PageHeader({ -
+
{displaySidebarTrigger ? ( ) : null} diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index a78a8cb..223d3f1 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -3,9 +3,6 @@ "description": "View your cart", "emptyCartMessage": "Your cart is empty", "emptyCartMessageDescription": "Add items to your cart to continue.", - "subtotal": "Subtotal", - "total": "Total", - "promotionsTotal": "Promotions total", "table": { "item": "Item", "quantity": "Quantity", @@ -58,7 +55,11 @@ } }, "order": { - "title": "Order" + "title": "Order", + "promotionsTotal": "Promotions total", + "subtotal": "Subtotal", + "total": "Total", + "giftCard": "Gift card" }, "orderConfirmed": { "title": "Order confirmed", diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index e5e7376..36b69d0 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -3,9 +3,6 @@ "description": "Vaata oma ostukorvi", "emptyCartMessage": "Sinu ostukorv on tühi", "emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.", - "subtotal": "Vahesumma", - "total": "Summa", - "promotionsTotal": "Soodustuse summa", "table": { "item": "Toode", "quantity": "Kogus", @@ -58,7 +55,11 @@ } }, "order": { - "title": "Tellimus" + "title": "Tellimus", + "promotionsTotal": "Soodustuse summa", + "subtotal": "Vahesumma", + "total": "Summa", + "giftCard": "Kinkekaart" }, "orderConfirmed": { "title": "Tellimus on edukalt esitatud", diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index bac0c63..289ff31 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -3,9 +3,6 @@ "description": "Просмотрите свою корзину", "emptyCartMessage": "Ваша корзина пуста", "emptyCartMessageDescription": "Добавьте товары в корзину, чтобы продолжить.", - "subtotal": "Промежуточный итог", - "total": "Сумма", - "promotionsTotal": "Скидка", "table": { "item": "Товар", "quantity": "Количество", @@ -58,7 +55,11 @@ } }, "order": { - "title": "Заказ" + "title": "Заказ", + "promotionsTotal": "Скидка", + "subtotal": "Промежуточный итог", + "total": "Сумма", + "giftCard": "Подарочная карта" }, "orderConfirmed": { "title": "Заказ успешно оформлен", From 8b3e58e833564afea237e60c1b4a8b640cf19aff Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:34:27 +0300 Subject: [PATCH 48/58] improve signup container --- app/auth/sign-up/page.tsx | 3 +- .../_components/update-account-form.tsx | 8 ++-- .../auth/src/components/auth-layout.tsx | 5 +-- .../auth/src/components/oauth-providers.tsx | 16 +++++--- .../components/password-sign-up-container.tsx | 22 +++++++++-- .../components/sign-up-methods-container.tsx | 37 +++++++++++++------ .../shared/src/components/ui/info-tooltip.tsx | 2 +- .../src/config/auth-providers.service.ts | 9 ++++- .../shared/src/config/dynamic-auth.config.ts | 12 ++++++ packages/shared/src/config/index.ts | 3 +- packages/ui/src/makerkit/page.tsx | 2 +- packages/ui/src/shadcn/card.tsx | 6 +-- public/locales/en/auth.json | 1 + public/locales/et/auth.json | 5 ++- public/locales/ru/auth.json | 1 + 15 files changed, 92 insertions(+), 40 deletions(-) diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index 52d77e9..cdee3a5 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -57,10 +57,9 @@ async function SignUpPage({ searchParams }: Props) {
diff --git a/app/auth/update-account/_components/update-account-form.tsx b/app/auth/update-account/_components/update-account-form.tsx index a657755..b807745 100644 --- a/app/auth/update-account/_components/update-account-form.tsx +++ b/app/auth/update-account/_components/update-account-form.tsx @@ -59,10 +59,10 @@ export function UpdateAccountForm({ const loading = toast.loading(t('updateAccount.updateAccountLoading')); try { const response = await onUpdateAccount({ - firstName: hasFirstName ? firstName : values.firstName, - lastName: hasLastName ? lastName : values.lastName, - personalCode: hasPersonalCode ? personalCode : values.personalCode, - email: hasEmail ? email : values.email, + firstName: values.firstName || firstName, + lastName: values.lastName || lastName, + personalCode: values.personalCode || personalCode, + email: values.email || email, phone: values.phone, weight: ((("weight" in values && values.weight) ?? defaultValues_weight) || null) as number, height: ((("height" in values && values.height) ?? defaultValues_height) || null) as number, diff --git a/packages/features/auth/src/components/auth-layout.tsx b/packages/features/auth/src/components/auth-layout.tsx index 2d83e61..003da16 100644 --- a/packages/features/auth/src/components/auth-layout.tsx +++ b/packages/features/auth/src/components/auth-layout.tsx @@ -7,9 +7,8 @@ export function AuthLayoutShell({ return (
{Logo ? : null} diff --git a/packages/features/auth/src/components/oauth-providers.tsx b/packages/features/auth/src/components/oauth-providers.tsx index 11dcc94..2bb7830 100644 --- a/packages/features/auth/src/components/oauth-providers.tsx +++ b/packages/features/auth/src/components/oauth-providers.tsx @@ -113,12 +113,16 @@ export const OauthProviders: React.FC<{ ); }} > - + {provider === 'keycloak' ? ( + + ) : ( + + )} ); })} diff --git a/packages/features/auth/src/components/password-sign-up-container.tsx b/packages/features/auth/src/components/password-sign-up-container.tsx index 5cbe21a..631c7a5 100644 --- a/packages/features/auth/src/components/password-sign-up-container.tsx +++ b/packages/features/auth/src/components/password-sign-up-container.tsx @@ -10,9 +10,18 @@ import { useCaptchaToken } from '../captcha/client'; import { usePasswordSignUpFlow } from '../hooks/use-sign-up-flow'; import { AuthErrorAlert } from './auth-error-alert'; import { PasswordSignUpForm } from './password-sign-up-form'; +import { Spinner } from '@kit/ui/makerkit/spinner'; interface EmailPasswordSignUpContainerProps { - displayTermsCheckbox?: boolean; + authConfig: { + providers: { + password: boolean; + magicLink: boolean; + oAuth: string[]; + }; + displayTermsCheckbox: boolean | undefined; + isMailerAutoconfirmEnabled: boolean; + }; defaultValues?: { email: string; }; @@ -21,10 +30,10 @@ interface EmailPasswordSignUpContainerProps { } export function EmailPasswordSignUpContainer({ + authConfig, defaultValues, onSignUp, emailRedirectTo, - displayTermsCheckbox, }: EmailPasswordSignUpContainerProps) { const { captchaToken, resetCaptchaToken } = useCaptchaToken(); @@ -43,7 +52,12 @@ export function EmailPasswordSignUpContainer({ return ( <> - + {authConfig.isMailerAutoconfirmEnabled ? ( +
+ +
+ ) : + }
@@ -53,7 +67,7 @@ export function EmailPasswordSignUpContainer({ onSubmit={onSignupRequested} loading={loading} defaultValues={defaultValues} - displayTermsCheckbox={displayTermsCheckbox} + displayTermsCheckbox={authConfig.displayTermsCheckbox} /> 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 8759e0c..00782bd 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -1,6 +1,7 @@ 'use client'; import type { Provider } from '@supabase/supabase-js'; +import { useRouter } from 'next/navigation'; import { isBrowser } from '@kit/shared/utils'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -19,15 +20,20 @@ export function SignUpMethodsContainer(props: { updateAccount: string; }; - providers: { - password: boolean; - magicLink: boolean; - oAuth: Provider[]; + authConfig: { + providers: { + password: boolean; + magicLink: boolean; + oAuth: Provider[]; + }; + displayTermsCheckbox: boolean | undefined; + isMailerAutoconfirmEnabled: boolean; }; - displayTermsCheckbox?: boolean; inviteToken?: string; }) { + const router = useRouter(); + const redirectUrl = getCallbackUrl(props); const defaultValues = getDefaultValues(); @@ -37,26 +43,33 @@ export function SignUpMethodsContainer(props: { - + redirect(redirectUrl)} + authConfig={props.authConfig} + onSignUp={() => { + if (!props.authConfig.isMailerAutoconfirmEnabled) { + return; + } + setTimeout(() => { + router.replace(props.paths.updateAccount) + }, 2_500); + }} /> - + - +
@@ -70,7 +83,7 @@ export function SignUpMethodsContainer(props: {
{icon || } - {content} + {content} ); diff --git a/packages/shared/src/config/auth-providers.service.ts b/packages/shared/src/config/auth-providers.service.ts index 5cdb599..179e7da 100644 --- a/packages/shared/src/config/auth-providers.service.ts +++ b/packages/shared/src/config/auth-providers.service.ts @@ -5,6 +5,7 @@ type SupabaseExternalProvider = Provider | 'email'; interface SupabaseAuthSettings { external: Record; disable_signup: boolean; + mailer_autoconfirm: boolean; } export class AuthProvidersService { @@ -61,6 +62,10 @@ export class AuthProvidersService { return process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true'; } + isMailerAutoconfirmEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean { + return settings?.mailer_autoconfirm === true; + } + isMagicLinkEnabled(): boolean { return process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true'; } @@ -105,10 +110,11 @@ export class AuthProvidersService { async getAuthConfig() { const settings = await this.fetchAuthSettings(); - const [passwordEnabled, magicLinkEnabled, oAuthProviders] = await Promise.all([ + const [passwordEnabled, magicLinkEnabled, oAuthProviders, isMailerAutoconfirmEnabled] = await Promise.all([ this.isPasswordEnabled({ settings }), this.isMagicLinkEnabled(), this.getEnabledOAuthProviders({ settings }), + this.isMailerAutoconfirmEnabled({ settings }), ]); return { @@ -118,6 +124,7 @@ export class AuthProvidersService { oAuth: oAuthProviders, }, displayTermsCheckbox: authConfig.displayTermsCheckbox, + isMailerAutoconfirmEnabled, }; } diff --git a/packages/shared/src/config/dynamic-auth.config.ts b/packages/shared/src/config/dynamic-auth.config.ts index 428be33..571516a 100644 --- a/packages/shared/src/config/dynamic-auth.config.ts +++ b/packages/shared/src/config/dynamic-auth.config.ts @@ -11,8 +11,19 @@ const DynamicAuthConfigSchema = z.object({ oAuth: providers.array(), }), displayTermsCheckbox: z.boolean().describe('Whether to display the terms checkbox during sign-up.'), + isMailerAutoconfirmEnabled: z.boolean().describe('Whether Supabase sends confirmation email automatically.'), }); +export type DynamicAuthConfig = { + providers: { + password: boolean; + magicLink: boolean; + oAuth: Provider[]; + }; + displayTermsCheckbox: boolean | undefined; + isMailerAutoconfirmEnabled: boolean; +} + export async function getDynamicAuthConfig() { const authService = createAuthProvidersService(); const dynamicProviders = await authService.getAuthConfig(); @@ -20,6 +31,7 @@ export async function getDynamicAuthConfig() { const config = { providers: dynamicProviders.providers, displayTermsCheckbox: dynamicProviders.displayTermsCheckbox, + isMailerAutoconfirmEnabled: dynamicProviders.isMailerAutoconfirmEnabled, }; return DynamicAuthConfigSchema.parse(config); diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 5669259..d1737bb 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -8,7 +8,7 @@ import { createPath, getTeamAccountSidebarConfig, } from './team-account-navigation.config'; -import { getCachedAuthConfig, getServerAuthConfig } from './dynamic-auth.config'; +import { DynamicAuthConfig, getCachedAuthConfig, getServerAuthConfig } from './dynamic-auth.config'; export { appConfig, @@ -21,4 +21,5 @@ export { personalAccountNavigationConfig, getCachedAuthConfig, getServerAuthConfig, + type DynamicAuthConfig, }; diff --git a/packages/ui/src/makerkit/page.tsx b/packages/ui/src/makerkit/page.tsx index 6ca7573..b4ee923 100644 --- a/packages/ui/src/makerkit/page.tsx +++ b/packages/ui/src/makerkit/page.tsx @@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) { > {MobileNavigation} -
+
{Children}
diff --git a/packages/ui/src/shadcn/card.tsx b/packages/ui/src/shadcn/card.tsx index fec5a6b..9841c3e 100644 --- a/packages/ui/src/shadcn/card.tsx +++ b/packages/ui/src/shadcn/card.tsx @@ -34,7 +34,7 @@ const CardHeader: React.FC> = ({ className, ...props }) => ( -
+
); CardHeader.displayName = 'CardHeader'; @@ -60,14 +60,14 @@ CardDescription.displayName = 'CardDescription'; const CardContent: React.FC> = ({ className, ...props -}) =>
; +}) =>
; CardContent.displayName = 'CardContent'; const CardFooter: React.FC> = ({ className, ...props }) => ( -
+
); CardFooter.displayName = 'CardFooter'; diff --git a/public/locales/en/auth.json b/public/locales/en/auth.json index 7db0925..5c89064 100644 --- a/public/locales/en/auth.json +++ b/public/locales/en/auth.json @@ -22,6 +22,7 @@ "alreadyHaveAccountStatement": "I already have an account, I want to sign in instead", "doNotHaveAccountStatement": "I do not have an account, I want to sign up instead", "signInWithProvider": "Sign in with {{provider}}", + "signInWithKeycloak": "Smart-ID/Mobile-ID/ID-card", "signInWithPhoneNumber": "Sign in with Phone Number", "signInWithEmail": "Sign in with Email", "signUpWithEmail": "Sign up with Email", diff --git a/public/locales/et/auth.json b/public/locales/et/auth.json index d9ebf9b..4919b25 100644 --- a/public/locales/et/auth.json +++ b/public/locales/et/auth.json @@ -2,7 +2,7 @@ "signUpHeading": "Loo konto", "signUp": "Loo konto", "signUpSubheading": "Täida allolev vorm, et luua konto.", - "signInHeading": "Logi oma kontole sisse", + "signInHeading": "Logi sisse", "signInSubheading": "Tere tulemast tagasi! Palun sisesta oma andmed", "signIn": "Logi sisse", "getStarted": "Alusta", @@ -22,6 +22,7 @@ "alreadyHaveAccountStatement": "Mul on juba konto, ma tahan sisse logida", "doNotHaveAccountStatement": "Mul pole kontot, ma tahan registreeruda", "signInWithProvider": "Logi sisse teenusega {{provider}}", + "signInWithKeycloak": "Smart-ID/Mobiil-ID/ID-kaart", "signInWithPhoneNumber": "Logi sisse telefoninumbriga", "signInWithEmail": "Logi sisse e-posti aadressiga", "signUpWithEmail": "Registreeru e-posti aadressiga", @@ -68,7 +69,7 @@ "acceptTermsAndConditions": "Ma nõustun ja ", "termsOfService": "Kasutustingimused", "privacyPolicy": "Privaatsuspoliitika", - "orContinueWith": "Või jätka koos", + "orContinueWith": "Või", "redirecting": "Oled sees! Palun oota...", "errors": { "Invalid login credentials": "Sisestatud andmed on valed", diff --git a/public/locales/ru/auth.json b/public/locales/ru/auth.json index 8634403..5dc5e1d 100644 --- a/public/locales/ru/auth.json +++ b/public/locales/ru/auth.json @@ -22,6 +22,7 @@ "alreadyHaveAccountStatement": "У меня уже есть аккаунт, я хочу войти", "doNotHaveAccountStatement": "У меня нет аккаунта, я хочу зарегистрироваться", "signInWithProvider": "Войти через {{provider}}", + "signInWithKeycloak": "Smart-ID/Mobiil-ID/ID-kaart", "signInWithPhoneNumber": "Войти по номеру телефона", "signInWithEmail": "Войти по Email", "signUpWithEmail": "Зарегистрироваться по Email", From 76433684e7ae43306c7284852bb1e39f073f502c Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 10 Sep 2025 06:34:34 +0300 Subject: [PATCH 49/58] improve cart mobile styles --- .../_components/cart/analysis-location.tsx | 13 ++++---- .../(user)/_components/cart/cart-item.tsx | 10 +++---- .../(user)/_components/cart/cart-items.tsx | 10 +++---- .../(user)/_components/cart/discount-code.tsx | 24 +++++++-------- app/home/(user)/_components/cart/index.tsx | 30 +++++++++---------- 5 files changed, 43 insertions(+), 44 deletions(-) diff --git a/app/home/(user)/_components/cart/analysis-location.tsx b/app/home/(user)/_components/cart/analysis-location.tsx index 9e1ab16..99355dd 100644 --- a/app/home/(user)/_components/cart/analysis-location.tsx +++ b/app/home/(user)/_components/cart/analysis-location.tsx @@ -55,11 +55,15 @@ export default function AnalysisLocation({ cart, synlabAnalyses }: { cart: Store } return ( -
+
+

+ +

+ onSubmit(data))} - className="w-full mb-2 flex gap-x-2" + className="w-full mb-2 flex gap-x-2 flex-1" >