Merge pull request #81 from MR-medreport/develop
develop -> main; keycloak, fixes etc
This commit is contained in:
5
.env
5
.env
@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
|
|||||||
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
||||||
|
|
||||||
# AUTH
|
# AUTH
|
||||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
NEXT_PUBLIC_AUTH_PASSWORD=false
|
||||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
@@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
|||||||
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
||||||
|
|
||||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
|
# Configure Medusa password secret for Keycloak users
|
||||||
|
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
# SITE
|
# SITE
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||||
|
|
||||||
# SUPABASE DEVELOPMENT
|
# SUPABASE DEVELOPMENT
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ import { Button } from '@kit/ui/button';
|
|||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { featureFlagsConfig } from '@kit/shared/config';
|
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
|
|
||||||
|
|
||||||
const ModeToggle = dynamic(() =>
|
const ModeToggle = dynamic(() =>
|
||||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||||
@@ -75,11 +72,13 @@ function AuthButtons() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
{authConfig.providers.password && (
|
||||||
<Link href={pathsConfig.auth.signUp}>
|
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||||
<Trans i18nKey={'auth:signUp'} />
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
</Link>
|
<Trans i18nKey={'auth:signUp'} />
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { ArrowRightIcon } from 'lucide-react';
|
import { ArrowRightIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { CtaButton, Hero } from '@kit/ui/marketing';
|
import { CtaButton, Hero } from '@kit/ui/marketing';
|
||||||
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
|
|||||||
return (
|
return (
|
||||||
<div className={'flex space-x-4'}>
|
<div className={'flex space-x-4'}>
|
||||||
<CtaButton>
|
<CtaButton>
|
||||||
<Link href={'/auth/sign-up'}>
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
<span className={'flex items-center space-x-0.5'}>
|
<span className={'flex items-center space-x-0.5'}>
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey={'common:getStarted'} />
|
<Trans i18nKey={'common:getStarted'} />
|
||||||
|
|||||||
@@ -1,19 +1,62 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { NextRequest } from 'next/server';
|
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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
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) {
|
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 service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
|
const oauthResult = await service.exchangeCodeForSession(authCode);
|
||||||
|
if (!("isSuccess" in oauthResult)) {
|
||||||
|
return redirectOnError(oauthResult.searchParams);
|
||||||
|
}
|
||||||
|
|
||||||
const { nextPath } = await service.exchangeCodeForSession(request, {
|
const api = createAccountsApi(getSupabaseServerClient());
|
||||||
joinTeamPath: pathsConfig.app.joinTeam,
|
|
||||||
redirectPath: pathsConfig.app.home,
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
|
|
||||||
|
|||||||
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||||
|
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export default function PasswordOption({
|
||||||
|
inviteToken,
|
||||||
|
returnPath,
|
||||||
|
}: {
|
||||||
|
inviteToken?: string;
|
||||||
|
returnPath?: string;
|
||||||
|
}) {
|
||||||
|
const signUpPath =
|
||||||
|
pathsConfig.auth.signUp +
|
||||||
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
callback: pathsConfig.auth.callback,
|
||||||
|
returnPath: returnPath ?? pathsConfig.app.home,
|
||||||
|
joinTeam: pathsConfig.app.joinTeam,
|
||||||
|
updateAccount: pathsConfig.auth.updateAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
<Heading level={4} className={'tracking-tight'}>
|
||||||
|
<Trans i18nKey={'auth:signInHeading'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'auth:signInSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignInMethodsContainer
|
||||||
|
inviteToken={inviteToken}
|
||||||
|
paths={paths}
|
||||||
|
providers={authConfig.providers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'flex justify-center'}>
|
||||||
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
|
<Link href={signUpPath} prefetch={true}>
|
||||||
|
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
@@ -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 <Loading />;
|
||||||
|
}
|
||||||
@@ -1,14 +1,9 @@
|
|||||||
import Link from 'next/link';
|
import { pathsConfig, authConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
|
|
||||||
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 { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
|
||||||
|
import PasswordOption from './components/PasswordOption';
|
||||||
|
|
||||||
interface SignInPageProps {
|
interface SignInPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -26,47 +21,14 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
||||||
await searchParams;
|
await searchParams;
|
||||||
|
|
||||||
const signUpPath =
|
if (authConfig.providers.password) {
|
||||||
pathsConfig.auth.signUp +
|
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
}
|
||||||
|
|
||||||
const paths = {
|
return <SignInPageClientRedirect />;
|
||||||
callback: pathsConfig.auth.callback,
|
|
||||||
returnPath: next ?? pathsConfig.app.home,
|
|
||||||
joinTeam: pathsConfig.app.joinTeam,
|
|
||||||
updateAccount: pathsConfig.auth.updateAccount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
|
||||||
<Heading level={4} className={'tracking-tight'}>
|
|
||||||
<Trans i18nKey={'auth:signInHeading'} />
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<p className={'text-muted-foreground text-sm'}>
|
|
||||||
<Trans i18nKey={'auth:signInSubheading'} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SignInMethodsContainer
|
|
||||||
inviteToken={inviteToken}
|
|
||||||
paths={paths}
|
|
||||||
providers={authConfig.providers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={'flex justify-center'}>
|
|
||||||
<Button asChild variant={'link'} size={'sm'}>
|
|
||||||
<Link href={signUpPath} prefetch={true}>
|
|
||||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(SignInPage);
|
export default withI18n(SignInPage);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||||
import { authConfig, pathsConfig } from '@kit/shared/config';
|
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||||
@@ -37,6 +38,10 @@ async function SignUpPage({ searchParams }: Props) {
|
|||||||
pathsConfig.auth.signIn +
|
pathsConfig.auth.signIn +
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
|
if (!authConfig.providers.password) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { User } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
import { ExternalLink } from '@/public/assets/external-link';
|
import { ExternalLink } from '@/public/assets/external-link';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -23,31 +21,52 @@ import { Trans } from '@kit/ui/trans';
|
|||||||
|
|
||||||
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
|
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
|
||||||
import { onUpdateAccount } from '../_lib/server/update-account';
|
import { onUpdateAccount } from '../_lib/server/update-account';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export function UpdateAccountForm({ user }: { user: User }) {
|
type UpdateAccountFormValues = z.infer<typeof UpdateAccountSchema>;
|
||||||
|
|
||||||
|
export function UpdateAccountForm({
|
||||||
|
defaultValues,
|
||||||
|
}: {
|
||||||
|
defaultValues: UpdateAccountFormValues,
|
||||||
|
}) {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(UpdateAccountSchema),
|
resolver: zodResolver(UpdateAccountSchema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues,
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
personalCode: '',
|
|
||||||
email: user.email,
|
|
||||||
phone: '',
|
|
||||||
city: '',
|
|
||||||
weight: 0,
|
|
||||||
height: 0,
|
|
||||||
userConsent: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues;
|
||||||
|
|
||||||
|
const hasFirstName = !!firstName;
|
||||||
|
const hasLastName = !!lastName;
|
||||||
|
const hasPersonalCode = !!personalCode;
|
||||||
|
const hasEmail = !!email;
|
||||||
|
const hasWeight = !!weight;
|
||||||
|
const hasHeight = !!height;
|
||||||
|
const hasUserConsent = !!userConsent;
|
||||||
|
|
||||||
|
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) =>
|
||||||
|
onUpdateAccount({
|
||||||
|
...values,
|
||||||
|
...(hasFirstName && { firstName }),
|
||||||
|
...(hasLastName && { lastName }),
|
||||||
|
...(hasPersonalCode && { personalCode }),
|
||||||
|
...(hasEmail && { email }),
|
||||||
|
...(hasWeight && { weight: values.weight ?? weight }),
|
||||||
|
...(hasHeight && { height: values.height ?? height }),
|
||||||
|
...(hasUserConsent && { userConsent: values.userConsent ?? userConsent }),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
||||||
onSubmit={form.handleSubmit(onUpdateAccount)}
|
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name="firstName"
|
name="firstName"
|
||||||
|
disabled={hasFirstName}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -63,6 +82,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="lastName"
|
name="lastName"
|
||||||
|
disabled={hasLastName}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -78,6 +98,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="personalCode"
|
name="personalCode"
|
||||||
|
disabled={hasPersonalCode}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -93,13 +114,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="email"
|
name="email"
|
||||||
|
disabled={hasEmail}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'common:formField:email'} />
|
<Trans i18nKey={'common:formField:email'} />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} disabled />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import parsePhoneNumber from 'libphonenumber-js/min';
|
||||||
|
|
||||||
export const UpdateAccountSchema = z.object({
|
export const UpdateAccountSchema = z.object({
|
||||||
firstName: z
|
firstName: z
|
||||||
@@ -23,7 +24,20 @@ export const UpdateAccountSchema = z.object({
|
|||||||
.string({
|
.string({
|
||||||
error: 'Phone number is required',
|
error: 'Phone number is required',
|
||||||
})
|
})
|
||||||
.nonempty(),
|
.nonempty()
|
||||||
|
.refine(
|
||||||
|
(phone) => {
|
||||||
|
try {
|
||||||
|
const phoneNumber = parsePhoneNumber(phone);
|
||||||
|
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'common:formFieldError.invalidPhoneNumber',
|
||||||
|
}
|
||||||
|
),
|
||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
weight: z
|
weight: z
|
||||||
.number({
|
.number({
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ export const onUpdateAccount = enhanceAction(
|
|||||||
console.warn('On update account error: ', err);
|
console.warn('On update account error: ', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCustomer({
|
try {
|
||||||
first_name: params.firstName,
|
await updateCustomer({
|
||||||
last_name: params.lastName,
|
first_name: params.firstName,
|
||||||
phone: params.phone,
|
last_name: params.lastName,
|
||||||
});
|
phone: params.phone,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update Medusa customer", e);
|
||||||
|
}
|
||||||
|
|
||||||
const hasUnseenMembershipConfirmation =
|
const hasUnseenMembershipConfirmation =
|
||||||
await api.hasUnseenMembershipConfirmation();
|
await api.hasUnseenMembershipConfirmation();
|
||||||
|
|||||||
@@ -11,18 +11,39 @@ import { Trans } from '@kit/ui/trans';
|
|||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import { UpdateAccountForm } from './_components/update-account-form';
|
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() {
|
async function UpdateAccount() {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const account = await loadCurrentUserAccount();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
} = await client.auth.getUser();
|
} = await client.auth.getUser();
|
||||||
|
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(pathsConfig.auth.signIn);
|
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 (
|
return (
|
||||||
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
||||||
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
|
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
|
||||||
@@ -34,7 +55,7 @@ async function UpdateAccount() {
|
|||||||
<p className="text-muted-foreground pt-1 text-sm">
|
<p className="text-muted-foreground pt-1 text-sm">
|
||||||
<Trans i18nKey={'account:updateAccount:description'} />
|
<Trans i18nKey={'account:updateAccount:description'} />
|
||||||
</p>
|
</p>
|
||||||
<UpdateAccountForm user={user} />
|
<UpdateAccountForm defaultValues={defaultValues} />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
|
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ export async function processMontonioCallback(orderToken: string) {
|
|||||||
throw new Error("Cart not found");
|
throw new Error("Cart not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
|
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
|
||||||
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
||||||
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async function UserHomePage() {
|
|||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const account = await loadCurrentUserAccount();
|
const account = await loadCurrentUserAccount();
|
||||||
const api = await createAccountsApi(client);
|
const api = createAccountsApi(client);
|
||||||
const bmiThresholds = await api.fetchBmiThresholds();
|
const bmiThresholds = await api.fetchBmiThresholds();
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const ComparePackagesModal = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={id}>
|
<TableRow key={id}>
|
||||||
<TableCell className="py-6">
|
<TableCell className="py-6 sm:max-w-[30vw]">
|
||||||
{title}{' '}
|
{title}{' '}
|
||||||
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
|
{description && (<InfoTooltip content={description} icon={<QuestionMarkCircledIcon />} />)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { getPersonParameters } from '@kit/shared/utils';
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -30,7 +29,7 @@ import { cn } from '@kit/ui/utils';
|
|||||||
|
|
||||||
import { isNil } from 'lodash';
|
import { isNil } from 'lodash';
|
||||||
import { BmiCategory } from '~/lib/types/bmi';
|
import { BmiCategory } from '~/lib/types/bmi';
|
||||||
import {
|
import PersonalCode, {
|
||||||
bmiFromMetric,
|
bmiFromMetric,
|
||||||
getBmiBackgroundColor,
|
getBmiBackgroundColor,
|
||||||
getBmiStatus,
|
getBmiStatus,
|
||||||
@@ -60,7 +59,7 @@ const cards = ({
|
|||||||
}) => [
|
}) => [
|
||||||
{
|
{
|
||||||
title: 'dashboard:gender',
|
title: 'dashboard:gender',
|
||||||
description: gender ?? 'dashboard:male',
|
description: gender ?? '-',
|
||||||
icon: <User />,
|
icon: <User />,
|
||||||
iconBg: 'bg-success',
|
iconBg: 'bg-success',
|
||||||
},
|
},
|
||||||
@@ -84,7 +83,7 @@ const cards = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'dashboard:bmi',
|
title: 'dashboard:bmi',
|
||||||
description: bmiFromMetric(weight || 0, height || 0).toString(),
|
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
|
||||||
icon: <TrendingUp />,
|
icon: <TrendingUp />,
|
||||||
iconBg: getBmiBackgroundColor(bmiStatus),
|
iconBg: getBmiBackgroundColor(bmiStatus),
|
||||||
},
|
},
|
||||||
@@ -145,21 +144,19 @@ export default function Dashboard({
|
|||||||
'id'
|
'id'
|
||||||
>[];
|
>[];
|
||||||
}) {
|
}) {
|
||||||
const params = getPersonParameters(account.personal_code!);
|
const height = account.accountParams?.height || 0;
|
||||||
const bmiStatus = getBmiStatus(bmiThresholds, {
|
const weight = account.accountParams?.weight || 0;
|
||||||
age: params?.age || 0,
|
const { age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!);
|
||||||
height: account.accountParams?.height || 0,
|
const bmiStatus = getBmiStatus(bmiThresholds, { age, height, weight });
|
||||||
weight: account.accountParams?.weight || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
||||||
{cards({
|
{cards({
|
||||||
gender: params?.gender,
|
gender: gender.label,
|
||||||
age: params?.age,
|
age,
|
||||||
height: account.accountParams?.height,
|
height,
|
||||||
weight: account.accountParams?.weight,
|
weight,
|
||||||
bmiStatus,
|
bmiStatus,
|
||||||
smoking: account.accountParams?.isSmoker,
|
smoking: account.accountParams?.isSmoker,
|
||||||
}).map(
|
}).map(
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function OrderAnalysesCards({
|
|||||||
className="px-2 text-black"
|
className="px-2 text-black"
|
||||||
onClick={() => handleSelect(variant.id)}
|
onClick={() => handleSelect(variant.id)}
|
||||||
>
|
>
|
||||||
{variantAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
{variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import Isikukood, { Gender } from 'isikukood';
|
|
||||||
|
|
||||||
import { listProductTypes, listProducts } from "@lib/data/products";
|
import { listProductTypes, listProducts } from "@lib/data/products";
|
||||||
import { listRegions } from '@lib/data/regions';
|
import { listRegions } from '@lib/data/regions';
|
||||||
@@ -8,6 +7,7 @@ import type { StoreProduct } from '@medusajs/types';
|
|||||||
import { loadCurrentUserAccount } from './load-user-account';
|
import { loadCurrentUserAccount } from './load-user-account';
|
||||||
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
||||||
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
|
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
|
||||||
|
import PersonalCode from '~/lib/utils';
|
||||||
|
|
||||||
async function countryCodesLoader() {
|
async function countryCodesLoader() {
|
||||||
const countryCodes = await listRegions().then((regions) =>
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
@@ -32,27 +32,8 @@ function userSpecificVariantLoader({
|
|||||||
if (!personalCode) {
|
if (!personalCode) {
|
||||||
throw new Error('Personal code not found');
|
throw new Error('Personal code not found');
|
||||||
}
|
}
|
||||||
const parsed = new Isikukood(personalCode);
|
|
||||||
const ageRange = (() => {
|
const { ageRange, gender: { value: gender } } = PersonalCode.parsePersonalCode(personalCode);
|
||||||
const age = parsed.getAge();
|
|
||||||
if (age >= 18 && age <= 29) {
|
|
||||||
return '18-29';
|
|
||||||
}
|
|
||||||
if (age >= 30 && age <= 39) {
|
|
||||||
return '30-39';
|
|
||||||
}
|
|
||||||
if (age >= 40 && age <= 49) {
|
|
||||||
return '40-49';
|
|
||||||
}
|
|
||||||
if (age >= 50 && age <= 59) {
|
|
||||||
return '50-59';
|
|
||||||
}
|
|
||||||
if (age >= 60) {
|
|
||||||
return '60';
|
|
||||||
}
|
|
||||||
throw new Error('Age range not supported');
|
|
||||||
})();
|
|
||||||
const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F';
|
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
product,
|
product,
|
||||||
@@ -89,6 +70,7 @@ async function analysisPackageElementsLoader({
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
id: analysisElementMedusaProductIds,
|
id: analysisElementMedusaProductIds,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
order: "title",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ export const loadUserAccount = cache(accountLoader);
|
|||||||
|
|
||||||
export async function loadCurrentUserAccount() {
|
export async function loadCurrentUserAccount() {
|
||||||
const user = await requireUserInServerComponent();
|
const user = await requireUserInServerComponent();
|
||||||
return user?.identities?.[0]?.id
|
return user?.id
|
||||||
? await loadUserAccount(user?.identities?.[0]?.id)
|
? await loadUserAccount(user.id)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accountLoader(accountId: string) {
|
async function accountLoader(userId: string) {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const api = createAccountsApi(client);
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
return api.getAccount(accountId);
|
return api.getPersonalAccountByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ export const getAccountHealthDetailsFields = (
|
|||||||
>[],
|
>[],
|
||||||
members: Database['medreport']['Functions']['get_account_members']['Returns'],
|
members: Database['medreport']['Functions']['get_account_members']['Returns'],
|
||||||
): AccountHealthDetailsField[] => {
|
): AccountHealthDetailsField[] => {
|
||||||
const avarageWeight =
|
const averageWeight =
|
||||||
memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length;
|
memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length;
|
||||||
const avarageHeight =
|
const averageHeight =
|
||||||
memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length;
|
memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length;
|
||||||
const avarageAge =
|
const averageAge =
|
||||||
members.reduce((sum, r) => {
|
members.reduce((sum, r) => {
|
||||||
const person = new Isikukood(r.personal_code);
|
const person = new Isikukood(r.personal_code);
|
||||||
return sum + person.getAge();
|
return sum + person.getAge();
|
||||||
@@ -48,11 +48,11 @@ export const getAccountHealthDetailsFields = (
|
|||||||
const person = new Isikukood(r.personal_code);
|
const person = new Isikukood(r.personal_code);
|
||||||
return person.getGender() === 'female';
|
return person.getGender() === 'female';
|
||||||
}).length;
|
}).length;
|
||||||
const averageBMI = bmiFromMetric(avarageWeight, avarageHeight);
|
const averageBMI = bmiFromMetric(averageWeight, averageHeight);
|
||||||
const bmiStatus = getBmiStatus(bmiThresholds, {
|
const bmiStatus = getBmiStatus(bmiThresholds, {
|
||||||
age: avarageAge,
|
age: averageAge,
|
||||||
height: avarageHeight,
|
height: averageHeight,
|
||||||
weight: avarageWeight,
|
weight: averageWeight,
|
||||||
});
|
});
|
||||||
const malePercentage = members.length
|
const malePercentage = members.length
|
||||||
? (numberOfMaleMembers / members.length) * 100
|
? (numberOfMaleMembers / members.length) * 100
|
||||||
@@ -76,7 +76,7 @@ export const getAccountHealthDetailsFields = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'teams:healthDetails.avgAge',
|
title: 'teams:healthDetails.avgAge',
|
||||||
value: avarageAge.toFixed(0),
|
value: averageAge.toFixed(0),
|
||||||
Icon: Clock,
|
Icon: Clock,
|
||||||
iconBg: 'bg-success',
|
iconBg: 'bg-success',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,9 +3,26 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { createClient } from '@/utils/supabase/server';
|
import { createClient } from '@/utils/supabase/server';
|
||||||
|
import { medusaLogout } from '@lib/data/customer';
|
||||||
|
|
||||||
export const signOutAction = async () => {
|
export const signOutAction = async () => {
|
||||||
const supabase = await createClient();
|
const client = await createClient();
|
||||||
await supabase.auth.signOut();
|
|
||||||
|
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('/');
|
return redirect('/');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
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";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Tables } from '@/packages/supabase/src/database.types';
|
|||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
import type { IUuringElement } from "./medipost.types";
|
import type { IUuringElement } from "./medipost.types";
|
||||||
|
|
||||||
type AnalysesWithGroupsAndElements = ({
|
export type AnalysesWithGroupsAndElements = ({
|
||||||
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
|
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
|
||||||
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
|
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<AnalysesWithGroupsAndElements> {
|
export async function getAnalyses({
|
||||||
|
ids,
|
||||||
|
originalIds,
|
||||||
|
}: {
|
||||||
|
ids?: number[];
|
||||||
|
originalIds?: string[];
|
||||||
|
}): Promise<AnalysesWithGroupsAndElements> {
|
||||||
const query = getSupabaseServerAdminClient()
|
const query = getSupabaseServerAdminClient()
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('analyses')
|
.from('analyses')
|
||||||
|
|||||||
@@ -5,23 +5,11 @@ import {
|
|||||||
createClient as createCustomClient,
|
createClient as createCustomClient,
|
||||||
} from '@supabase/supabase-js';
|
} 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 { SyncStatus } from '@/lib/types/audit';
|
||||||
import {
|
import {
|
||||||
AnalysisOrderStatus,
|
AnalysisOrderStatus,
|
||||||
GetMessageListResponse,
|
GetMessageListResponse,
|
||||||
IMedipostResponseXMLBase,
|
IMedipostResponseXMLBase,
|
||||||
MaterjalideGrupp,
|
|
||||||
MedipostAction,
|
MedipostAction,
|
||||||
MedipostOrderResponse,
|
MedipostOrderResponse,
|
||||||
MedipostPublicMessageResponse,
|
MedipostPublicMessageResponse,
|
||||||
@@ -32,7 +20,6 @@ import {
|
|||||||
import { toArray } from '@/lib/utils';
|
import { toArray } from '@/lib/utils';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import { uniqBy } from 'lodash';
|
|
||||||
|
|
||||||
import { Tables } from '@kit/supabase/database';
|
import { Tables } from '@kit/supabase/database';
|
||||||
import { createAnalysisGroup } from './analysis-group.service';
|
import { createAnalysisGroup } from './analysis-group.service';
|
||||||
@@ -47,6 +34,7 @@ import { listRegions } from '@lib/data/regions';
|
|||||||
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
||||||
import { MedipostValidationError } from './medipost/MedipostValidationError';
|
import { MedipostValidationError } from './medipost/MedipostValidationError';
|
||||||
import { logMedipostDispatch } from './audit.service';
|
import { logMedipostDispatch } from './audit.service';
|
||||||
|
import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service';
|
||||||
|
|
||||||
const BASE_URL = process.env.MEDIPOST_URL!;
|
const BASE_URL = process.env.MEDIPOST_URL!;
|
||||||
const USER = process.env.MEDIPOST_USER!;
|
const USER = process.env.MEDIPOST_USER!;
|
||||||
@@ -451,122 +439,6 @@ export async function syncPublicMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function composeOrderXML({
|
|
||||||
person,
|
|
||||||
orderedAnalysisElementsIds,
|
|
||||||
orderedAnalysesIds,
|
|
||||||
orderId,
|
|
||||||
orderCreatedAt,
|
|
||||||
comment,
|
|
||||||
}: {
|
|
||||||
person: {
|
|
||||||
idCode: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
phone: string;
|
|
||||||
};
|
|
||||||
orderedAnalysisElementsIds: number[];
|
|
||||||
orderedAnalysesIds: number[];
|
|
||||||
orderId: string;
|
|
||||||
orderCreatedAt: Date;
|
|
||||||
comment?: string;
|
|
||||||
}) {
|
|
||||||
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
|
|
||||||
if (analysisElements.length !== orderedAnalysisElementsIds.length) {
|
|
||||||
throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const analyses = await getAnalyses({ ids: orderedAnalysesIds });
|
|
||||||
if (analyses.length !== orderedAnalysesIds.length) {
|
|
||||||
throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
|
|
||||||
uniqBy(
|
|
||||||
(
|
|
||||||
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
|
|
||||||
[]
|
|
||||||
).concat(
|
|
||||||
analyses?.flatMap(
|
|
||||||
({ analysis_elements }) => analysis_elements.analysis_groups,
|
|
||||||
) ?? [],
|
|
||||||
),
|
|
||||||
'id',
|
|
||||||
);
|
|
||||||
|
|
||||||
const specimenSection = [];
|
|
||||||
const analysisSection = [];
|
|
||||||
let order = 1;
|
|
||||||
for (const currentGroup of analysisGroups) {
|
|
||||||
let relatedAnalysisElement = analysisElements?.find(
|
|
||||||
(element) => element.analysis_groups.id === currentGroup.id,
|
|
||||||
);
|
|
||||||
const relatedAnalyses = analyses?.filter((analysis) => {
|
|
||||||
return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!relatedAnalysisElement) {
|
|
||||||
relatedAnalysisElement = relatedAnalyses?.find(
|
|
||||||
(relatedAnalysis) =>
|
|
||||||
relatedAnalysis.analysis_elements.analysis_groups.id ===
|
|
||||||
currentGroup.id,
|
|
||||||
)?.analysis_elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
|
|
||||||
const materials = toArray(group.Materjal);
|
|
||||||
const specimenXml = materials.flatMap(
|
|
||||||
({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => {
|
|
||||||
return toArray(Konteiner).map((container) =>
|
|
||||||
getSpecimen(
|
|
||||||
MaterjaliTyypOID,
|
|
||||||
MaterjaliTyyp,
|
|
||||||
MaterjaliNimi,
|
|
||||||
order,
|
|
||||||
container.ProovinouKoodOID,
|
|
||||||
container.ProovinouKood,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
specimenSection.push(...specimenXml);
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupXml = getAnalysisGroup(
|
|
||||||
currentGroup.original_id,
|
|
||||||
currentGroup.name,
|
|
||||||
order,
|
|
||||||
relatedAnalysisElement,
|
|
||||||
);
|
|
||||||
order++;
|
|
||||||
analysisSection.push(groupXml);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
|
|
||||||
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
|
|
||||||
<Tellimus cito="EI">
|
|
||||||
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
|
||||||
${getClientInstitution()}
|
|
||||||
${getProviderInstitution()}
|
|
||||||
${getClientPerson()}
|
|
||||||
${getOrderEnteredPerson()}
|
|
||||||
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
|
|
||||||
${getPatient(person)}
|
|
||||||
${getConfidentiality()}
|
|
||||||
${specimenSection.join('')}
|
|
||||||
${analysisSection?.join('')}
|
|
||||||
</Tellimus>
|
|
||||||
</Saadetis>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLatestMessage({
|
function getLatestMessage({
|
||||||
messages,
|
messages,
|
||||||
excludedMessageIds,
|
excludedMessageIds,
|
||||||
@@ -694,7 +566,7 @@ async function syncPrivateMessage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: allOrderResponseElements} = await supabase
|
const { data: allOrderResponseElements } = await supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('analysis_response_elements')
|
.from('analysis_response_elements')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -714,20 +586,36 @@ export async function sendOrderToMedipost({
|
|||||||
orderedAnalysisElements,
|
orderedAnalysisElements,
|
||||||
}: {
|
}: {
|
||||||
medusaOrderId: string;
|
medusaOrderId: string;
|
||||||
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
|
orderedAnalysisElements: OrderedAnalysisElement[];
|
||||||
}) {
|
}) {
|
||||||
const medreportOrder = await getOrder({ medusaOrderId });
|
const medreportOrder = await getOrder({ medusaOrderId });
|
||||||
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
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({
|
const orderXml = await composeOrderXML({
|
||||||
|
analyses,
|
||||||
|
analysisElements,
|
||||||
person: {
|
person: {
|
||||||
idCode: account.personal_code!,
|
idCode: account.personal_code!,
|
||||||
firstName: account.name ?? '',
|
firstName: account.name ?? '',
|
||||||
lastName: account.last_name ?? '',
|
lastName: account.last_name ?? '',
|
||||||
phone: account.phone ?? '',
|
phone: account.phone ?? '',
|
||||||
},
|
},
|
||||||
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
|
||||||
orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
|
||||||
orderId: medusaOrderId,
|
orderId: medusaOrderId,
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
comment: '',
|
comment: '',
|
||||||
@@ -826,7 +714,12 @@ export async function getOrderedAnalysisIds({
|
|||||||
throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`);
|
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) {
|
if (ids.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -867,10 +760,10 @@ export async function createMedipostActionLog({
|
|||||||
hasError = false,
|
hasError = false,
|
||||||
}: {
|
}: {
|
||||||
action:
|
action:
|
||||||
| 'send_order_to_medipost'
|
| 'send_order_to_medipost'
|
||||||
| 'sync_analysis_results_from_medipost'
|
| 'sync_analysis_results_from_medipost'
|
||||||
| 'send_fake_analysis_results_to_medipost'
|
| 'send_fake_analysis_results_to_medipost'
|
||||||
| 'send_analysis_results_to_medipost';
|
| 'send_analysis_results_to_medipost';
|
||||||
xml: string;
|
xml: string;
|
||||||
hasAnalysisResults?: boolean;
|
hasAnalysisResults?: boolean;
|
||||||
medusaOrderId?: string | null;
|
medusaOrderId?: string | null;
|
||||||
|
|||||||
201
lib/services/medipostXML.service.ts
Normal file
201
lib/services/medipostXML.service.ts
Normal file
@@ -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: string;
|
||||||
|
orderCreatedAt: Date;
|
||||||
|
comment?: string;
|
||||||
|
}) {
|
||||||
|
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
|
||||||
|
uniqBy(
|
||||||
|
(
|
||||||
|
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
|
||||||
|
[]
|
||||||
|
).concat(
|
||||||
|
analyses?.flatMap(
|
||||||
|
({ analysis_elements }) => analysis_elements.analysis_groups,
|
||||||
|
) ?? [],
|
||||||
|
),
|
||||||
|
'id',
|
||||||
|
);
|
||||||
|
|
||||||
|
// First, collect all unique materials across all analysis groups
|
||||||
|
const uniqueMaterials = new Map<string, {
|
||||||
|
MaterjaliTyypOID: string;
|
||||||
|
MaterjaliTyyp: string;
|
||||||
|
MaterjaliNimi: string;
|
||||||
|
ProovinouKoodOID?: string;
|
||||||
|
ProovinouKood?: string;
|
||||||
|
order: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
|
||||||
|
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
|
||||||
|
<Tellimus cito="EI">
|
||||||
|
<ValisTellimuseId>${orderId}</ValisTellimuseId>
|
||||||
|
${getClientInstitution()}
|
||||||
|
${getProviderInstitution()}
|
||||||
|
${getClientPerson()}
|
||||||
|
${getOrderEnteredPerson()}
|
||||||
|
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
|
||||||
|
${getPatient(person)}
|
||||||
|
${getConfidentiality()}
|
||||||
|
${specimenSection.join('')}
|
||||||
|
${analysisSection?.join('')}
|
||||||
|
</Tellimus>
|
||||||
|
</Saadetis>`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import Isikukood, { Gender } from 'isikukood';
|
|
||||||
import { Tables } from '@/packages/supabase/src/database.types';
|
import { Tables } from '@/packages/supabase/src/database.types';
|
||||||
import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants';
|
import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants';
|
||||||
|
import PersonalCode from '../utils';
|
||||||
|
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@@ -73,15 +73,15 @@ export const getPatient = ({
|
|||||||
lastName: string,
|
lastName: string,
|
||||||
firstName: string,
|
firstName: string,
|
||||||
}) => {
|
}) => {
|
||||||
const isikukood = new Isikukood(idCode);
|
const { dob, gender } = PersonalCode.parsePersonalCode(idCode);
|
||||||
return `<Patsient>
|
return `<Patsient>
|
||||||
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
|
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
|
||||||
<Isikukood>${idCode}</Isikukood>
|
<Isikukood>${idCode}</Isikukood>
|
||||||
<PerekonnaNimi>${lastName}</PerekonnaNimi>
|
<PerekonnaNimi>${lastName}</PerekonnaNimi>
|
||||||
<EesNimi>${firstName}</EesNimi>
|
<EesNimi>${firstName}</EesNimi>
|
||||||
<SynniAeg>${format(isikukood.getBirthday(), DATE_FORMAT)}</SynniAeg>
|
<SynniAeg>${format(dob, DATE_FORMAT)}</SynniAeg>
|
||||||
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
|
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
|
||||||
<Sugu>${isikukood.getGender() === Gender.MALE ? 'M' : 'N'}</Sugu>
|
<Sugu>${gender.value === 'M' ? 'M' : 'N'}</Sugu>
|
||||||
</Patsient>`;
|
</Patsient>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
85
lib/utils.ts
85
lib/utils.ts
@@ -15,11 +15,12 @@ export function toArray<T>(input?: T | T[] | null): T[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toTitleCase(str?: string) {
|
export function toTitleCase(str?: string) {
|
||||||
if (!str) return '';
|
return (
|
||||||
return str.replace(
|
str
|
||||||
/\w\S*/g,
|
?.toLowerCase()
|
||||||
(text: string) =>
|
.replace(/[^-'’\s]+/g, (match) =>
|
||||||
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
match.replace(/^./, (first) => first.toUpperCase()),
|
||||||
|
) ?? ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +41,12 @@ export function sortByDate<T>(
|
|||||||
|
|
||||||
export const bmiFromMetric = (kg: number, cm: number) => {
|
export const bmiFromMetric = (kg: number, cm: number) => {
|
||||||
const m = cm / 100;
|
const m = cm / 100;
|
||||||
const bmi = kg / (m * m);
|
const m2 = m * m;
|
||||||
return bmi ? Math.round(bmi) : NaN;
|
if (m2 === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bmi = kg / m2;
|
||||||
|
return !Number.isNaN(bmi) ? Math.round(bmi) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getBmiStatus(
|
export function getBmiStatus(
|
||||||
@@ -58,7 +63,9 @@ export function getBmiStatus(
|
|||||||
) || null;
|
) || null;
|
||||||
const bmi = bmiFromMetric(params.weight, params.height);
|
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.obesity_min) return BmiCategory.OBESE;
|
||||||
if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT;
|
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) {
|
type AgeRange = '18-29' | '30-39' | '40-49' | '50-59' | '60';
|
||||||
const person = new Isikukood(personalCode);
|
export default class PersonalCode {
|
||||||
if (person.getGender() === Gender.FEMALE) return 'common:female';
|
static getPersonalCode(personalCode: string | null) {
|
||||||
if (person.getGender() === Gender.MALE) return 'common:male';
|
if (!personalCode) {
|
||||||
return 'common:unknown';
|
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');
|
||||||
|
})();
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"fast-xml-parser": "^5.2.5",
|
"fast-xml-parser": "^5.2.5",
|
||||||
"isikukood": "3.1.7",
|
"isikukood": "3.1.7",
|
||||||
"jsonwebtoken": "9.0.2",
|
"jsonwebtoken": "9.0.2",
|
||||||
|
"libphonenumber-js": "^1.12.15",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
|
|||||||
@@ -79,15 +79,9 @@ export function PersonalAccountDropdown({
|
|||||||
}) {
|
}) {
|
||||||
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||||
|
|
||||||
const signedInAsLabel = useMemo(() => {
|
const { name, last_name } = personalAccountData ?? {};
|
||||||
const email = user?.email ?? undefined;
|
const firstNameLabel = toTitleCase(name) ?? '-';
|
||||||
const phone = user?.phone ?? undefined;
|
const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
|
||||||
|
|
||||||
return email ?? phone;
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const displayName =
|
|
||||||
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
|
|
||||||
|
|
||||||
const hasTotpFactor = useMemo(() => {
|
const hasTotpFactor = useMemo(() => {
|
||||||
const factors = user?.factors ?? [];
|
const factors = user?.factors ?? [];
|
||||||
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
|
|||||||
<ProfileAvatar
|
<ProfileAvatar
|
||||||
className={'rounded-md'}
|
className={'rounded-md'}
|
||||||
fallbackClassName={'rounded-md border'}
|
fallbackClassName={'rounded-md border'}
|
||||||
displayName={displayName ?? user?.email ?? ''}
|
displayName={firstNameLabel}
|
||||||
pictureUrl={personalAccountData?.picture_url}
|
pictureUrl={personalAccountData?.picture_url}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
|
|||||||
data-test={'account-dropdown-display-name'}
|
data-test={'account-dropdown-display-name'}
|
||||||
className={'truncate text-sm'}
|
className={'truncate text-sm'}
|
||||||
>
|
>
|
||||||
{toTitleCase(displayName)}
|
{firstNameLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className={'block truncate'}>{signedInAsLabel}</span>
|
<span className={'block truncate'}>{fullNameLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
|
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
|
||||||
|
import PersonalCode from '~/lib/utils';
|
||||||
|
|
||||||
export type AccountWithParams =
|
export type AccountWithParams =
|
||||||
Database['medreport']['Tables']['accounts']['Row'] & {
|
Database['medreport']['Tables']['accounts']['Row'] & {
|
||||||
@@ -48,6 +49,33 @@ class AccountsApi {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getPersonalAccountByUserId
|
||||||
|
* @description Get the personal account data for the given user ID.
|
||||||
|
* @param userId
|
||||||
|
*/
|
||||||
|
async getPersonalAccountByUserId(userId: string): Promise<AccountWithParams> {
|
||||||
|
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
|
* @name getAccountWorkspace
|
||||||
* @description Get the account workspace data.
|
* @description Get the account workspace data.
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
|
|||||||
* @see https://supabase.com/docs/guides/auth/social-login
|
* @see https://supabase.com/docs/guides/auth/social-login
|
||||||
*/
|
*/
|
||||||
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
||||||
azure: 'email',
|
// azure: 'email',
|
||||||
keycloak: 'openid',
|
// keycloak: 'openid',
|
||||||
// add your OAuth providers here
|
// add your OAuth providers here
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{
|
|||||||
queryParams.set('invite_token', props.inviteToken);
|
queryParams.set('invite_token', props.inviteToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectPath = [
|
// signicat/keycloak will not allow redirect-uri with changing query params
|
||||||
props.paths.callback,
|
const INCLUDE_QUERY_PARAMS = false as boolean;
|
||||||
queryParams.toString(),
|
|
||||||
].join('?');
|
const redirectPath = INCLUDE_QUERY_PARAMS
|
||||||
|
? [props.paths.callback, queryParams.toString()].join('?')
|
||||||
|
: props.paths.callback;
|
||||||
|
|
||||||
const redirectTo = [origin, redirectPath].join('');
|
const redirectTo = [origin, redirectPath].join('');
|
||||||
const scopes = OAUTH_SCOPES[provider] ?? undefined;
|
const scopes = OAUTH_SCOPES[provider] ?? undefined;
|
||||||
@@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{
|
|||||||
redirectTo,
|
redirectTo,
|
||||||
queryParams: props.queryParams,
|
queryParams: props.queryParams,
|
||||||
scopes,
|
scopes,
|
||||||
|
// skipBrowserRedirect: false,
|
||||||
},
|
},
|
||||||
} satisfies SignInWithOAuthCredentials;
|
} satisfies SignInWithOAuthCredentials;
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export function SignInMethodsContainer(props: {
|
|||||||
callback: props.paths.callback,
|
callback: props.paths.callback,
|
||||||
returnPath: props.paths.returnPath,
|
returnPath: props.paths.returnPath,
|
||||||
}}
|
}}
|
||||||
|
queryParams={{
|
||||||
|
prompt: 'login',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</If>
|
</If>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function SignUpMethodsContainer(props: {
|
|||||||
emailRedirectTo={props.paths.callback}
|
emailRedirectTo={props.paths.callback}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
displayTermsCheckbox={props.displayTermsCheckbox}
|
displayTermsCheckbox={props.displayTermsCheckbox}
|
||||||
onSignUp={() => redirect(redirectUrl)}
|
//onSignUp={() => redirect(redirectUrl)}
|
||||||
/>
|
/>
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
@@ -79,6 +79,9 @@ export function SignUpMethodsContainer(props: {
|
|||||||
callback: props.paths.callback,
|
callback: props.paths.callback,
|
||||||
returnPath: props.paths.appHome,
|
returnPath: props.paths.appHome,
|
||||||
}}
|
}}
|
||||||
|
queryParams={{
|
||||||
|
prompt: 'login',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</If>
|
</If>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class AuthApi {
|
|||||||
p_name: data.firstName,
|
p_name: data.firstName,
|
||||||
p_last_name: data.lastName,
|
p_last_name: data.lastName,
|
||||||
p_personal_code: data.personalCode,
|
p_personal_code: data.personalCode,
|
||||||
|
p_email: data.email || '',
|
||||||
p_phone: data.phone || '',
|
p_phone: data.phone || '',
|
||||||
p_city: data.city || '',
|
p_city: data.city || '',
|
||||||
p_has_consent_personal_data: data.userConsent,
|
p_has_consent_personal_data: data.userConsent,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
|
|||||||
import medusaError from "@lib/util/medusa-error"
|
import medusaError from "@lib/util/medusa-error"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import { revalidateTag } from "next/cache"
|
import { revalidateTag } from "next/cache"
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
import {
|
import {
|
||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
getCacheOptions,
|
getCacheOptions,
|
||||||
@@ -127,7 +126,7 @@ export async function login(_currentState: unknown, formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signout(countryCode?: string, shouldRedirect = true) {
|
export async function medusaLogout(countryCode = 'ee') {
|
||||||
await sdk.auth.logout()
|
await sdk.auth.logout()
|
||||||
|
|
||||||
await removeAuthToken()
|
await removeAuthToken()
|
||||||
@@ -139,10 +138,6 @@ export async function signout(countryCode?: string, shouldRedirect = true) {
|
|||||||
|
|
||||||
const cartCacheTag = await getCacheTag("carts")
|
const cartCacheTag = await getCacheTag("carts")
|
||||||
revalidateTag(cartCacheTag)
|
revalidateTag(cartCacheTag)
|
||||||
|
|
||||||
if (shouldRedirect) {
|
|
||||||
redirect(`/${countryCode!}/account`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function transferCart() {
|
export async function transferCart() {
|
||||||
@@ -262,72 +257,110 @@ export const updateCustomerAddress = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function medusaLoginOrRegister(credentials: {
|
async function medusaLogin(email: string, password: string) {
|
||||||
email: string
|
const token = await sdk.auth.login("customer", "emailpass", { email, password });
|
||||||
password?: string
|
await setAuthToken(token as string);
|
||||||
}) {
|
|
||||||
const { email, password } = credentials;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await sdk.auth.login("customer", "emailpass", {
|
await transferCart();
|
||||||
email,
|
} catch (e) {
|
||||||
password,
|
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 {
|
export async function medusaLoginOrRegister(credentials: {
|
||||||
await transferCart();
|
email: string
|
||||||
} catch (e) {
|
supabaseUserId?: string
|
||||||
console.error("Failed to transfer cart", e);
|
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");
|
return generateDeterministicPassword(email, supabaseUserId);
|
||||||
revalidateTag(customerCacheTag);
|
})();
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const registerToken = await sdk.auth.register("customer", "emailpass", {
|
await medusaRegister({ email, password, name, lastName });
|
||||||
email: email,
|
return await medusaLogin(email, password);
|
||||||
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;
|
|
||||||
} catch (registerError) {
|
} catch (registerError) {
|
||||||
|
console.error("Failed to create Medusa account for user with email=${email}", registerError);
|
||||||
throw medusaError(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<string> {
|
||||||
|
// 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!`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ export const listProducts = async ({
|
|||||||
regionId,
|
regionId,
|
||||||
}: {
|
}: {
|
||||||
pageParam?: number
|
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
|
countryCode?: string
|
||||||
regionId?: string
|
regionId?: string
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin"
|
|||||||
import Package from "@modules/common/icons/package"
|
import Package from "@modules/common/icons/package"
|
||||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
import { signout } from "@lib/data/customer"
|
import { medusaLogout } from "@lib/data/customer"
|
||||||
|
|
||||||
const AccountNav = ({
|
const AccountNav = ({
|
||||||
customer,
|
customer,
|
||||||
@@ -21,7 +21,7 @@ const AccountNav = ({
|
|||||||
const { countryCode } = useParams() as { countryCode: string }
|
const { countryCode } = useParams() as { countryCode: string }
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signout(countryCode)
|
await medusaLogout(countryCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({
|
|||||||
providers: {
|
providers: {
|
||||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||||
oAuth: ['google'],
|
oAuth: ['keycloak'],
|
||||||
},
|
},
|
||||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AuthError,
|
AuthError,
|
||||||
type EmailOtpType,
|
type EmailOtpType,
|
||||||
SupabaseClient,
|
SupabaseClient,
|
||||||
|
User,
|
||||||
} from '@supabase/supabase-js';
|
} from '@supabase/supabase-js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
|
|||||||
* @description Service for handling auth callbacks in Supabase
|
* @description Service for handling auth callbacks in Supabase
|
||||||
*/
|
*/
|
||||||
class AuthCallbackService {
|
class AuthCallbackService {
|
||||||
constructor(private readonly client: SupabaseClient) {}
|
constructor(private readonly client: SupabaseClient) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name verifyTokenHash
|
* @name verifyTokenHash
|
||||||
@@ -128,89 +129,117 @@ class AuthCallbackService {
|
|||||||
/**
|
/**
|
||||||
* @name exchangeCodeForSession
|
* @name exchangeCodeForSession
|
||||||
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
|
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
|
||||||
* @param request
|
* @param authCode
|
||||||
* @param params
|
|
||||||
*/
|
*/
|
||||||
async exchangeCodeForSession(
|
async exchangeCodeForSession(authCode: string): Promise<{
|
||||||
request: Request,
|
isSuccess: boolean;
|
||||||
params: {
|
user: User;
|
||||||
joinTeamPath: string;
|
} | ErrorURLParameters> {
|
||||||
redirectPath: string;
|
let user: User;
|
||||||
errorPath?: string;
|
try {
|
||||||
},
|
const { data, error } =
|
||||||
): Promise<{
|
await this.client.auth.exchangeCodeForSession(authCode);
|
||||||
nextPath: string;
|
|
||||||
}> {
|
|
||||||
const requestUrl = new URL(request.url);
|
|
||||||
const searchParams = requestUrl.searchParams;
|
|
||||||
|
|
||||||
const authCode = searchParams.get('code');
|
// if we have an error, we redirect to the error page
|
||||||
const error = searchParams.get('error');
|
if (error) {
|
||||||
const nextUrlPathFromParams = searchParams.get('next');
|
return getErrorURLParameters({
|
||||||
const inviteToken = searchParams.get('invite_token');
|
code: error.code,
|
||||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
error: error.message,
|
||||||
|
|
||||||
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 (error) {
|
// Handle Keycloak users - set up Medusa integration
|
||||||
return onError({
|
if (data?.user && this.isKeycloakUser(data.user)) {
|
||||||
error,
|
await this.setupMedusaUserForKeycloak(data.user);
|
||||||
path: errorPath,
|
}
|
||||||
|
|
||||||
|
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 {
|
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<void> {
|
||||||
|
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) {
|
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
|
||||||
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||||
url.host = host as string;
|
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,
|
error,
|
||||||
path,
|
|
||||||
code,
|
code,
|
||||||
}: {
|
}: {
|
||||||
error: string;
|
error: string;
|
||||||
path: string;
|
|
||||||
code?: string;
|
code?: string;
|
||||||
}) {
|
}): ErrorURLParameters {
|
||||||
const errorMessage = getAuthErrorMessage({ error, code });
|
const errorMessage = getAuthErrorMessage({ error, code });
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
@@ -255,10 +288,10 @@ function onError({
|
|||||||
code: code ?? '',
|
code: code ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextPath = `${path}?${searchParams.toString()}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nextPath,
|
error: errorMessage,
|
||||||
|
code: code ?? '',
|
||||||
|
searchParams: searchParams.toString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
|||||||
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
||||||
const keys = getSupabaseClientKeys();
|
const keys = getSupabaseClientKeys();
|
||||||
|
|
||||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
|
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||||
|
auth: {
|
||||||
|
flowType: 'pkce',
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export function createMiddlewareClient<GenericSchema = Database>(
|
|||||||
const keys = getSupabaseClientKeys();
|
const keys = getSupabaseClientKeys();
|
||||||
|
|
||||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||||
|
auth: {
|
||||||
|
flowType: 'pkce',
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
},
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
return request.cookies.getAll();
|
return request.cookies.getAll();
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
|
|||||||
const keys = getSupabaseClientKeys();
|
const keys = getSupabaseClientKeys();
|
||||||
|
|
||||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||||
|
auth: {
|
||||||
|
flowType: 'pkce',
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
},
|
||||||
cookies: {
|
cookies: {
|
||||||
async getAll() {
|
async getAll() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|||||||
@@ -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: {
|
medreport_product_groups: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -2053,6 +2073,7 @@ export type Database = {
|
|||||||
p_personal_code: string
|
p_personal_code: string
|
||||||
p_phone: string
|
p_phone: string
|
||||||
p_uid: string
|
p_uid: string
|
||||||
|
p_email: string
|
||||||
}
|
}
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() {
|
|||||||
const medusaAccountId = await medusaLoginOrRegister({
|
const medusaAccountId = await medusaLoginOrRegister({
|
||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
|
isDevPasswordLogin: true,
|
||||||
});
|
});
|
||||||
await client
|
await client
|
||||||
.schema('medreport').from('accounts')
|
.schema('medreport').from('accounts')
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ export function useSignInWithProvider() {
|
|||||||
const mutationKey = ['auth', 'sign-in-with-provider'];
|
const mutationKey = ['auth', 'sign-in-with-provider'];
|
||||||
|
|
||||||
const mutationFn = async (credentials: SignInWithOAuthCredentials) => {
|
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) {
|
if (response.error) {
|
||||||
throw response.error.message;
|
throw response.error.message;
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useSupabase } from './use-supabase';
|
import { useSupabase } from './use-supabase';
|
||||||
import { signout } from '../../../features/medusa-storefront/src/lib/data/customer';
|
|
||||||
|
|
||||||
export function useSignOut() {
|
export function useSignOut() {
|
||||||
const client = useSupabase();
|
const client = useSupabase();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await signout(undefined, false);
|
try {
|
||||||
return client.auth.signOut();
|
try {
|
||||||
|
const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer');
|
||||||
|
await medusaLogout();
|
||||||
|
} catch (medusaError) {
|
||||||
|
console.warn('Medusa logout failed or not available:', medusaError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await client.auth.signOut();
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() {
|
|||||||
const medusaAccountId = await medusaLoginOrRegister({
|
const medusaAccountId = await medusaLoginOrRegister({
|
||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
|
isDevPasswordLogin: true,
|
||||||
});
|
});
|
||||||
await client
|
await client
|
||||||
.schema('medreport').from('accounts')
|
.schema('medreport').from('accounts')
|
||||||
|
|||||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@@ -128,6 +128,9 @@ importers:
|
|||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: 9.0.2
|
specifier: 9.0.2
|
||||||
version: 9.0.2
|
version: 9.0.2
|
||||||
|
libphonenumber-js:
|
||||||
|
specifier: ^1.12.15
|
||||||
|
version: 1.12.15
|
||||||
lodash:
|
lodash:
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -475,10 +478,10 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@keystatic/core':
|
'@keystatic/core':
|
||||||
specifier: 0.5.47
|
specifier: 0.5.47
|
||||||
version: 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@keystatic/next':
|
'@keystatic/next':
|
||||||
specifier: ^5.0.4
|
specifier: ^5.0.4
|
||||||
version: 5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@markdoc/markdoc':
|
'@markdoc/markdoc':
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.4(@types/react@19.1.4)(react@19.1.0)
|
version: 0.5.4(@types/react@19.1.4)(react@19.1.0)
|
||||||
@@ -1269,7 +1272,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: ^9.19.0
|
specifier: ^9.19.0
|
||||||
version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
|
version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
|
||||||
import-in-the-middle:
|
import-in-the-middle:
|
||||||
specifier: 1.13.2
|
specifier: 1.13.2
|
||||||
version: 1.13.2
|
version: 1.13.2
|
||||||
@@ -8174,6 +8177,9 @@ packages:
|
|||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
libphonenumber-js@1.12.15:
|
||||||
|
resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -11455,7 +11461,7 @@ snapshots:
|
|||||||
|
|
||||||
'@juggle/resize-observer@3.4.0': {}
|
'@juggle/resize-observer@3.4.0': {}
|
||||||
|
|
||||||
'@keystar/ui@0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@keystar/ui@0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.27.6
|
'@babel/runtime': 7.27.6
|
||||||
'@emotion/css': 11.13.5
|
'@emotion/css': 11.13.5
|
||||||
@@ -11548,18 +11554,18 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-dom: 19.1.0(react@19.1.0)
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.27.6
|
'@babel/runtime': 7.27.6
|
||||||
'@braintree/sanitize-url': 6.0.4
|
'@braintree/sanitize-url': 6.0.4
|
||||||
'@emotion/weak-memoize': 0.3.1
|
'@emotion/weak-memoize': 0.3.1
|
||||||
'@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
'@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@internationalized/string': 3.2.7
|
'@internationalized/string': 3.2.7
|
||||||
'@keystar/ui': 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
'@keystar/ui': 0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0)
|
'@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0)
|
||||||
'@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
'@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
'@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -11630,13 +11636,13 @@ snapshots:
|
|||||||
- next
|
- next
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
'@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.27.6
|
'@babel/runtime': 7.27.6
|
||||||
'@keystatic/core': 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
'@keystatic/core': 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@types/react': 19.1.4
|
'@types/react': 19.1.4
|
||||||
chokidar: 3.6.0
|
chokidar: 3.6.0
|
||||||
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-dom: 19.1.0(react@19.1.0)
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
server-only: 0.0.1
|
server-only: 0.0.1
|
||||||
@@ -17230,7 +17236,7 @@ snapshots:
|
|||||||
|
|
||||||
'@sentry/core@9.46.0': {}
|
'@sentry/core@9.46.0': {}
|
||||||
|
|
||||||
'@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
|
'@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/semantic-conventions': 1.34.0
|
'@opentelemetry/semantic-conventions': 1.34.0
|
||||||
@@ -17243,7 +17249,7 @@ snapshots:
|
|||||||
'@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
|
'@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
|
||||||
'@sentry/webpack-plugin': 3.5.0(webpack@5.101.3)
|
'@sentry/webpack-plugin': 3.5.0(webpack@5.101.3)
|
||||||
chalk: 3.0.0
|
chalk: 3.0.0
|
||||||
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
resolve: 1.22.8
|
resolve: 1.22.8
|
||||||
rollup: 4.35.0
|
rollup: 4.35.0
|
||||||
stacktrace-parser: 0.1.11
|
stacktrace-parser: 0.1.11
|
||||||
@@ -20630,6 +20636,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isomorphic.js: 0.2.5
|
isomorphic.js: 0.2.5
|
||||||
|
|
||||||
|
libphonenumber-js@1.12.15: {}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -21265,31 +21273,6 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
|
||||||
dependencies:
|
|
||||||
'@next/env': 15.5.2
|
|
||||||
'@swc/helpers': 0.5.15
|
|
||||||
caniuse-lite: 1.0.30001723
|
|
||||||
postcss: 8.4.31
|
|
||||||
react: 19.1.0
|
|
||||||
react-dom: 19.1.0(react@19.1.0)
|
|
||||||
styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@19.1.0)
|
|
||||||
optionalDependencies:
|
|
||||||
'@next/swc-darwin-arm64': 15.5.2
|
|
||||||
'@next/swc-darwin-x64': 15.5.2
|
|
||||||
'@next/swc-linux-arm64-gnu': 15.5.2
|
|
||||||
'@next/swc-linux-arm64-musl': 15.5.2
|
|
||||||
'@next/swc-linux-x64-gnu': 15.5.2
|
|
||||||
'@next/swc-linux-x64-musl': 15.5.2
|
|
||||||
'@next/swc-win32-arm64-msvc': 15.5.2
|
|
||||||
'@next/swc-win32-x64-msvc': 15.5.2
|
|
||||||
'@opentelemetry/api': 1.9.0
|
|
||||||
babel-plugin-react-compiler: 19.1.0-rc.2
|
|
||||||
sharp: 0.34.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
- babel-plugin-macros
|
|
||||||
|
|
||||||
no-case@3.0.4:
|
no-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
lower-case: 2.0.2
|
lower-case: 2.0.2
|
||||||
|
|||||||
@@ -128,6 +128,9 @@
|
|||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"selectDate": "Select date"
|
"selectDate": "Select date"
|
||||||
},
|
},
|
||||||
|
"formFieldError": {
|
||||||
|
"invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)"
|
||||||
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"balance": "Your MedReport account balance",
|
"balance": "Your MedReport account balance",
|
||||||
"expiredAt": "Valid until {{expiredAt}}"
|
"expiredAt": "Valid until {{expiredAt}}"
|
||||||
|
|||||||
@@ -128,6 +128,9 @@
|
|||||||
"amount": "Summa",
|
"amount": "Summa",
|
||||||
"selectDate": "Vali kuupäev"
|
"selectDate": "Vali kuupäev"
|
||||||
},
|
},
|
||||||
|
"formFieldError": {
|
||||||
|
"invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)"
|
||||||
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"balance": "Sinu MedReporti konto saldo",
|
"balance": "Sinu MedReporti konto saldo",
|
||||||
"expiredAt": "Kehtiv kuni {{expiredAt}}"
|
"expiredAt": "Kehtiv kuni {{expiredAt}}"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -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;
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
export const getAnalysisElementMedusaProductIds = (products: ({
|
import { StoreProduct } from "@medusajs/types";
|
||||||
|
|
||||||
|
type Product = {
|
||||||
metadata?: {
|
metadata?: {
|
||||||
analysisElementMedusaProductIds?: string;
|
analysisElementMedusaProductIds?: string;
|
||||||
} | null;
|
} | null;
|
||||||
} | null)[]) => {
|
variant?: {
|
||||||
|
metadata?: {
|
||||||
|
analysisElementMedusaProductIds?: string;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export const getAnalysisElementMedusaProductIds = (products: Pick<StoreProduct, 'metadata'>[]) => {
|
||||||
if (!products) {
|
if (!products) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = products
|
const mapped = products
|
||||||
.flatMap((product) => {
|
.flatMap((product) => {
|
||||||
const value = product?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
|
const value = (product as Product)?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
|
||||||
|
const value_variant = (product as Product)?.variant?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
|
||||||
try {
|
try {
|
||||||
return JSON.parse(value as string);
|
return [...JSON.parse(value as string), ...JSON.parse(value_variant as string)];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e);
|
console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user