diff --git a/.env b/.env index cfe6997..8367ad9 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,9 @@ 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== + +# False by default +MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false diff --git a/.env.development b/.env.development index 962cb9d..2cc0b56 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 @@ -25,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/(marketing)/_components/site-header-account-section.tsx b/app/(marketing)/_components/site-header-account-section.tsx index c4c388f..dd62722 100644 --- a/app/(marketing)/_components/site-header-account-section.tsx +++ b/app/(marketing)/_components/site-header-account-section.tsx @@ -13,10 +13,8 @@ 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 { featureFlagsConfig, pathsConfig } from '@kit/shared/config'; +import { useAuthConfig } from '@kit/shared/hooks'; const ModeToggle = dynamic(() => import('@kit/ui/mode-toggle').then((mod) => ({ @@ -60,6 +58,8 @@ export function SiteHeaderAccountSection({ } function AuthButtons() { + const { config } = useAuthConfig(); + return (
@@ -68,19 +68,25 @@ function AuthButtons() {
-
- + {config && ( +
+ {(config.providers.password || config.providers.oAuth.length > 0) && ( + + )} - -
+ {config.providers.password && ( + + )} +
+ )}
); } 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 (
- + diff --git a/app/api/job/handler/sync-analysis-results.ts b/app/api/job/handler/sync-analysis-results.ts index c5a2323..e666916 100644 --- a/app/api/job/handler/sync-analysis-results.ts +++ b/app/api/job/handler/sync-analysis-results.ts @@ -6,11 +6,12 @@ type ProcessedMessage = { hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined; + analysisOrderId: number | undefined; }; type GroupedResults = { - processed: Pick[]; - waitingForResults: Pick[]; + processed: Pick[]; + waitingForResults: Pick[]; }; export default async function syncAnalysisResults() { @@ -37,14 +38,14 @@ export default async function syncAnalysisResults() { } const groupedResults = processedMessages.reduce((acc, result) => { - if (result.medusaOrderId) { + if (result.analysisOrderId) { if (result.hasAnalysisResponse) { if (!acc.processed) { acc.processed = []; } acc.processed.push({ messageId: result.messageId, - medusaOrderId: result.medusaOrderId, + analysisOrderId: result.analysisOrderId, }); } else { if (!acc.waitingForResults) { @@ -52,7 +53,7 @@ export default async function syncAnalysisResults() { } acc.waitingForResults.push({ messageId: result.messageId, - medusaOrderId: result.medusaOrderId, + analysisOrderId: result.analysisOrderId, }); } } 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/api/job/test-medipost-responses/route.ts b/app/api/job/test-medipost-responses/route.ts index c745b98..7c2944d 100644 --- a/app/api/job/test-medipost-responses/route.ts +++ b/app/api/job/test-medipost-responses/route.ts @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) { }, orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[], orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[], - orderId: medusaOrderId, + orderId: medreportOrder.id, orderCreatedAt: new Date(medreportOrder.created_at), }); diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts index 2302631..262c1f0 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`); @@ -29,8 +29,8 @@ 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), + orderId: analysisOrder.id, + orderCreatedAt: new Date(analysisOrder.created_at), }); try { 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/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()); diff --git a/app/auth/sign-in/components/PasswordOption.tsx b/app/auth/sign-in/components/PasswordOption.tsx new file mode 100644 index 0000000..4f0b59d --- /dev/null +++ b/app/auth/sign-in/components/PasswordOption.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; + +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'; + +export default function PasswordOption({ + inviteToken, + returnPath, + providers, +}: { + inviteToken?: string; + returnPath?: string; + providers: Providers; +}) { + 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..4799f7d 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 { getServerAuthConfig, pathsConfig } 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,26 @@ 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}` : ''); + const authConfig = await getServerAuthConfig(); - const paths = { - callback: pathsConfig.auth.callback, - returnPath: next ?? pathsConfig.app.home, - joinTeam: pathsConfig.app.joinTeam, - updateAccount: pathsConfig.auth.updateAccount, - }; - - 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 5c0a4e2..cdee3a5 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -1,7 +1,8 @@ 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'; @@ -37,6 +38,12 @@ async function SignUpPage({ searchParams }: Props) { pathsConfig.auth.signIn + (inviteToken ? `?invite_token=${inviteToken}` : ''); + const authConfig = await getServerAuthConfig(); + + if (!authConfig.providers.password) { + return redirect('/'); + } + return ( <>
@@ -50,8 +57,7 @@ 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 58887f0..b807745 100644 --- a/app/auth/update-account/_components/update-account-form.tsx +++ b/app/auth/update-account/_components/update-account-form.tsx @@ -1,8 +1,9 @@ 'use client'; import Link from 'next/link'; - -import { User } from '@supabase/supabase-js'; +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,40 +22,87 @@ 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 { toast } from '@kit/ui/sonner'; +import { pathsConfig } from '@/packages/shared/src/config'; + +type UpdateAccountFormValues = z.infer>; + +export function UpdateAccountForm({ + defaultValues, + isEmailUser, +}: { + defaultValues: UpdateAccountFormValues, + isEmailUser: boolean, +}) { + const router = useRouter(); + const { t } = useTranslation('account'); -export function UpdateAccountForm({ user }: { user: User }) { const form = useForm({ - resolver: zodResolver(UpdateAccountSchema), + resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })), mode: 'onChange', - defaultValues: { - firstName: '', - lastName: '', - personalCode: '', - email: user.email, - phone: '', - city: '', - weight: 0, - height: 0, - userConsent: false, - }, + 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 onUpdateAccountOptions = async (values: UpdateAccountFormValues) => { + const loading = toast.loading(t('updateAccount.updateAccountLoading')); + try { + const response = await onUpdateAccount({ + 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, + 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 (
( - + @@ -63,13 +111,14 @@ export function UpdateAccountForm({ user }: { user: User }) { ( - + @@ -78,6 +127,7 @@ export function UpdateAccountForm({ user }: { user: User }) { ( @@ -93,13 +143,14 @@ export function UpdateAccountForm({ user }: { user: User }) { ( - + @@ -121,72 +172,76 @@ export function UpdateAccountForm({ user }: { user: User }) { )} /> - ( - - - - - - - - - - )} - /> + {!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', }), phone: z .string({ - error: 'Phone number is required', + error: 'error:invalidPhone', }) - .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({ @@ -45,4 +69,34 @@ 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.nullable(), + height: updateAccountSchema.height.nullable(), + userConsent: updateAccountSchema.userConsent, +}); +export const UpdateAccountSchemaClient = ({ isEmailUser }: { isEmailUser: boolean }) => z.object({ + firstName: updateAccountSchema.firstName, + lastName: updateAccountSchema.lastName, + personalCode: updateAccountSchema.personalCode, + email: updateAccountSchema.email, + phone: updateAccountSchema.phone, + ...(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 e2fcd0f..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'; @@ -10,8 +8,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) => { @@ -28,22 +25,23 @@ 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(); - - if (hasUnseenMembershipConfirmation) { - redirect(pathsConfig.auth.membershipConfirmation); - } else { - redirect(pathsConfig.app.selectPackage); + return { + hasUnseenMembershipConfirmation, } }, { - schema: UpdateAccountSchema, + schema: UpdateAccountSchemaServer, }, ); diff --git a/app/auth/update-account/page.tsx b/app/auth/update-account/page.tsx index 28a6395..031120c 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'; @@ -11,18 +10,36 @@ 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, user } = await loadCurrentUserAccount(); - const { - data: { user }, - } = await client.auth.getUser(); + const isKeycloakUser = user?.app_metadata?.provider === 'keycloak'; + const isEmailUser = user?.app_metadata?.provider === 'email'; 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 ?? '+372', + city: account?.city ?? '', + weight: account?.accountParams?.weight ?? 0, + height: account?.accountParams?.height ?? 0, + userConsent: account?.has_consent_personal_data ?? false, + }; + return (
@@ -34,7 +51,7 @@ async function UpdateAccount() {

- +
diff --git a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx index a568eed..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'; @@ -22,14 +23,15 @@ export default async function AnalysisResultsPage({ id: string; }>; }) { - const account = await loadCurrentUserAccount(); + const { id: analysisOrderId } = await params; - const { id: analysisResponseId } = await params; + const [{ account }, analysisResponse] = await Promise.all([ + loadCurrentUserAccount(), + loadUserAnalysis(Number(analysisOrderId)), + ]); - const analysisResponse = await loadUserAnalysis(Number(analysisResponseId)); - - if (!account?.id || !analysisResponse) { - return null; + if (!account?.id) { + return redirect("/"); } await createPageViewLog({ @@ -37,6 +39,19 @@ export default async function AnalysisResultsPage({ action: PageViewAction.VIEW_ANALYSIS_RESULTS, }); + if (!analysisResponse) { + return ( + <> + } + description={} + /> + + + + ); + } + return ( <> diff --git a/app/home/(user)/(dashboard)/booking/page.tsx b/app/home/(user)/(dashboard)/booking/page.tsx index 680edfb..94e2b2f 100644 --- a/app/home/(user)/(dashboard)/booking/page.tsx +++ b/app/home/(user)/(dashboard)/booking/page.tsx @@ -28,9 +28,13 @@ function BookingPage() { return ( <> -

+ } + description={} + /> +

-

+

); } diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index 9ef4799..f705399 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 { createOrder } 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 @@ -28,24 +30,27 @@ 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 ({ account, email, analysisPackageName, - personName, partnerLocationName, language, }: { - account: AccountWithParams, + account: Pick, email: string, analysisPackageName: string, - personName: string, partnerLocationName: string, language: string, }) => { @@ -58,7 +63,7 @@ const sendEmail = async ({ const { html, subject } = await renderSynlabAnalysisPackageEmail({ analysisPackageName, - personName, + personName: account.name, partnerLocationName, language, }); @@ -83,9 +88,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,54 +99,120 @@ export async function processMontonioCallback(orderToken: string) { throw new Error("Payment not successful"); } - const account = await loadCurrentUserAccount(); + 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 }); - const orderId = await createOrder({ medusaOrder, orderedAnalysisElements }); - 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); + 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 orderResult = { - medusaOrderId: medusaOrder.id, - email: medusaOrder.email, - partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - orderedAnalysisElements, - }; + const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements }); + const orderResult = await getOrderResultParameters(medusaOrder); - const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult; - const personName = account.name; + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; - if (email && analysisPackageName) { - try { - await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language }); - } catch (error) { - console.error("Failed to send email", error); + if (email) { + if (analysisPackageOrder) { + await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder }); + } else { + console.info(`Order has no analysis package, skipping email.`); + } + + 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 }); + if (env().isEnabledDispatchOnMontonioCallback) { + await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); + } return { success: true, orderId }; } catch (error) { 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/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)/order-health-analysis/page.tsx b/app/home/(user)/(dashboard)/order-health-analysis/page.tsx index a4a4478..432ca39 100644 --- a/app/home/(user)/(dashboard)/order-health-analysis/page.tsx +++ b/app/home/(user)/(dashboard)/order-health-analysis/page.tsx @@ -21,6 +21,9 @@ async function OrderHealthAnalysisPage() { description={} /> +

+ +

); diff --git a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx index 21e1829..9c3f42f 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({ analysisOrderId: 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..8bc90d7 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({ analysisOrderId: Number(params.orderId) }).catch(() => null); if (!order) { redirect(pathsConfig.app.myOrders); } diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index 5bb1ae7..48faae3 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -61,6 +61,11 @@ async function OrdersPage() { ) })} + {analysisOrders.length === 0 && ( +
+ +
+ )} ); diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 7eba847..ca9fc22 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -26,8 +26,8 @@ export const generateMetadata = async () => { async function UserHomePage() { const client = getSupabaseServerClient(); - const account = await loadCurrentUserAccount(); - const api = await createAccountsApi(client); + const { account } = await loadCurrentUserAccount(); + const api = createAccountsApi(client); const bmiThresholds = await api.fetchBmiThresholds(); if (!account) { 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" > + @@ -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/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/[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', }, 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/app/select-package/page.tsx b/app/select-package/page.tsx index b45f213..532794f 100644 --- a/app/select-package/page.tsx +++ b/app/select-package/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { redirect } from 'next/navigation'; import { CaretRightIcon } from '@radix-ui/react-icons'; import { Scale } from 'lucide-react'; @@ -27,6 +28,10 @@ async function SelectPackagePage() { const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages(); + if (analysisPackageElements.length === 0) { + return redirect(pathsConfig.app.home); + } + return (
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/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"; diff --git a/lib/i18n/i18n.settings.ts b/lib/i18n/i18n.settings.ts index b970b7a..7bb07a8 100644 --- a/lib/i18n/i18n.settings.ts +++ b/lib/i18n/i18n.settings.ts @@ -37,6 +37,7 @@ export const defaultI18nNamespaces = [ 'booking', 'order-analysis-package', 'order-analysis', + 'order-health-analysis', 'cart', 'orders', 'analysis-results', 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/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/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/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index b6aec5d..6fb4302 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,12 +20,11 @@ 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'; 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'; @@ -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!; @@ -206,12 +194,13 @@ export async function readPrivateMessageResponse({ excludedMessageIds, }: { excludedMessageIds: string[]; -}): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined }> { +}): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined; analysisOrderId: number | undefined }> { let messageId: string | null = null; let hasAnalysisResponse = false; let hasPartialAnalysisResponse = false; let hasFullAnalysisResponse = false; let medusaOrderId: string | undefined = undefined; + let analysisOrderId: number | undefined = undefined; try { const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds }); @@ -224,6 +213,7 @@ export async function readPrivateMessageResponse({ hasPartialAnalysisResponse: false, hasFullAnalysisResponse: false, medusaOrderId: undefined, + analysisOrderId: undefined }; } @@ -232,16 +222,15 @@ export async function readPrivateMessageResponse({ ); const messageResponse = privateMessageContent?.Saadetis?.Vastus; - medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId; + analysisOrderId = Number(privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId); - const hasInvalidOrderId = !medusaOrderId || !medusaOrderId.toString().startsWith('order_'); + const hasInvalidOrderId = isNaN(analysisOrderId) if (hasInvalidOrderId || !messageResponse) { await createMedipostActionLog({ action: 'sync_analysis_results_from_medipost', xml: privateMessageXml, hasAnalysisResults: false, - medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId, }); return { messageId, @@ -249,12 +238,16 @@ export async function readPrivateMessageResponse({ hasPartialAnalysisResponse: false, hasFullAnalysisResponse: false, medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId, + analysisOrderId: hasInvalidOrderId ? undefined : analysisOrderId }; } + const analysisOrder = await getAnalysisOrder({ analysisOrderId: analysisOrderId }) + medusaOrderId = analysisOrder.medusa_order_id; + 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}`); @@ -263,11 +256,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; @@ -276,7 +269,7 @@ export async function readPrivateMessageResponse({ console.warn(`Failed to process private message id=${messageId}, message=${(e as Error).message}`); } - return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId }; + return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId, analysisOrderId }; } async function saveAnalysisGroup( @@ -451,122 +444,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, @@ -694,7 +571,7 @@ async function syncPrivateMessage({ ); } - const { data: allOrderResponseElements} = await supabase + const { data: allOrderResponseElements } = await supabase .schema('medreport') .from('analysis_response_elements') .select('*') @@ -714,21 +591,37 @@ export async function sendOrderToMedipost({ orderedAnalysisElements, }: { medusaOrderId: string; - orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[]; + orderedAnalysisElements: OrderedAnalysisElement[]; }) { - const medreportOrder = await getOrder({ medusaOrderId }); + const medreportOrder = await getAnalysisOrder({ 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, + orderId: medreportOrder.id, orderCreatedAt: new Date(medreportOrder.created_at), comment: '', }); @@ -780,7 +673,7 @@ export async function sendOrderToMedipost({ hasAnalysisResults: false, medusaOrderId, }); - await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); } export async function getOrderedAnalysisIds({ @@ -826,7 +719,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 []; } @@ -867,10 +765,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/lib/services/medipostTest.service.ts b/lib/services/medipostTest.service.ts index f4393f7..4b1d1ca 100644 --- a/lib/services/medipostTest.service.ts +++ b/lib/services/medipostTest.service.ts @@ -68,7 +68,7 @@ export async function composeOrderTestResponseXML({ }; orderedAnalysisElementsIds: number[]; orderedAnalysesIds: number[]; - orderId: string; + orderId: number; orderCreatedAt: Date; }) { const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); @@ -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 new file mode 100644 index 0000000..2b457d5 --- /dev/null +++ b/lib/services/medipostXML.service.ts @@ -0,0 +1,201 @@ +'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: number; + 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', + ); + + // 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, + ); + 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); + 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, + ProovinouKoodOID: container.ProovinouKoodOID, + ProovinouKood: container.ProovinouKood, + order: specimenOrder++, + }); + } + } + } + } + } + + // 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, + specimenOrderNumber, + relatedAnalysisElement, + ); + analysisSection.push(groupXml); + } + + return ` + + ${getPais(USER, RECIPIENT, orderId)} + + ${orderId} + ${getClientInstitution()} + ${getProviderInstitution()} + ${getClientPerson()} + ${getOrderEnteredPerson()} + ${comment ?? ''} + ${getPatient(person)} + ${getConfidentiality()} + ${specimenSection.join('')} + ${analysisSection?.join('')} + +`; +} 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'); } diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 487153a..eced8f9 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,12 +80,12 @@ export async function updateOrderStatus({ .throwOnError(); } -export async function getOrder({ +export async function getAnalysisOrder({ medusaOrderId, - orderId, + analysisOrderId, }: { medusaOrderId?: string; - orderId?: number; + analysisOrderId?: number; }) { const query = getSupabaseServerAdminClient() .schema('medreport') @@ -93,15 +93,15 @@ export async function getOrder({ .select('*') if (medusaOrderId) { query.eq('medusa_order_id', medusaOrderId); - } else if (orderId) { - query.eq('id', orderId); + } else if (analysisOrderId) { + query.eq('id', analysisOrderId); } else { throw new Error('Either medusaOrderId or orderId must be provided'); } const { data: order, error } = await query.single(); if (error) { - throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or orderId=${orderId}, message=${error.message}, data=${JSON.stringify(order)}`); + throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`); } return order; } diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 10ce573..f6da882 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -1,15 +1,14 @@ 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'; export const getPais = ( sender: string, recipient: string, - createdAt: Date, - orderId: string, + orderId: number, packageName = "OL", ) => { if (isProd) { @@ -19,7 +18,7 @@ export const getPais = ( ${packageName} ${sender} ${recipient} - ${format(createdAt, DATE_TIME_FORMAT)} + ${format(new Date(), DATE_TIME_FORMAT)} ${orderId} info@medreport.ee `; @@ -73,15 +72,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.value === 'M' ? 'M' : 'N'} `; }; diff --git a/lib/utils.ts b/lib/utils.ts index 3448c2b..233042a 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()), + ) ?? "" ); } @@ -40,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( @@ -58,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; @@ -83,9 +90,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'; +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: { label: string; value: string }; + 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, age=' + age); + })(); + 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, + gender, + dob: parsed.getBirthday(), + age: parsed.getAge(), + } + } } diff --git a/middleware.ts b/middleware.ts index 1507e71..b000a1c 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,14 @@ function getPatterns() { return NextResponse.redirect( new URL(pathsConfig.app.home, req.nextUrl.origin).href, ); + } 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, + ); } }, }, diff --git a/package.json b/package.json index e449db7..33ec639 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", @@ -101,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/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/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/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx index 6c302ff..b485d95 100644 --- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx @@ -265,11 +265,13 @@ function FactorQrCode({ z.object({ factorName: z.string().min(1), qrCode: z.string().min(1), + totpSecret: z.string().min(1), }), ), defaultValues: { factorName: '', qrCode: '', + totpSecret: '', }, }); @@ -319,6 +321,7 @@ function FactorQrCode({ if (data.type === 'totp') { form.setValue('factorName', name); form.setValue('qrCode', data.totp.qr_code); + form.setValue('totpSecret', data.totp.secret); } // dispatch event to set factor ID @@ -331,7 +334,7 @@ function FactorQrCode({ return (

@@ -343,6 +346,10 @@ function FactorQrCode({

+ +

+ {form.getValues('totpSecret')} +

); } diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts index 4c9e467..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'] & { @@ -48,6 +49,33 @@ 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: PersonalCode.getPersonalCode(personal_code), + }; + } + /** * @name getAccountWorkspace * @description Get the account workspace data. 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/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 454b552..2bb7830 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; @@ -110,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-in-methods-container.tsx b/packages/features/auth/src/components/sign-in-methods-container.tsx index 83bda02..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(); @@ -108,6 +110,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 c10054d..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,8 +1,7 @@ 'use client'; -import { redirect } from 'next/navigation'; - 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'; @@ -21,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(); @@ -39,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); + }} /> - + - +
@@ -72,13 +83,16 @@ export function SignUpMethodsContainer(props: {
diff --git a/packages/features/auth/src/server/api.ts b/packages/features/auth/src/server/api.ts index 6f8ead2..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; } @@ -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/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/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(), }) } diff --git a/packages/features/medusa-storefront/src/lib/data/customer.ts b/packages/features/medusa-storefront/src/lib/data/customer.ts index bf56d6e..3c05921 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,21 +126,21 @@ export async function login(_currentState: unknown, formData: FormData) { } } -export async function signout(countryCode?: string, shouldRedirect = true) { +export async function medusaLogout(countryCode = 'ee', canRevalidateTags = true) { await sdk.auth.logout() await removeAuthToken() - const customerCacheTag = await getCacheTag("customers") - revalidateTag(customerCacheTag) + if (canRevalidateTags) { + const customerCacheTag = await getCacheTag("customers") + revalidateTag(customerCacheTag) + } await removeCartId() - const cartCacheTag = await getCacheTag("carts") - revalidateTag(cartCacheTag) - - if (shouldRedirect) { - redirect(`/${countryCode!}/account`) + if (canRevalidateTags) { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) } } @@ -262,72 +261,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/lib/data/orders.ts b/packages/features/medusa-storefront/src/lib/data/orders.ts index c20931f..cf0231c 100644 --- a/packages/features/medusa-storefront/src/lib/data/orders.ts +++ b/packages/features/medusa-storefront/src/lib/data/orders.ts @@ -54,7 +54,6 @@ export const listOrders = async ( }, headers, next, - cache: "force-cache", }) .then(({ orders }) => orders) .catch((err) => medusaError(err)) diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index a8ea25d..b65efe9 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<{ @@ -68,7 +73,6 @@ export const listProducts = async ({ }, headers, next, - cache: "force-cache", } ) .then(({ products, count }) => { 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/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, + }); + } +} diff --git a/packages/shared/src/components/select-analysis-package.tsx b/packages/shared/src/components/select-analysis-package.tsx index ef05d0a..f46d2bc 100644 --- a/packages/shared/src/components/select-analysis-package.tsx +++ b/packages/shared/src/components/select-analysis-package.tsx @@ -5,12 +5,10 @@ import { useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import { StoreProduct } from '@medusajs/types'; -import { Button } from '@medusajs/ui'; +import type { AdminProductVariant, StoreProduct } from '@medusajs/types'; import { useTranslation } from 'react-i18next'; -import { handleAddToCart } from '../../../../lib/services/medusaCart.service'; -import { toast } from '@kit/ui/sonner'; +import { Button } from '@kit/ui/button'; import { Card, CardContent, @@ -18,18 +16,27 @@ import { CardFooter, CardHeader, } from '@kit/ui/card'; +import { toast } from '@kit/ui/sonner'; +import { Spinner } from '@kit/ui/spinner'; import { Trans } from '@kit/ui/trans'; -import { ButtonTooltip } from './ui/button-tooltip'; -import { PackageHeader } from './package-header'; -import { pathsConfig } from '../config'; -export type AnalysisPackageWithVariant = Pick & { +import { handleAddToCart } from '~/lib/services/medusaCart.service'; + +import { pathsConfig } from '../config'; +import { PackageHeader } from './package-header'; +import { ButtonTooltip } from './ui/button-tooltip'; + +export type AnalysisPackageWithVariant = Pick< + StoreProduct, + 'title' | 'description' | 'subtitle' | 'metadata' +> & { variantId: string; nrOfAnalyses: number; price: number; isStandard: boolean; isStandardPlus: boolean; isPremium: boolean; + variant: Pick; }; export default function SelectAnalysisPackage({ @@ -37,7 +44,7 @@ export default function SelectAnalysisPackage({ countryCode, }: { analysisPackage: AnalysisPackageWithVariant; - countryCode: string, + countryCode: string; }) { const router = useRouter(); const { @@ -46,8 +53,15 @@ export default function SelectAnalysisPackage({ } = useTranslation(); const [isAddingToCart, setIsAddingToCart] = useState(false); - - const { nrOfAnalyses, variantId, title, subtitle = '', description = '', price } = analysisPackage; + + const { + nrOfAnalyses, + variantId, + title, + subtitle = '', + description = '', + price, + } = analysisPackage; const handleSelect = async () => { setIsAddingToCart(true); @@ -57,10 +71,16 @@ export default function SelectAnalysisPackage({ countryCode, }); setIsAddingToCart(false); - toast.success(); + toast.success( + , + ); router.push(pathsConfig.app.cart); } catch (e) { - toast.error(); + toast.error( + , + ); setIsAddingToCart(false); console.error(e); } @@ -86,7 +106,7 @@ export default function SelectAnalysisPackage({ {subtitle} - diff --git a/packages/shared/src/components/ui/info-tooltip.tsx b/packages/shared/src/components/ui/info-tooltip.tsx index 10a7ae3..1217c24 100644 --- a/packages/shared/src/components/ui/info-tooltip.tsx +++ b/packages/shared/src/components/ui/info-tooltip.tsx @@ -23,7 +23,7 @@ export function InfoTooltip({ {icon || } - {content} + {content} ); 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..179e7da --- /dev/null +++ b/packages/shared/src/config/auth-providers.service.ts @@ -0,0 +1,144 @@ +import type { Provider } from '@supabase/supabase-js'; +import authConfig from './auth.config'; + +type SupabaseExternalProvider = Provider | 'email'; +interface SupabaseAuthSettings { + external: Record; + disable_signup: boolean; + mailer_autoconfirm: 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'; + } + + isMailerAutoconfirmEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean { + return settings?.mailer_autoconfirm === 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, isMailerAutoconfirmEnabled] = await Promise.all([ + this.isPasswordEnabled({ settings }), + this.isMagicLinkEnabled(), + this.getEnabledOAuthProviders({ settings }), + this.isMailerAutoconfirmEnabled({ settings }), + ]); + + return { + providers: { + password: passwordEnabled, + magicLink: magicLinkEnabled, + oAuth: oAuthProviders, + }, + displayTermsCheckbox: authConfig.displayTermsCheckbox, + isMailerAutoconfirmEnabled, + }; + } + + 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/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/shared/src/config/dynamic-auth.config.ts b/packages/shared/src/config/dynamic-auth.config.ts new file mode 100644 index 0000000..571516a --- /dev/null +++ b/packages/shared/src/config/dynamic-auth.config.ts @@ -0,0 +1,114 @@ +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.'), + 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(); + + const config = { + providers: dynamicProviders.providers, + displayTermsCheckbox: dynamicProviders.displayTermsCheckbox, + isMailerAutoconfirmEnabled: dynamicProviders.isMailerAutoconfirmEnabled, + }; + + 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..d1737bb 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 { DynamicAuthConfig, getCachedAuthConfig, getServerAuthConfig } from './dynamic-auth.config'; export { appConfig, @@ -18,4 +19,7 @@ export { getTeamAccountSidebarConfig, pathsConfig, personalAccountNavigationConfig, + getCachedAuthConfig, + getServerAuthConfig, + type DynamicAuthConfig, }; 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, + }; +} 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/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index c4697e9..f4e91a9 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "@kit/tsconfig/base.json", "compilerOptions": { - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "paths": { + "~/lib/*": ["../../lib/*"] + } }, "include": ["*.ts", "src"], "exclude": ["node_modules"] 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/database.types.ts b/packages/supabase/src/database.types.ts index a4f8cc1..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 @@ -2053,6 +2073,7 @@ export type Database = { p_personal_code: string p_phone: string p_uid: string + p_email: string } Returns: undefined } 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..7a64bd3 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(undefined, false); + } 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') diff --git a/packages/supabase/src/hooks/use-user.ts b/packages/supabase/src/hooks/use-user.ts index 0986775..9a9cdd9 100644 --- a/packages/supabase/src/hooks/use-user.ts +++ b/packages/supabase/src/hooks/use-user.ts @@ -28,8 +28,8 @@ export function useUser(initialData?: User | null) { queryFn, queryKey, initialData, - refetchInterval: false, - refetchOnMount: false, - refetchOnWindowFocus: false, + refetchInterval: 2_000, + refetchOnMount: true, + refetchOnWindowFocus: true, }); } diff --git a/packages/ui/src/makerkit/app-breadcrumbs.tsx b/packages/ui/src/makerkit/app-breadcrumbs.tsx index 31296b2..dc38b90 100644 --- a/packages/ui/src/makerkit/app-breadcrumbs.tsx +++ b/packages/ui/src/makerkit/app-breadcrumbs.tsx @@ -1,6 +1,7 @@ 'use client'; import { Fragment } from 'react'; +import clsx from 'clsx'; import { usePathname } from 'next/navigation'; @@ -52,9 +53,13 @@ export function AppBreadcrumbs(props: { /> ); + const isLast = index === visiblePaths.length - 1; + return ( - + {label} @@ -77,7 +83,7 @@ export function AppBreadcrumbs(props: { )} - + 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); } diff --git a/packages/ui/src/makerkit/page.tsx b/packages/ui/src/makerkit/page.tsx index 7a4ee42..aaf48ad 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}
@@ -106,7 +106,7 @@ export function PageBody( }>, ) { const className = cn( - 'flex w-full flex-1 flex-col space-y-6 lg:px-4', + 'flex w-full flex-1 flex-col space-y-6', props.className, ); @@ -119,8 +119,8 @@ export function PageNavigation(props: React.PropsWithChildren) { export function PageDescription(props: React.PropsWithChildren) { return ( -
-
+
+
{props.children}
@@ -158,7 +158,7 @@ export function PageHeader({ return (
@@ -168,7 +168,7 @@ export function PageHeader({ -
+
{displaySidebarTrigger ? ( ) : null} 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/pnpm-lock.yaml b/pnpm-lock.yaml index c7b691e..77d0440 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 @@ -217,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 @@ -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'} @@ -8887,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: @@ -9589,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==} @@ -9814,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.*'} @@ -20630,6 +20632,8 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + libphonenumber-js@1.12.15: {} + lightningcss-darwin-arm64@1.30.1: optional: true @@ -21523,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 @@ -21535,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: {} @@ -22491,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: @@ -22813,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 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/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/en/cart.json b/public/locales/en/cart.json index 84221a4..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", @@ -25,13 +22,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": { @@ -52,7 +55,11 @@ } }, "order": { - "title": "Order" + "title": "Order", + "promotionsTotal": "Promotions total", + "subtotal": "Subtotal", + "total": "Total", + "giftCard": "Gift card" }, "orderConfirmed": { "title": "Order confirmed", @@ -64,7 +71,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/en/common.json b/public/locales/en/common.json index ed8d175..cf41acd 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -128,6 +128,11 @@ "amount": "Amount", "selectDate": "Select date" }, + "formFieldError": { + "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", "expiredAt": "Valid until {{expiredAt}}" 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/en/order-health-analysis.json b/public/locales/en/order-health-analysis.json new file mode 100644 index 0000000..60633ac --- /dev/null +++ b/public/locales/en/order-health-analysis.json @@ -0,0 +1,4 @@ +{ + "title": "Order health analysis", + "description": "Select a suitable date and book your appointment time." +} \ No newline at end of file diff --git a/public/locales/en/orders.json b/public/locales/en/orders.json index f846b0a..7aa958c 100644 --- a/public/locales/en/orders.json +++ b/public/locales/en/orders.json @@ -1,6 +1,7 @@ { "title": "Orders", "description": "View your orders", + "noOrders": "No orders found", "table": { "analysisPackage": "Analysis package", "otherOrders": "Order", 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/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/et/cart.json b/public/locales/et/cart.json index a7bcb19..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", - "promotionsTotal": "Soodustuse summa", - "total": "Summa", "table": { "item": "Toode", "quantity": "Kogus", @@ -34,8 +31,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": { @@ -56,7 +55,11 @@ } }, "order": { - "title": "Tellimus" + "title": "Tellimus", + "promotionsTotal": "Soodustuse summa", + "subtotal": "Vahesumma", + "total": "Summa", + "giftCard": "Kinkekaart" }, "orderConfirmed": { "title": "Tellimus on edukalt esitatud", @@ -68,7 +71,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/et/common.json b/public/locales/et/common.json index 3a8f55c..96f3572 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -46,7 +46,7 @@ "skip": "Jäta vahele", "signedInAs": "Sisselogitud kasutajana", "pageOfPages": "Leht {{page}} / {{total}}", - "noData": "Andmeid puudub", + "noData": "Andmed puuduvad", "pageNotFoundHeading": "Ups! :|", "errorPageHeading": "Ups! :|", "notifications": "Teavitused", @@ -128,6 +128,11 @@ "amount": "Summa", "selectDate": "Vali kuupäev" }, + "formFieldError": { + "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", "expiredAt": "Kehtiv kuni {{expiredAt}}" 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/et/order-health-analysis.json b/public/locales/et/order-health-analysis.json new file mode 100644 index 0000000..f267e09 --- /dev/null +++ b/public/locales/et/order-health-analysis.json @@ -0,0 +1,4 @@ +{ + "title": "Telli terviseuuring", + "description": "Vali kalendrist sobiv kuupäev ja broneeri endale vastuvõtuaeg." +} \ No newline at end of file diff --git a/public/locales/et/orders.json b/public/locales/et/orders.json index 4811a2e..822c6b1 100644 --- a/public/locales/et/orders.json +++ b/public/locales/et/orders.json @@ -1,6 +1,7 @@ { "title": "Tellimused", "description": "Vaata oma tellimusi", + "noOrders": "Tellimusi ei leitud", "table": { "analysisPackage": "Analüüsi pakett", "otherOrders": "Tellimus", 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/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", diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 9aaeb3f..289ff31 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -3,8 +3,6 @@ "description": "Просмотрите свою корзину", "emptyCartMessage": "Ваша корзина пуста", "emptyCartMessageDescription": "Добавьте товары в корзину, чтобы продолжить.", - "subtotal": "Промежуточный итог", - "total": "Сумма", "table": { "item": "Товар", "quantity": "Количество", @@ -28,7 +26,15 @@ "label": "Добавить промокод", "apply": "Применить", "subtitle": "Если хотите, можете добавить промокод", - "placeholder": "Введите промокод" + "placeholder": "Введите промокод", + "remove": "Удалить промокод", + "appliedCodes": "Примененные промокоды:", + "removeError": "Не удалось удалить промокод", + "removeSuccess": "Промокод удален", + "removeLoading": "Удаление промокода...", + "addError": "Не удалось применить промокод", + "addSuccess": "Промокод применен", + "addLoading": "Применение промокода..." }, "items": { "synlabAnalyses": { @@ -49,7 +55,11 @@ } }, "order": { - "title": "Заказ" + "title": "Заказ", + "promotionsTotal": "Скидка", + "subtotal": "Промежуточный итог", + "total": "Сумма", + "giftCard": "Подарочная карта" }, "orderConfirmed": { "title": "Заказ успешно оформлен", @@ -61,7 +71,8 @@ "orderDate": "Дата заказа", "orderNumber": "Номер заказа", "orderStatus": "Статус заказа", - "paymentStatus": "Статус оплаты" + "paymentStatus": "Статус оплаты", + "discount": "Скидка" }, "montonioCallback": { "title": "Процесс оплаты Montonio", 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}}" 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 diff --git a/public/locales/ru/order-health-analysis.json b/public/locales/ru/order-health-analysis.json new file mode 100644 index 0000000..75c65f7 --- /dev/null +++ b/public/locales/ru/order-health-analysis.json @@ -0,0 +1,4 @@ +{ + "title": "Заказать анализ здоровья", + "description": "Выберите подходящую дату и забронируйте время для вашего приёма." +} \ No newline at end of file diff --git a/public/locales/ru/orders.json b/public/locales/ru/orders.json index c42a230..6669aff 100644 --- a/public/locales/ru/orders.json +++ b/public/locales/ru/orders.json @@ -1,6 +1,7 @@ { "title": "Заказы", "description": "Просмотрите ваши заказы", + "noOrders": "Заказы не найдены", "table": { "analysisPackage": "Пакет анализов", "otherOrders": "Заказ", 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; +$$; 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; diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts index 6cbbb3b..b08ef73 100644 --- a/utils/medusa-product.ts +++ b/utils/medusa-product.ts @@ -1,21 +1,43 @@ -export const getAnalysisElementMedusaProductIds = (products: ({ +import type { AdminProductVariant, StoreProduct } from "@medusajs/types"; + +type Product = { metadata?: { analysisElementMedusaProductIds?: string; } | null; -} | null)[]) => { + variant?: { + metadata?: { + analysisElementMedusaProductIds?: string; + } | null; + } | null; +} | null; + +export const getAnalysisElementMedusaProductIds = (products: (Pick & { variant?: 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("'", '"'); + + const result: string[] = []; try { - return JSON.parse(value 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[];