10 Commits

Author SHA1 Message Date
a44f9c9207 test 2025-09-08 00:45:43 +03:00
0cf04b4f55 wip 2025-09-05 01:39:06 +03:00
84216c3ced test 2025-09-04 13:40:21 +03:00
0037241558 allow transferCart to fail on register 2025-09-04 13:36:46 +03:00
c7f89723e3 allow transferCart to fail on login/register 2025-09-04 13:19:09 +03:00
4a06059a25 medusa product can have either analysiselement or analysis originalId 2025-09-04 13:18:42 +03:00
a0abb44257 hide dashboard recommendations block 2025-09-04 12:57:18 +03:00
283b502963 fix tooltip should wrap long text 2025-09-04 12:41:18 +03:00
5c8f8b73d7 try to display price before adding to cart 2025-09-04 12:39:24 +03:00
5b52da0a62 fix adding to cart loading 2025-09-04 12:39:21 +03:00
80 changed files with 53387 additions and 581 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
.git
Dockerfile

3
.env
View File

@@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
# Configure Medusa password secret for Keycloak users
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==

View File

@@ -25,14 +25,48 @@ EMAIL_PORT=1025 # or 465 for SSL
EMAIL_TLS=false
NODE_TLS_REJECT_UNAUTHORIZED=0
# MEDIPOST
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
MEDIPOST_USER=trvurgtst
MEDIPOST_PASSWORD=SRB48HZMV
MEDIPOST_RECIPIENT=trvurgtst
MEDIPOST_MESSAGE_SENDER=trvurgtst
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
#MEDIPOST_USER=medreport
#MEDIPOST_PASSWORD=
#MEDIPOST_RECIPIENT=HTI
#MEDIPOST_MESSAGE_SENDER=medreport
# MEDUSA
MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
MEDUSA_SECRET_API_KEY=
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_827a2ab863021cb67993f1d81078f81bfce4b4e0da642d8c0f5398ded9d8fd32
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
#NEXT_PUBLIC_MONTONIO_ACCESS_KEY=13e3686a-e7ad-41f6-998b-3f7d7de17654
#MONTONIO_SECRET_KEY=
#MONTONIO_API_URL=https://stargate.montonio.com
# JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1Njk4NDYyOCwiZXhwIjoyMDcyNTYwNjI4fQ.1UZR7AqSD9bOy1gtZRGhOCNoESsw2W-DoFDDsNNMwoE
#NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
#NEXT_PUBLIC_SUPABASE_ANON_KEY=
#SUPABASE_SERVICE_ROLE_KEY=

View File

@@ -13,10 +13,7 @@ import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { featureFlagsConfig } from '@kit/shared/config';
import { pathsConfig } from '@kit/shared/config';
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
const ModeToggle = dynamic(() =>
import('@kit/ui/mode-toggle').then((mod) => ({
@@ -75,11 +72,13 @@ function AuthButtons() {
</Link>
</Button>
<Button asChild className="text-xs md:text-sm" variant={'default'}>
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth:signUp'} />
</Link>
</Button>
{authConfig.providers.password && (
<Button asChild className="text-xs md:text-sm" variant={'default'}>
<Link href={pathsConfig.auth.signUp}>
<Trans i18nKey={'auth:signUp'} />
</Link>
</Button>
)}
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import Link from 'next/link';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { pathsConfig } from '@kit/shared/config';
import { ArrowRightIcon } from 'lucide-react';
import { CtaButton, Hero } from '@kit/ui/marketing';
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
return (
<div className={'flex space-x-4'}>
<CtaButton>
<Link href={'/auth/sign-up'}>
<Link href={pathsConfig.auth.signUp}>
<span className={'flex items-center space-x-0.5'}>
<span>
<Trans i18nKey={'common:getStarted'} />

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { sendEmail } from "~/lib/services/mailer.service";
export const GET = async () => {
const { renderInviteEmail } = await import('@kit/email-templates');
const html = await renderInviteEmail({
language: 'en',
teamName: 'Test Team',
invitedUserEmail: 'test@example.com',
productName: 'Test Product',
teamLogo: 'https://placehold.co/100x100',
inviter: 'John Doe',
link: 'https://www.google.com',
});
return NextResponse.json({
html,
length: html.html.length,
});
};

View File

@@ -2,10 +2,10 @@ import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs';
import { createAnalysisGroup, getAnalysisGroups } from '~/lib/services/analysis-group.service';
import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost.types';
import { IMedipostPublicMessageDataParsed, IUuringElement } from '~/lib/services/medipost.types';
import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service';
import { getLastCheckedDate } from '~/lib/services/sync-entries.service';
import { createAnalysisElement } from '~/lib/services/analysis-element.service';
import { createAnalysisElement, getAnalysisElements } from '~/lib/services/analysis-element.service';
import { createCodes } from '~/lib/services/codes.service';
import { getLatestPublicMessageListItem } from '~/lib/services/medipost.service';
import type { ICode } from '~/lib/types/code';
@@ -80,81 +80,92 @@ export default async function syncAnalysisGroups() {
}
const codes: ICode[] = [];
const analysesToCreate: { analysisGroupId: number, analyses: IUuringElement[], analysisElementId: number }[] = [];
for (const analysisGroup of analysisGroups) {
let analysisGroupId: number | undefined;
const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId);
if (existingAnalysisGroup) {
console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`);
continue;
analysisGroupId = existingAnalysisGroup.id;
} else {
// SAVE ANALYSIS GROUP
analysisGroupId = await createAnalysisGroup({
id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
});
const analysisGroupCodes = toArray(analysisGroup.Kood);
codes.push(
...analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId!,
analysis_element_id: null,
analysis_id: null,
})),
);
}
// SAVE ANALYSIS GROUP
const analysisGroupId = await createAnalysisGroup({
id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
});
const analysisGroupCodes = toArray(analysisGroup.Kood);
codes.push(
...analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId,
analysis_element_id: null,
analysis_id: null,
})),
);
const analysisGroupItems = toArray(analysisGroup.Uuring);
for (const item of analysisGroupItems) {
const analysisElement = item.UuringuElement;
const insertedAnalysisElementId = await createAnalysisElement({
analysisElement,
analysisGroupId,
materialGroups: toArray(item.MaterjalideGrupp),
});
let insertedAnalysisElementId: number | undefined;
const existingAnalysisElement = (await getAnalysisElements({ originalIds: [analysisElement.UuringId] }))?.[0];
if (existingAnalysisElement) {
console.info(`Analysis element '${analysisElement.UuringNimi}' already exists`);
insertedAnalysisElementId = existingAnalysisElement.id;
} else {
insertedAnalysisElementId = await createAnalysisElement({
analysisElement,
analysisGroupId: analysisGroupId!,
materialGroups: toArray(item.MaterjalideGrupp),
});
if (analysisElement.Kood) {
const analysisElementCodes = toArray(analysisElement.Kood);
codes.push(
...analysisElementCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: insertedAnalysisElementId!,
analysis_id: null,
})),
);
}
}
const analyses = toArray(analysisElement.UuringuElement);
if (analyses?.length && insertedAnalysisElementId) {
analysesToCreate.push({ analysisGroupId: analysisGroupId!, analyses, analysisElementId: insertedAnalysisElementId });
}
}
}
for (const { analysisGroupId, analyses, analysisElementId } of analysesToCreate) {
for (const analysis of analyses) {
const insertedAnalysisId = await createAnalysis(analysis, analysisElementId);
if (analysis.Kood) {
const analysisCodes = toArray(analysis.Kood);
if (analysisElement.Kood) {
const analysisElementCodes = toArray(analysisElement.Kood);
codes.push(
...analysisElementCodes.map((kood) => ({
...analysisCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: insertedAnalysisElementId,
analysis_id: null,
analysis_element_id: null,
analysis_id: insertedAnalysisId,
})),
);
}
const analyses = analysisElement.UuringuElement;
if (analyses?.length) {
for (const analysis of analyses) {
const insertedAnalysisId = await createAnalysis(analysis, analysisGroupId);
if (analysis.Kood) {
const analysisCodes = toArray(analysis.Kood);
codes.push(
...analysisCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: null,
analysis_element_id: null,
analysis_id: insertedAnalysisId,
})),
);
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
import { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
import { retrieveOrder } from "@lib/data/orders";
import { getMedipostDispatchTries } from "~/lib/services/audit.service";
@@ -25,7 +25,7 @@ export const POST = async (request: NextRequest) => {
try {
const medusaOrder = await retrieveOrder(medusaOrderId);
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
console.info("Successfully sent order to medipost");
return NextResponse.json({

View File

@@ -22,6 +22,7 @@ export const POST = async (request: NextRequest) => {
console.error("Error syncing analysis groups", e);
return NextResponse.json({
message: 'Failed to sync analysis groups',
error: e instanceof Error ? JSON.stringify(e, undefined, 2) : 'Unknown error',
}, { status: 500 });
}
};

View File

@@ -3,7 +3,7 @@ import { getAnalysisOrdersAdmin } from "~/lib/services/order.service";
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from "~/lib/services/account.service";
import { getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
import { getOrderedAnalysisIds } from "~/lib/services/medipost.service";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
const medusaOrder = await retrieveOrder(medusaOrderId)
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
const idsToSend = orderedAnalysisElementsIds;
@@ -35,8 +35,8 @@ export async function POST(request: NextRequest) {
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
});

View File

@@ -3,7 +3,7 @@ import { getOrder } from "~/lib/services/order.service";
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from "~/lib/services/account.service";
import { createMedipostActionLog, getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
import { createMedipostActionLog, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
export async function POST(request: Request) {
// const isDev = process.env.NODE_ENV === 'development';
@@ -11,16 +11,15 @@ export async function POST(request: Request) {
// return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
// }
const { medusaOrderId, maxItems = null } = await request.json();
const { medusaOrderId } = await request.json();
const medusaOrder = await retrieveOrder(medusaOrderId)
const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} (${maxItems ?? 'all'}) ordered analysis elements`);
const idsToSend = typeof maxItems === 'number' ? orderedAnalysisElementsIds.slice(0, maxItems) : orderedAnalysisElementsIds;
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
const messageXml = await composeOrderTestResponseXML({
person: {
idCode: account.personal_code!,
@@ -28,8 +27,8 @@ export async function POST(request: Request) {
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
});

View File

@@ -1,19 +1,62 @@
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth';
import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
const ERROR_PATH = '/auth/callback/error';
const redirectOnError = (searchParams?: string) => {
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const error = searchParams.get('error');
if (error) {
const { searchParams } = getErrorURLParameters({ error });
return redirectOnError(searchParams);
}
const authCode = searchParams.get('code');
if (!authCode) {
return redirectOnError();
}
let redirectPath = searchParams.get('next') || pathsConfig.app.home;
// if we have an invite token, we redirect to the join team page
// instead of the default next url. This is because the user is trying
// to join a team and we want to make sure they are redirected to the
// correct page.
const inviteToken = searchParams.get('invite_token');
if (inviteToken) {
const urlParams = new URLSearchParams({
invite_token: inviteToken,
email: searchParams.get('email') ?? '',
});
redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`;
}
const service = createAuthCallbackService(getSupabaseServerClient());
const oauthResult = await service.exchangeCodeForSession(authCode);
if (!("isSuccess" in oauthResult)) {
return redirectOnError(oauthResult.searchParams);
}
const { nextPath } = await service.exchangeCodeForSession(request, {
joinTeamPath: pathsConfig.app.joinTeam,
redirectPath: pathsConfig.app.home,
});
const api = createAccountsApi(getSupabaseServerClient());
return redirect(nextPath);
const account = await api.getPersonalAccountByUserId(
oauthResult.user.id,
);
if (!account.email || !account.name || !account.last_name) {
return redirect(pathsConfig.auth.updateAccount);
}
return redirect(redirectPath);
}

View File

@@ -5,7 +5,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());

View 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>
</>
);
}

View 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 />;
}

View File

@@ -1,14 +1,9 @@
import Link from 'next/link';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { pathsConfig, authConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
import PasswordOption from './components/PasswordOption';
interface SignInPageProps {
searchParams: Promise<{
@@ -26,47 +21,14 @@ export const generateMetadata = async () => {
};
async function SignInPage({ searchParams }: SignInPageProps) {
const { invite_token: inviteToken, next = pathsConfig.app.home } =
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
await searchParams;
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
if (authConfig.providers.password) {
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
}
const paths = {
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>
</>
);
return <SignInPageClientRedirect />;
}
export default withI18n(SignInPage);

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { authConfig, pathsConfig } from '@kit/shared/config';
@@ -37,6 +38,10 @@ async function SignUpPage({ searchParams }: Props) {
pathsConfig.auth.signIn +
(inviteToken ? `?invite_token=${inviteToken}` : '');
if (!authConfig.providers.password) {
return redirect('/');
}
return (
<>
<div className={'flex flex-col items-center gap-1'}>

View File

@@ -2,8 +2,6 @@
import Link from 'next/link';
import { User } from '@supabase/supabase-js';
import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@@ -23,31 +21,52 @@ import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account';
import { z } from 'zod';
export function UpdateAccountForm({ user }: { user: User }) {
type UpdateAccountFormValues = z.infer<typeof UpdateAccountSchema>;
export function UpdateAccountForm({
defaultValues,
}: {
defaultValues: UpdateAccountFormValues,
}) {
const form = useForm({
resolver: zodResolver(UpdateAccountSchema),
mode: 'onChange',
defaultValues: {
firstName: '',
lastName: '',
personalCode: '',
email: user.email,
phone: '',
city: '',
weight: 0,
height: 0,
userConsent: false,
},
defaultValues,
});
const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues;
const hasFirstName = !!firstName;
const hasLastName = !!lastName;
const hasPersonalCode = !!personalCode;
const hasEmail = !!email;
const hasWeight = !!weight;
const hasHeight = !!height;
const hasUserConsent = !!userConsent;
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) =>
onUpdateAccount({
...values,
...(hasFirstName && { firstName }),
...(hasLastName && { lastName }),
...(hasPersonalCode && { personalCode }),
...(hasEmail && { email }),
...(hasWeight && { weight: values.weight ?? weight }),
...(hasHeight && { height: values.height ?? height }),
...(hasUserConsent && { userConsent: values.userConsent ?? userConsent }),
});
return (
<Form {...form}>
<form
className="flex flex-col gap-6 px-6 pt-10 text-left"
onSubmit={form.handleSubmit(onUpdateAccount)}
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
>
<FormField
name="firstName"
disabled={hasFirstName}
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -63,6 +82,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="lastName"
disabled={hasLastName}
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -78,6 +98,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="personalCode"
disabled={hasPersonalCode}
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -93,13 +114,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="email"
disabled={hasEmail}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:email'} />
</FormLabel>
<FormControl>
<Input {...field} disabled />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -1,4 +1,5 @@
import { z } from 'zod';
import parsePhoneNumber from 'libphonenumber-js/min';
export const UpdateAccountSchema = z.object({
firstName: z
@@ -23,7 +24,20 @@ export const UpdateAccountSchema = z.object({
.string({
error: 'Phone number is required',
})
.nonempty(),
.nonempty()
.refine(
(phone) => {
try {
const phoneNumber = parsePhoneNumber(phone);
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPhoneNumber',
}
),
city: z.string().optional(),
weight: z
.number({

View File

@@ -28,11 +28,15 @@ export const onUpdateAccount = enhanceAction(
console.warn('On update account error: ', err);
}
await updateCustomer({
first_name: params.firstName,
last_name: params.lastName,
phone: params.phone,
});
try {
await updateCustomer({
first_name: params.firstName,
last_name: params.lastName,
phone: params.phone,
});
} catch (e) {
console.error("Failed to update Medusa customer", e);
}
const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation();

View File

@@ -11,18 +11,39 @@ import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { UpdateAccountForm } from './_components/update-account-form';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { toTitleCase } from '~/lib/utils';
async function UpdateAccount() {
const client = getSupabaseServerClient();
const account = await loadCurrentUserAccount();
const {
data: { user },
} = await client.auth.getUser();
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
if (!user) {
redirect(pathsConfig.auth.signIn);
}
const defaultValues = {
firstName: account?.name ? toTitleCase(account.name) : '',
lastName: account?.last_name ? toTitleCase(account.last_name) : '',
personalCode: account?.personal_code ?? '',
email: (() => {
if (isKeycloakUser) {
return account?.email ?? '';
}
return account?.email ?? user?.email ?? '';
})(),
phone: account?.phone ?? '',
city: account?.city ?? '',
weight: account?.accountParams?.weight ?? 0,
height: account?.accountParams?.height ?? 0,
userConsent: account?.has_consent_personal_data ?? false,
};
return (
<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">
@@ -34,7 +55,7 @@ async function UpdateAccount() {
<p className="text-muted-foreground pt-1 text-sm">
<Trans i18nKey={'account:updateAccount:description'} />
</p>
<UpdateAccountForm user={user} />
<UpdateAccountForm defaultValues={defaultValues} />
</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>

View File

@@ -8,7 +8,7 @@ import { listProductTypes } from "@lib/data/products";
import { placeOrder, retrieveCart } from "@lib/data/cart";
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
import { createOrder } from '~/lib/services/order.service';
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
import { createNotificationsApi } from '@kit/notifications/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { AccountWithParams } from '@kit/accounts/api';
@@ -114,7 +114,7 @@ export async function processMontonioCallback(orderToken: string) {
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder });
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
const { productTypes } = await listProductTypes();

View File

@@ -27,7 +27,7 @@ async function UserHomePage() {
const client = getSupabaseServerClient();
const account = await loadCurrentUserAccount();
const api = await createAccountsApi(client);
const api = createAccountsApi(client);
const bmiThresholds = await api.fetchBmiThresholds();
if (!account) {

View File

@@ -17,6 +17,7 @@ import { formatCurrency } from "@/packages/shared/src/utils";
import { useTranslation } from "react-i18next";
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
import AnalysisLocation from "./analysis-location";
import { composeOrderXML, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
const IS_DISCOUNT_SHOWN = false as boolean;
@@ -133,6 +134,26 @@ export default function Cart({
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>
<Button type='button' onClick={async () => {
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder: { items: cart.items ?? [] } });
const xml = await composeOrderXML({
person: {
idCode: '1234567890',
firstName: 'John',
lastName: 'Doe',
phone: '1234567890',
},
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: '1234567890',
orderCreatedAt: new Date(),
});
console.log('test', { items: cart.items, ids: orderedAnalysisElementsIds, xml });
console.log('test', xml);
}}>
Test
</Button>
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { BlendingModeIcon } from '@radix-ui/react-icons';
import {
Droplets,
} from 'lucide-react';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardHeader,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
const dummyRecommendations = [
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-cyan/10 text-cyan',
title: 'Kolesterooli kontroll',
description: 'HDL-kolestrool',
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-primary/10 text-primary',
title: 'Kolesterooli kontroll',
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
color: 'bg-destructive/10 text-destructive',
title: 'Vererõhu kontroll',
tooltipContent: 'Selgitus',
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
export default function DashboardRecommendations() {
return (
<Card>
<CardHeader className="items-start">
<h4>
<Trans i18nKey="dashboard:recommendedForYou" />
</h4>
</CardHeader>
<CardContent className="space-y-6">
{dummyRecommendations.map(
(
{
icon,
color,
title,
description,
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
return (
<div
className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
color,
)}
>
{icon}
</div>
<div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title}
<InfoTooltip content={tooltipContent} />
</div>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
</div>
<div className="grid w-36 min-w-fit auto-rows-fr grid-cols-2 items-center gap-4">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
</Link>
) : (
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
);
}

View File

@@ -9,20 +9,17 @@ import {
Activity,
ChevronRight,
Clock9,
Droplets,
Pill,
Scale,
TrendingUp,
User,
} from 'lucide-react';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { pathsConfig } from '@kit/shared/config';
import { getPersonParameters } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
@@ -38,6 +35,7 @@ import {
getBmiBackgroundColor,
getBmiStatus,
} from '~/lib/utils';
import DashboardRecommendations from './dashboard-recommendations';
const getCardVariant = (isSuccess: boolean | null): CardProps['variant'] => {
if (isSuccess === null) return 'default';
@@ -86,7 +84,7 @@ const cards = ({
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(),
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},
@@ -135,37 +133,7 @@ const cards = ({
},
];
const dummyRecommendations = [
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-cyan/10 text-cyan',
title: 'Kolesterooli kontroll',
description: 'HDL-kolestrool',
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-primary/10 text-primary',
title: 'Kolesterooli kontroll',
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
color: 'bg-destructive/10 text-destructive',
title: 'Vererõhu kontroll',
tooltipContent: 'Selgitus',
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
const IS_SHOWN_RECOMMENDATIONS = false as boolean;
export default function Dashboard({
account,
@@ -232,79 +200,7 @@ export default function Dashboard({
),
)}
</div>
<Card>
<CardHeader className="items-start">
<h4>
<Trans i18nKey="dashboard:recommendedForYou" />
</h4>
</CardHeader>
<CardContent className="space-y-6">
{dummyRecommendations.map(
(
{
icon,
color,
title,
description,
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
return (
<div
className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
color,
)}
>
{icon}
</div>
<div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title}
<InfoTooltip content={tooltipContent} />
</div>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
</div>
<div className="grid w-36 min-w-fit auto-rows-fr grid-cols-2 items-center gap-4">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
</Link>
) : (
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
{IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
</>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
@@ -15,12 +16,14 @@ import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Trans } from '@kit/ui/trans';
import { toast } from '@kit/ui/sonner';
import { formatCurrency } from '@/packages/shared/src/utils';
export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle'
> & {
isAvailable: boolean;
variant: { id: string };
price: number | null;
};
export default function OrderAnalysesCards({
@@ -30,23 +33,26 @@ export default function OrderAnalysesCards({
analyses: OrderAnalysisCard[];
countryCode: string;
}) {
const [isAddingToCart, setIsAddingToCart] = useState(false);
const { i18n: { language } } = useTranslation()
const [variantAddingToCart, setVariantAddingToCart] = useState<string | null>(null);
const handleSelect = async (variantId: string) => {
if (isAddingToCart) {
if (variantAddingToCart) {
return null;
}
setIsAddingToCart(true);
setVariantAddingToCart(variantId);
try {
await handleAddToCart({
selectedVariant: { id: variantId },
countryCode,
});
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
setIsAddingToCart(false);
setVariantAddingToCart(null);
} catch (e) {
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
setIsAddingToCart(false);
setVariantAddingToCart(null);
console.error(e);
}
}
@@ -59,7 +65,15 @@ export default function OrderAnalysesCards({
description,
subtitle,
isAvailable,
price,
}) => {
const formattedPrice = typeof price === 'number'
? formatCurrency({
currencyCode: 'eur',
locale: language,
value: price,
})
: null;
return (
<Card
key={title}
@@ -80,7 +94,7 @@ export default function OrderAnalysesCards({
className="px-2 text-black"
onClick={() => handleSelect(variant.id)}
>
{isAddingToCart ? <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>
</div>
)}
@@ -91,7 +105,14 @@ export default function OrderAnalysesCards({
{description && (
<>
{' '}
<InfoTooltip content={`${description}`} />
<InfoTooltip
content={
<div className='flex flex-col gap-2'>
<span>{formattedPrice}</span>
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>

View File

@@ -1,11 +1,10 @@
import { cache } from 'react';
import { getProductCategories } from '@lib/data/categories';
import { listProductTypes } from '@lib/data/products';
import { listProducts, listProductTypes } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
import { ServiceCategory } from '../../_components/service-categories';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -39,13 +38,20 @@ async function analysesLoader() {
const category = productCategories.find(
({ metadata }) => metadata?.page === 'order-analysis',
);
const categoryProducts = category
? await listProducts({
countryCode,
queryParams: { limit: 100, category_id: category.id },
})
: null;
const serviceCategories = productCategories.filter(
({ parent_category }) => parent_category?.handle === 'tto-categories',
);
return {
analyses:
category?.products?.map<OrderAnalysisCard>(
categoryProducts?.response.products.map<OrderAnalysisCard>(
({ title, description, subtitle, variants, status, metadata }) => {
const variant = variants![0]!;
return {
@@ -57,6 +63,7 @@ async function analysesLoader() {
},
isAvailable:
status === 'published' && !!metadata?.analysisIdOriginal,
price: variant.calculated_price?.calculated_amount ?? null,
};
},
) ?? [],

View File

@@ -16,14 +16,14 @@ export const loadUserAccount = cache(accountLoader);
export async function loadCurrentUserAccount() {
const user = await requireUserInServerComponent();
return user?.identities?.[0]?.id
? await loadUserAccount(user?.identities?.[0]?.id)
return user?.id
? await loadUserAccount(user.id)
: null;
}
async function accountLoader(accountId: string) {
async function accountLoader(userId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
return api.getAccount(accountId);
return api.getPersonalAccountByUserId(userId);
}

8362
current-test-data.sql Normal file

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,26 @@
import { redirect } from 'next/navigation';
import { createClient } from '@/utils/supabase/server';
import { medusaLogout } from '@lib/data/customer';
export const signOutAction = async () => {
const supabase = await createClient();
await supabase.auth.signOut();
const client = await createClient();
try {
try {
await medusaLogout();
} catch (medusaError) {
console.warn('Medusa logout failed or not available:', medusaError);
}
const { error } = await client.auth.signOut();
if (error) {
throw error;
}
} catch (error) {
console.error('Logout error:', error);
throw error;
}
return redirect('/');
};

View File

@@ -2,7 +2,7 @@ import type { Tables } from '@/packages/supabase/src/database.types';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { IUuringElement } from "./medipost.types";
type AnalysesWithGroupsAndElements = ({
export type AnalysesWithGroupsAndElements = ({
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
@@ -105,12 +105,18 @@ export const createMedusaSyncSuccessEntry = async () => {
});
}
export async function getAnalyses({ ids }: { ids: number[] }): Promise<AnalysesWithGroupsAndElements> {
const { data } = await getSupabaseServerAdminClient()
export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise<AnalysesWithGroupsAndElements> {
const query = getSupabaseServerAdminClient()
.schema('medreport')
.from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`)
.in('id', ids);
.select(`*, analysis_elements(*, analysis_groups(*))`);
if (Array.isArray(ids)) {
query.in('id', ids);
}
if (Array.isArray(originalIds)) {
query.in('analysis_id_original', originalIds);
}
const { data } = await query.throwOnError();
return data as unknown as AnalysesWithGroupsAndElements;
}

View File

@@ -37,10 +37,10 @@ import { Tables } from '@kit/supabase/database';
import { createAnalysisGroup } from './analysis-group.service';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { getOrder, updateOrderStatus } from './order.service';
import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { getAnalyses } from './analyses.service';
import { AnalysisElement, getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { AnalysesWithGroupsAndElements, getAnalyses } from './analyses.service';
import { getAccountAdmin } from './account.service';
import { StoreOrder } from '@medusajs/types';
import { StoreOrder, StoreOrderLineItem } from '@medusajs/types';
import { listProducts } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
@@ -480,72 +480,60 @@ export async function composeOrderXML({
throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
}
const uniques = [
...analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ?? [],
...analyses?.flatMap(({ analysis_elements }) => analysis_elements.analysis_groups) ?? []
];
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
uniqBy(
(
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
[]
).concat(
analyses?.flatMap(
({ analysis_elements }) => analysis_elements.analysis_groups,
) ?? [],
),
'id',
);
uniqBy(uniques, 'id');
console.log('analysisGroups', { analysisGroups, uniques });
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;
const relatedAnalysisElements = await getRelatedAnalysisElements({
analysisElements,
analyses,
currentGroup,
});
if (!relatedAnalysisElement) {
relatedAnalysisElement = relatedAnalyses?.find(
(relatedAnalysis) =>
relatedAnalysis.analysis_elements.analysis_groups.id ===
currentGroup.id,
)?.analysis_elements;
}
for (const relatedAnalysisElement of relatedAnalysisElements) {
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
throw new Error(
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
);
}
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);
}
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"?>
@@ -666,7 +654,7 @@ async function syncPrivateMessage({
unit: element.Mootyhik ?? null,
original_response_element: element,
analysis_name: element.UuringNimi || element.KNimetus,
comment: element.UuringuKommentaar
comment: element.UuringuKommentaar ?? '',
})),
);
}
@@ -695,7 +683,7 @@ async function syncPrivateMessage({
);
}
const { data: allOrderResponseElements} = await supabase
const { data: allOrderResponseElements } = await supabase
.schema('medreport')
.from('analysis_response_elements')
.select('*')
@@ -715,7 +703,7 @@ export async function sendOrderToMedipost({
orderedAnalysisElements,
}: {
medusaOrderId: string;
orderedAnalysisElements: { analysisElementId: number }[];
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
}) {
const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
@@ -727,8 +715,8 @@ export async function sendOrderToMedipost({
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
orderedAnalysesIds: [],
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
comment: '',
@@ -784,17 +772,21 @@ export async function sendOrderToMedipost({
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
}
export async function getOrderedAnalysisElementsIds({
type OrderItems = {
items: Pick<StoreOrderLineItem, 'product'>[];
}
export async function getOrderedAnalysisIds({
medusaOrder,
}: {
medusaOrder: StoreOrder;
medusaOrder: OrderItems;
}): Promise<{
analysisElementId: number;
analysisElementId?: number;
analysisId?: number;
}[]> {
const countryCodes = await listRegions();
const countryCode = countryCodes[0]!.countries![0]!.iso_2!;
async function getOrderedAnalysisElements(medusaOrder: StoreOrder) {
async function getOrderedAnalysisElements(medusaOrder: OrderItems) {
const originalIds = (medusaOrder?.items ?? [])
.map((a) => a.product?.metadata?.analysisIdOriginal)
.filter((a) => typeof a === 'string') as string[];
@@ -802,7 +794,15 @@ export async function getOrderedAnalysisElementsIds({
return analysisElements.map(({ id }) => ({ analysisElementId: id }));
}
async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) {
async function getOrderedAnalyses(medusaOrder: OrderItems) {
const originalIds = (medusaOrder?.items ?? [])
.map((a) => a.product?.metadata?.analysisIdOriginal)
.filter((a) => typeof a === 'string') as string[];
const analyses = await getAnalyses({ originalIds });
return analyses.map(({ id }) => ({ analysisId: id }));
}
async function getOrderedAnalysisPackages(medusaOrder: OrderItems) {
const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX));
const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[];
if (orderedPackageIds.length === 0) {
@@ -841,12 +841,13 @@ export async function getOrderedAnalysisElementsIds({
return analysisElements.map(({ id }) => ({ analysisElementId: id }));
}
const [analysisPackageElements, orderedAnalysisElements] = await Promise.all([
const [analysisPackageElements, orderedAnalysisElements, orderedAnalyses] = await Promise.all([
getOrderedAnalysisPackages(medusaOrder),
getOrderedAnalysisElements(medusaOrder),
getOrderedAnalyses(medusaOrder),
]);
return [...analysisPackageElements, ...orderedAnalysisElements];
return [...analysisPackageElements, ...orderedAnalysisElements, ...orderedAnalyses];
}
export async function createMedipostActionLog({
@@ -858,10 +859,10 @@ export async function createMedipostActionLog({
hasError = false,
}: {
action:
| 'send_order_to_medipost'
| 'sync_analysis_results_from_medipost'
| 'send_fake_analysis_results_to_medipost'
| 'send_analysis_results_to_medipost';
| 'send_order_to_medipost'
| 'sync_analysis_results_from_medipost'
| 'send_fake_analysis_results_to_medipost'
| 'send_analysis_results_to_medipost';
xml: string;
hasAnalysisResults?: boolean;
medusaOrderId?: string | null;
@@ -882,3 +883,39 @@ export async function createMedipostActionLog({
.select('id')
.throwOnError();
}
async function getRelatedAnalysisElements({
analysisElements,
analyses,
currentGroup,
}: {
analysisElements: AnalysisElement[];
analyses: AnalysesWithGroupsAndElements;
currentGroup: {
created_at: string;
id: number;
name: string;
order: number;
original_id: string;
updated_at: string | null;
};
}) {
const relatedAnalysisElements: AnalysisElement[] = [];
const related1 = analysisElements?.filter(
(element) => element.analysis_groups.id === currentGroup.id,
);
if (related1) {
relatedAnalysisElements.push(...related1);
}
const related2 = analyses
?.filter(({ analysis_elements }) => analysis_elements.analysis_groups.id === currentGroup.id)
?.filter(({ analysis_elements }) => analysis_elements.analysis_groups.id === currentGroup.id)
?.flatMap(({ analysis_elements }) => analysis_elements);
if (related2) {
relatedAnalysisElements.push(...related2);
}
return relatedAnalysisElements;
}

View File

@@ -110,7 +110,7 @@ export async function handleNavigateToPayment({
const paymentLink =
await new MontonioOrderHandlerService().getMontonioPaymentLink({
notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`,
returnUrl: `${env().siteUrl}/home/cart/montonio-callback`,
returnUrl: `${"https://webhook.site"}/home/cart/montonio-callback`,
amount: cart.total,
currency: cart.currency_code.toUpperCase(),
description: `Order from Medreport`,

View File

@@ -10,7 +10,7 @@ export async function createOrder({
orderedAnalysisElements,
}: {
medusaOrder: StoreOrder;
orderedAnalysisElements: { analysisElementId: number }[];
orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
}) {
const supabase = getSupabaseServerClient();
@@ -21,8 +21,8 @@ export async function createOrder({
const orderResult = await supabase.schema('medreport')
.from('analysis_orders')
.insert({
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId),
analysis_ids: [],
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
analysis_ids: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
status: 'QUEUED',
user_id: user.id,
medusa_order_id: medusaOrder.id,

View File

@@ -15,11 +15,12 @@ export function toArray<T>(input?: T | T[] | null): T[] {
}
export function toTitleCase(str?: string) {
if (!str) return '';
return str.replace(
/\w\S*/g,
(text: string) =>
text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
return (
str
?.toLowerCase()
.replace(/[^-'\s]+/g, (match) =>
match.replace(/^./, (first) => first.toUpperCase()),
) ?? ""
);
}
@@ -40,8 +41,12 @@ export function sortByDate<T>(
export const bmiFromMetric = (kg: number, cm: number) => {
const m = cm / 100;
const bmi = kg / (m * m);
return bmi ? Math.round(bmi) : NaN;
const m2 = m * m;
if (m2 === 0) {
return null;
}
const bmi = kg / m2;
return !Number.isNaN(bmi) ? Math.round(bmi) : null;
};
export function getBmiStatus(
@@ -58,7 +63,9 @@ export function getBmiStatus(
) || null;
const bmi = bmiFromMetric(params.weight, params.height);
if (!thresholdByAge || Number.isNaN(bmi)) return null;
if (!thresholdByAge || bmi === null) {
return null;
}
if (bmi > thresholdByAge.obesity_min) return BmiCategory.OBESE;
if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT;

View File

@@ -0,0 +1,5 @@
const list = ["prod_01K2JQF451ZKVV97FMX10T6DG1","prod_01K2JQFECMKR1CGDYQV81HB2W1","prod_01K2JQCZKZRZWD71CRN84V84NJ","prod_01K2JQD1AWQH7VHGPS4BA4028A","prod_01K2JQD321BMZTP7R4ZXEJNR17","prod_01K2JQD4RCRGERJRY8JQB7VWMT","prod_01K2JQD6F2VDANADSB5HY6WB6M","prod_01K2JQD85JGDRE0EJSQXGB74SE","prod_01K2JQD9VG391PZ02ZS57Y72PC","prod_01K2JQDBHYMESBB332PHF5TNTB", "prod_01K2JQG1EK4VTFH4GR2ZVB1RK6", "prod_01K2JQH0AMN407P1234MJ64BZM"]
const list2 = ['prod_01K2JQF451ZKVV97FMX10T6DG1', 'prod_01K2JQFECMKR1CGDYQV81HB2W1', 'prod_01K2JQCZKZRZWD71CRN84V84NJ', 'prod_01K2JQD1AWQH7VHGPS4BA4028A', 'prod_01K2JQD321BMZTP7R4ZXEJNR17', 'prod_01K2JQD4RCRGERJRY8JQB7VWMT', 'prod_01K2JQD6F2VDANADSB5HY6WB6M', 'prod_01K2JQD85JGDRE0EJSQXGB74SE', 'prod_01K2JQD9VG391PZ02ZS57Y72PC', 'prod_01K2JQDBHYMESBB332PHF5TNTB', 'prod_01K2JQG1EK4VTFH4GR2ZVB1RK6', 'prod_01K2JQH0AMN407P1234MJ64BZM']
console.log(list2.map(a => `'${a}'`).join(', '));

View File

@@ -0,0 +1,8 @@
function send_medipost_test_response() {
curl -X POST "$HOSTNAME/api/order/medipost-test-response" \
--header "x-jobs-api-key: $JOBS_API_TOKEN" \
--header 'Content-Type: application/json' \
--data '{ "medusaOrderId": "'$MEDUSA_ORDER_ID'" }'
}
#

View File

@@ -0,0 +1,16 @@
const SyncHelper = {
async send() {
await fetch('https://test.medreport.ee/api/order/medipost-test-response', {
method: "POST",
headers: { "x-jobs-api-key": "fd26ec26-70ed-11f0-9e95-431ac3b15a84", "content-type": "application/json" },
body: JSON.stringify({ "medusaOrderId": "order_01K2F3KC87NTMZX04T3KDZAQ69" }),
});
},
async sync() {
await fetch('https://test.medreport.ee/api/job/sync-analysis-results', {
method: "POST",
headers: { "x-jobs-api-key": "fd26ec26-70ed-11f0-9e95-431ac3b15a84" },
});
},
};
SyncHelper.sync()

View File

@@ -0,0 +1,157 @@
# Testing the Supabase Cron Job Setup
This guide provides step-by-step instructions to test your Supabase cron job configuration.
## Quick Setup Commands
### 1. Deploy the Migration (Option A)
If you want to use the migration approach:
```bash
# Make sure you're connected to your Supabase project
npm run supabase:deploy
```
Then manually update the migration file with your actual values before deploying.
### 2. Manual Setup (Option B - Recommended)
Use the SQL Editor in Supabase Dashboard:
1. Go to your Supabase Dashboard → Database → SQL Editor
2. Copy and paste the content from `supabase/sql/setup-cron-job.sql`
3. Run the SQL to create the function
4. Then execute the schedule function with your actual values:
```sql
select schedule_sync_analysis_results_cron(
'https://your-production-domain.com', -- Your actual API URL
'your-actual-jobs-api-token' -- Your actual JOBS_API_TOKEN
);
```
## Testing Steps
### 1. Verify Extensions are Enabled
```sql
select * from pg_extension where extname in ('pg_cron', 'pg_net');
```
Expected result: Both `pg_cron` and `pg_net` should be listed.
### 2. Check Job is Scheduled
```sql
select * from cron.job where jobname = 'sync-analysis-results-every-15-minutes';
```
Expected result: One row with your job details, `active` should be `true`.
### 3. Test API Endpoint Manually
Before relying on the cron job, test your API endpoint manually:
```bash
curl -X POST https://your-domain.com/api/job/sync-analysis-results \
-H "Content-Type: application/json" \
-H "x-jobs-api-key: YOUR_JOBS_API_TOKEN" \
-v
```
Expected result: Status 200 with success message.
### 4. Monitor Job Execution
Wait for the job to run (up to 15 minutes), then check execution history:
```sql
select
job_run_details.*,
job.jobname
from cron.job_run_details
join cron.job on job.jobid = job_run_details.jobid
where job.jobname = 'sync-analysis-results-every-15-minutes'
order by start_time desc
limit 5;
```
### 5. Check Application Logs
Monitor your application logs to see if the API calls are being received and processed successfully.
## Environment Variables Required
Make sure these environment variables are set in your production environment:
- `JOBS_API_TOKEN` - The API key for authenticating job requests
- All other environment variables required by your `sync-analysis-results` handler
## Common Issues and Solutions
### Issue 1: Job Not Appearing
**Problem**: Job doesn't appear in `cron.job` table.
**Solution**:
- Check if you have sufficient permissions
- Ensure extensions are enabled
- Try running the schedule function again
### Issue 2: Job Scheduled but Not Running
**Problem**: Job appears in table but no execution history.
**Solutions**:
- Check if `active` is `true` in `cron.job` table
- Verify cron schedule format is correct
- Check Supabase logs for any cron-related errors
### Issue 3: HTTP Requests Failing
**Problem**: Job runs but API calls fail.
**Solutions**:
- Test API endpoint manually with curl
- Verify API URL is correct and accessible from Supabase
- Check if `JOBS_API_TOKEN` is correct
- Ensure your application is deployed and running
### Issue 4: Authentication Errors
**Problem**: Getting 401 Unauthorized responses.
**Solutions**:
- Verify `x-jobs-api-key` header is included
- Check that `JOBS_API_TOKEN` matches between cron job and application
- Ensure the header name is exactly `x-jobs-api-key` (case-sensitive)
## Cleanup Commands
If you need to remove the cron job:
```sql
-- Unschedule the job
select cron.unschedule('sync-analysis-results-every-15-minutes');
-- Drop the helper function (optional)
drop function if exists schedule_sync_analysis_results_cron(text, text);
```
## Next Steps
Once the cron job is working:
1. Remove any old instrumentation.ts cron logic if it exists
2. Monitor the job performance and adjust interval if needed
3. Set up alerting for failed job executions
4. Consider adding more detailed logging to your API endpoint
## Support
If you encounter issues:
1. Check the troubleshooting section in `docs/supabase-cron-setup.md`
2. Review Supabase documentation for pg_cron and pg_net
3. Contact your team for deployment-specific configuration details

File diff suppressed because it is too large Load Diff

138
local-sync/xmls/curl2.sh Executable file
View File

@@ -0,0 +1,138 @@
curl --location 'https://meditest.medisoft.ee:7443/Medipost/MedipostServlet' \
--form 'Action="SendPrivateMessage";type=text/plain; charset=UTF-8' \
--form 'User="trvurgtst";type=text/plain; charset=UTF-8' \
--form 'Password="SRB48HZMV";type=text/plain; charset=UTF-8' \
--form 'Receiver="trvurgtst";type=text/plain; charset=UTF-8' \
--form 'MessageType="Tellimus";type=text/plain; charset=UTF-8' \
--form 'Message="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<Saadetis xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"TellimusLOINC.xsd\">
<Pais>
<Pakett versioon=\"20\">OL</Pakett>
<Saatja>trvurgtst</Saatja>
<Saaja>trvurgtst</Saaja>
<Aeg>2022-07-22 11:31:57</Aeg>
<SaadetisId>234254234</SaadetisId>
<Email>info@terviseuuringud.ee</Email>
</Pais>
<Tellimus cito=\"EI\">
<ValisTellimuseId>1288</ValisTellimuseId>
<\!--<TellijaAsutus>-->
<Asutus tyyp=\"TELLIJA\">
<AsutuseId>12702440</AsutuseId>
<AsutuseNimi>Health Tests OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
<AllyksuseNimi/>
<Telefon>+37256257117</Telefon>
<Vald>0387</Vald>
<Aadress>Valukoja 10, 11415 Tallinn</Aadress>
</Asutus>
<\!--<TeostajaAsutus>-->
<Asutus tyyp=\"TEOSTAJA\">
<AsutuseId>12702440</AsutuseId>
<AsutuseNimi>Health Tests OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
<AllyksuseNimi/>
<Telefon>+37256257117</Telefon>
<Vald>0387</Vald>
<Aadress>Valukoja 10, 11415 Tallinn</Aadress>
</Asutus>
<\!--<TellijaIsik>-->
<Personal tyyp=\"TELLIJA\">
<\!--Tervishoiutöötaja kood (OID: 1.3.6.1.4.1.28284.6.2.4.9)
või Eesti isikukood (OID: 1.3.6.1.4.1.28284.6.2.2.1) -->
<PersonalOID>1.3.6.1.4.1.28284.6.2.2.1</PersonalOID>
<PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>Tsvetkov</PersonalPerekonnaNimi>
<PersonalEesNimi>Eduard</PersonalEesNimi>
<Telefon>+3725555000</Telefon>
</Personal>
<\!--<SisestajaIsik>-->
<Personal tyyp=\"SISESTAJA\">
<\!--Tervishoiutöötaja kood (OID: 1.3.6.1.4.1.28284.6.2.4.9)
või Eesti isikukood (OID: 1.3.6.1.4.1.28284.6.2.2.1) -->
<PersonalOID>1.3.6.1.4.1.28284.6.2.2.1</PersonalOID>
<PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>Tsvetkov</PersonalPerekonnaNimi>
<PersonalEesNimi>Eduard</PersonalEesNimi>
</Personal>
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
<Patsient>
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
<Isikukood>37907262736</Isikukood>
<PerekonnaNimi>KIVIRÜÜT</PerekonnaNimi>
<EesNimi>ARGO</EesNimi>
<SynniAeg>1979-07-26</SynniAeg>
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
<Sugu>M</Sugu>
</Patsient>
<Konfidentsiaalsus>
<PatsiendileOID>2.16.840.1.113883.5.25</PatsiendileOID>
<Patsiendile>N</Patsiendile>
<ArstileOID>1.3.6.1.4.1.28284.6.2.2.39.1</ArstileOID>
<Arstile>N</Arstile>
<EsindajaleOID>1.3.6.1.4.1.28284.6.2.2.37.1</EsindajaleOID>
<Esindajale>N</Esindajale>
</Konfidentsiaalsus>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.4.100</ProovinouIdOID>
<ProovinouId>ANI7570-16522287</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.8</MaterjaliTyypOID>
<MaterjaliTyyp>119297000</MaterjaliTyyp>
<MaterjaliNimi>Veri</MaterjaliNimi>
<Ribakood>16522287</Ribakood>
<Jarjenumber>7570</Jarjenumber>
<VotmisAeg>2022-06-13 08:53:00</VotmisAeg>
</Proov>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.4.100</ProovinouIdOID>
<ProovinouId>ANI7571-16522288</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.8</MaterjaliTyypOID>
<MaterjaliTyyp>119297000</MaterjaliTyyp>
<MaterjaliNimi>Veri</MaterjaliNimi>
<Ribakood>16522288</Ribakood>
<Jarjenumber>7571</Jarjenumber>
<VotmisAeg>2022-06-13 08:53:00</VotmisAeg>
</Proov>
<UuringuGrupp>
<UuringuGruppId>TL10</UuringuGruppId>
<UuringuGruppNimi>Hematoloogilised uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>57021-8</UuringId>
<TLyhend>B-CBC-5Diff</TLyhend>
<KNimetus>Hemogramm 5-osalise leukogrammiga</KNimetus>
<UuringNimi>Hemogramm</UuringNimi>
<TellijaUuringId>18327</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>7570</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
<UuringuGrupp>
<UuringuGruppId>TL40</UuringuGruppId>
<UuringuGruppNimi>Kliinilise keemia uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>L-3757</UuringId>
<TLyhend>B-HbA1c panel</TLyhend>
<KNimetus>HbA1c paneel</KNimetus>
<UuringNimi>HbA1c</UuringNimi>
<TellijaUuringId>18349</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>7571</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
</Tellimus>
</Saadetis>
";type=text/xml; charset=UTF-8'

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
<Pais>
<Pakett versioon="20">OL</Pakett>
<Saatja>trvurgtst</Saatja>
<Saaja>trvurgtst</Saaja>
<Aeg>2025-08-04 03:30:15</Aeg>
<SaadetisId>
1</SaadetisId>
<Email>argo@medreport.ee</Email>
</Pais>
<Tellimus cito="EI">
<ValisTellimuseId>
1</ValisTellimuseId>
<!--<TellijaAsutus>-->
<Asutus tyyp="TELLIJA">
<AsutuseId>16381793</AsutuseId>
<AsutuseNimi>MedReport
</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
<Telefon>+37258871517</Telefon>
</Asutus>
<!--<TeostajaAsutus>-->
<Asutus tyyp="TEOSTAJA">
<AsutuseId>11107913</AsutuseId>
<AsutuseNimi>Synlab HTI
Tallinn</AsutuseNimi>
<AsutuseKood>SLA</AsutuseKood>
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
<Telefon>+3723417123</Telefon>
</Asutus>
<!--<TellijaIsik>-->
<Personal tyyp="TELLIJA"
jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>
39610230904</PersonalKood>
<PersonalPerekonnaNimi>test2</PersonalPerekonnaNimi>
<PersonalEesNimi>
test1</PersonalEesNimi>
<Telefon>56232775</Telefon>
</Personal>
<TellijaMarkused>Test
comment</TellijaMarkused>
<Patsient>
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
<Isikukood>39610230904</Isikukood>
<PerekonnaNimi>test2</PerekonnaNimi>
<EesNimi>
test1</EesNimi>
<SynniAeg>1996-00-23</SynniAeg>
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
<Sugu>male</Sugu>
</Patsient>
<Konfidentsiaalsus>
<PatsiendileOID>
2.16.840.1.113883.5.25</PatsiendileOID>
<Patsiendile>N</Patsiendile>
<ArstileOID>
1.3.6.1.4.1.28284.6.2.2.39.1</ArstileOID>
<Arstile>N</Arstile>
<EsindajaleOID>
1.3.6.1.4.1.28284.6.2.2.37.1</EsindajaleOID>
<Esindajale>N</Esindajale>
</Konfidentsiaalsus>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.1.243.16</ProovinouIdOID>
<ProovinouId>A2</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.10</MaterjaliTyypOID>
<MaterjaliTyyp>119364003</MaterjaliTyyp>
<MaterjaliNimi>Seerum</MaterjaliNimi>
<Jarjenumber>1</Jarjenumber>
</Proov>
<UuringuGrupp>
<UuringuGruppId>TL106</UuringuGruppId>
<UuringuGruppNimi>Söömishäirete uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>2276-4</UuringId>
<TLyhend>S,P-Fer</TLyhend>
<KNimetus>Ferritiin</KNimetus>
<UuringNimi>Ferritiin</UuringNimi>
<TellijaUuringId>84</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>1</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
</Tellimus>
</Saadetis>

View File

@@ -0,0 +1,76 @@
<?xml version= \"1.0\" encoding= \"UTF-8\"?>
<Saadetis xmlns:xsi= \"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation= \"TellimusLOINC.xsd\">
<Pais>
<Pakett versioon= \"20\">OL</Pakett>
<Saatja>trvurgtst</Saatja>
<Saaja>trvurgtst</Saaja>
<Aeg>2025-08-04 06:22:18</Aeg>
<SaadetisId>TSU000001200</SaadetisId>
<Email>argo@medreport.ee</Email>
</Pais>
<Vastus>
<ValisTellimuseId>TSU000001200</ValisTellimuseId>
<Asutus tyyp= \"TELLIJA\" jarjenumber= \"1\">
<AsutuseId>16381793</AsutuseId>
<AsutuseNimi>MedReport OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
<Telefon>+37258871517</Telefon>
</Asutus>
<Asutus tyyp= \"TEOSTAJA\" jarjenumber= \"1\">
<AsutuseId>11107913</AsutuseId>
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi>
<AsutuseKood>SLA</AsutuseKood>
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
<Telefon>+3723417123</Telefon>
</Asutus>
<Personal tyyp= \"TELLIJA\" jarjenumber= \"1\">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>39610230903</PersonalKood>
<PersonalPerekonnaNimi>User</PersonalPerekonnaNimi>
<PersonalEesNimi>Test</PersonalEesNimi>
<Telefon>+37256232775</Telefon>
</Personal>
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
<TellimuseNumber>TSU000001200</TellimuseNumber>
<TellimuseOlek>4</TellimuseOlek>
<Patsient>
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
<Isikukood>39610230903</Isikukood>
<PerekonnaNimi>User</PerekonnaNimi>
<EesNimi>Test</EesNimi>
<SynniAeg>1996-00-23</SynniAeg>
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
<Sugu>male</Sugu>
</Patsient>
<UuringuGrupp>
<UuringuGruppId>TL106</UuringuGruppId>
<UuringuGruppNimi>Söömishäirete uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>2276-4</UuringId>
<TLyhend>S,P-Fer</TLyhend>
<KNimetus>Ferritiin</KNimetus>
<UuringNimi>Ferritiin</UuringNimi>
<TellijaUuringId>84</TellijaUuringId>
<TeostajaUuringId>84</TeostajaUuringId>
<UuringOlek>4</UuringOlek>
<Mootyhik>%</Mootyhik>
<UuringuVastus>
<VastuseVaartus>30000</VastuseVaartus>
<VastuseAeg>2025-08-04 07:00:12</VastuseAeg>
<NormYlem kaasaarvatud= \"EI\">100000</NormYlem>
<NormAlum kaasaarvatud= \"EI\">50</NormAlum>
<NormiStaatus>0</NormiStaatus>
<ProoviJarjenumber>1</ProoviJarjenumber>
</UuringuVastus>
</UuringuElement>
<UuringuTaitjaAsutuseJnr>2</UuringuTaitjaAsutuseJnr>
</Uuring>
</UuringuGrupp>
</Vastus>
</Saadetis>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
<Pais>
<Pakett versioon="20">OL</Pakett>
<Saatja>medreport</Saatja>
<Saaja>HTI</Saaja>
<Aeg>2025-09-04 14:04:33</Aeg>
<SaadetisId>1234567890</SaadetisId>
<Email>argo@medreport.ee</Email>
</Pais>
<Tellimus cito="EI">
<ValisTellimuseId>1234567890</ValisTellimuseId>
<!--<TellijaAsutus>-->
<Asutus tyyp="TELLIJA">
<AsutuseId>16381793</AsutuseId>
<AsutuseNimi>MedReport OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
<Telefon>+37258871517</Telefon>
</Asutus>
<!--<TeostajaAsutus>-->
<Asutus tyyp="TEOSTAJA">
<AsutuseId>11107913</AsutuseId>
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi>
<AsutuseKood>SLA</AsutuseKood>
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
<Telefon>+3723417123</Telefon>
</Asutus>
<!--<TellijaIsik>-->
<Personal tyyp="TELLIJA" jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>1234567890</PersonalKood>
<PersonalPerekonnaNimi>Doe</PersonalPerekonnaNimi>
<PersonalEesNimi>John</PersonalEesNimi>
<Telefon>+3721234567890</Telefon>
</Personal>
<TellijaMarkused></TellijaMarkused>
<Patsient>
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
<Isikukood>1234567890</Isikukood>
<PerekonnaNimi>Doe</PerekonnaNimi>
<EesNimi>John</EesNimi>
<SynniAeg>1826-00-06</SynniAeg>
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
<Sugu>M</Sugu>
</Patsient>
<Konfidentsiaalsus>
<PatsiendileOID>2.16.840.1.113883.5.25</PatsiendileOID>
<Patsiendile>N</Patsiendile>
<ArstileOID>1.3.6.1.4.1.28284.6.2.2.39.1</ArstileOID>
<Arstile>N</Arstile>
<EsindajaleOID>1.3.6.1.4.1.28284.6.2.2.37.1</EsindajaleOID>
<Esindajale>N</Esindajale>
</Konfidentsiaalsus>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.1.243.22</ProovinouIdOID>
<ProovinouId>A7</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.15</MaterjaliTyypOID>
<MaterjaliTyyp>445295009</MaterjaliTyyp>
<MaterjaliNimi>K2E/K3E-veri</MaterjaliNimi>
<Jarjenumber>1</Jarjenumber>
</Proov>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.1.243.22</ProovinouIdOID>
<ProovinouId>A2</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.15</MaterjaliTyypOID>
<MaterjaliTyyp>119364003</MaterjaliTyyp>
<MaterjaliNimi>Seerum</MaterjaliNimi>
<Jarjenumber>2</Jarjenumber>
</Proov>
<UuringuGrupp>
<UuringuGruppId>TL10</UuringuGruppId>
<UuringuGruppNimi>Hematoloogilised uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>57021-8</UuringId>
<TLyhend>B-CBC-5Diff</TLyhend>
<KNimetus>Hemogramm 5-osalise leukogrammiga</KNimetus>
<UuringNimi>Hemogramm</UuringNimi>
<TellijaUuringId>4522</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>1</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
<UuringuGrupp>
<UuringuGruppId>TL50</UuringuGruppId>
<UuringuGruppNimi>Hormoon- jm. immuunuuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>2276-4</UuringId>
<TLyhend>S,P-Fer</TLyhend>
<KNimetus>Ferritiin</KNimetus>
<UuringNimi>Ferritiin</UuringNimi>
<TellijaUuringId>4605</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>2</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
</Tellimus>
</Saadetis>

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
<Pais>
<Pakett versioon="20">OL</Pakett>
<Saatja>medreport</Saatja>
<Saaja>HTI</Saaja>
<Aeg>2025-09-04 14:17:12</Aeg>
<SaadetisId>1234567890</SaadetisId>
<Email>argo@medreport.ee</Email>
</Pais>
<Tellimus cito="EI">
<ValisTellimuseId>1234567890</ValisTellimuseId>
<!--<TellijaAsutus>-->
<Asutus tyyp="TELLIJA">
<AsutuseId>16381793</AsutuseId>
<AsutuseNimi>MedReport OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
<Telefon>+37258871517</Telefon>
</Asutus>
<!--<TeostajaAsutus>-->
<Asutus tyyp="TEOSTAJA">
<AsutuseId>11107913</AsutuseId>
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi>
<AsutuseKood>SLA</AsutuseKood>
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
<Telefon>+3723417123</Telefon>
</Asutus>
<!--<TellijaIsik>-->
<Personal tyyp="TELLIJA" jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>1234567890</PersonalKood>
<PersonalPerekonnaNimi>Doe</PersonalPerekonnaNimi>
<PersonalEesNimi>John</PersonalEesNimi>
<Telefon>+3721234567890</Telefon>
</Personal>
<TellijaMarkused></TellijaMarkused>
<Patsient>
<IsikukoodiOID>1.3.6.1.4.1.28284.6.2.2.1</IsikukoodiOID>
<Isikukood>1234567890</Isikukood>
<PerekonnaNimi>Doe</PerekonnaNimi>
<EesNimi>John</EesNimi>
<SynniAeg>1826-00-06</SynniAeg>
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
<Sugu>M</Sugu>
</Patsient>
<Konfidentsiaalsus>
<PatsiendileOID>2.16.840.1.113883.5.25</PatsiendileOID>
<Patsiendile>N</Patsiendile>
<ArstileOID>1.3.6.1.4.1.28284.6.2.2.39.1</ArstileOID>
<Arstile>N</Arstile>
<EsindajaleOID>1.3.6.1.4.1.28284.6.2.2.37.1</EsindajaleOID>
<Esindajale>N</Esindajale>
</Konfidentsiaalsus>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.1.243.22</ProovinouIdOID>
<ProovinouId>A9</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.15</MaterjaliTyypOID>
<MaterjaliTyyp>2491000181101</MaterjaliTyyp>
<MaterjaliNimi>Glükolüüsi inhibiitoriga plasma</MaterjaliNimi>
<Jarjenumber>1</Jarjenumber>
</Proov>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.1.243.22</ProovinouIdOID>
<ProovinouId>A2</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.15</MaterjaliTyypOID>
<MaterjaliTyyp>119364003</MaterjaliTyyp>
<MaterjaliNimi>Seerum</MaterjaliNimi>
<Jarjenumber>2</Jarjenumber>
</Proov>
<Proov>
<ProovinouIdOID>1.3.6.1.4.1.28284.6.2.1.243.22</ProovinouIdOID>
<ProovinouId>A2</ProovinouId>
<MaterjaliTyypOID>1.3.6.1.4.1.28284.6.2.1.244.15</MaterjaliTyypOID>
<MaterjaliTyyp>119364003</MaterjaliTyyp>
<MaterjaliNimi>Seerum</MaterjaliNimi>
<Jarjenumber>3</Jarjenumber>
</Proov>
<UuringuGrupp>
<UuringuGruppId>TL40</UuringuGruppId>
<UuringuGruppNimi>Kliinilise keemia uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>14771-0</UuringId>
<TLyhend>fS,fP-Gluc</TLyhend>
<KNimetus>Glükoos paastuseerumis/-plasmas</KNimetus>
<UuringNimi>Glükoos</UuringNimi>
<TellijaUuringId>4530</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>1</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
<UuringuGrupp>
<UuringuGruppId>TL40</UuringuGruppId>
<UuringuGruppNimi>Kliinilise keemia uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>14927-8</UuringId>
<TLyhend>S,P-Trigl</TLyhend>
<KNimetus>Triglütseriidid</KNimetus>
<UuringNimi>Triglütseriidid</UuringNimi>
<TellijaUuringId>4535</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>2</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
<UuringuGrupp>
<UuringuGruppId>TL40</UuringuGruppId>
<UuringuGruppNimi>Kliinilise keemia uuringud</UuringuGruppNimi>
<Uuring>
<UuringuElement>
<UuringIdOID>2.16.840.1.113883.6.1</UuringIdOID>
<UuringId>14798-3</UuringId>
<TLyhend>S,P-Fe</TLyhend>
<KNimetus>Raud</KNimetus>
<UuringNimi>Raud</UuringNimi>
<TellijaUuringId>4570</TellijaUuringId>
</UuringuElement>
<ProoviJarjenumber>3</ProoviJarjenumber>
</Uuring>
</UuringuGrupp>
</Tellimus>
</Saadetis>

View File

@@ -69,6 +69,7 @@
"fast-xml-parser": "^5.2.5",
"isikukood": "3.1.7",
"jsonwebtoken": "9.0.2",
"libphonenumber-js": "^1.12.15",
"lodash": "^4.17.21",
"lucide-react": "^0.510.0",
"next": "15.3.2",

View File

@@ -79,15 +79,9 @@ export function PersonalAccountDropdown({
}) {
const { data: personalAccountData } = usePersonalAccountData(user.id);
const signedInAsLabel = useMemo(() => {
const email = user?.email ?? undefined;
const phone = user?.phone ?? undefined;
return email ?? phone;
}, [user]);
const displayName =
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
const { name, last_name } = personalAccountData ?? {};
const firstNameLabel = toTitleCase(name) ?? '-';
const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
const hasTotpFactor = useMemo(() => {
const factors = user?.factors ?? [];
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
<ProfileAvatar
className={'rounded-md'}
fallbackClassName={'rounded-md border'}
displayName={displayName ?? user?.email ?? ''}
displayName={firstNameLabel}
pictureUrl={personalAccountData?.picture_url}
/>
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
data-test={'account-dropdown-display-name'}
className={'truncate text-sm'}
>
{toTitleCase(displayName)}
{firstNameLabel}
</span>
</div>
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
</div>
<div>
<span className={'block truncate'}>{signedInAsLabel}</span>
<span className={'block truncate'}>{fullNameLabel}</span>
</div>
</div>
</DropdownMenuItem>

View File

@@ -48,6 +48,41 @@ class AccountsApi {
return data;
}
/**
* @name getPersonalAccountByUserId
* @description Get the personal account data for the given user ID.
* @param userId
*/
async getPersonalAccountByUserId(userId: string): Promise<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: (() => {
if (!personal_code) {
return null;
}
if (personal_code.toLowerCase().startsWith('ee')) {
return personal_code.substring(2);
}
return personal_code;
})(),
};
}
/**
* @name getAccountWorkspace
* @description Get the account workspace data.

View File

@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
* @see https://supabase.com/docs/guides/auth/social-login
*/
const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
azure: 'email',
keycloak: 'openid',
// azure: 'email',
// keycloak: 'openid',
// add your OAuth providers here
};
@@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{
queryParams.set('invite_token', props.inviteToken);
}
const redirectPath = [
props.paths.callback,
queryParams.toString(),
].join('?');
// signicat/keycloak will not allow redirect-uri with changing query params
const INCLUDE_QUERY_PARAMS = false as boolean;
const redirectPath = INCLUDE_QUERY_PARAMS
? [props.paths.callback, queryParams.toString()].join('?')
: props.paths.callback;
const redirectTo = [origin, redirectPath].join('');
const scopes = OAUTH_SCOPES[provider] ?? undefined;
@@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{
redirectTo,
queryParams: props.queryParams,
scopes,
// skipBrowserRedirect: false,
},
} satisfies SignInWithOAuthCredentials;

View File

@@ -108,6 +108,9 @@ export function SignInMethodsContainer(props: {
callback: props.paths.callback,
returnPath: props.paths.returnPath,
}}
queryParams={{
prompt: 'login',
}}
/>
</If>
</>

View File

@@ -44,7 +44,7 @@ export function SignUpMethodsContainer(props: {
emailRedirectTo={props.paths.callback}
defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox}
onSignUp={() => redirect(redirectUrl)}
//onSignUp={() => redirect(redirectUrl)}
/>
</If>
@@ -79,6 +79,9 @@ export function SignUpMethodsContainer(props: {
callback: props.paths.callback,
returnPath: props.paths.appHome,
}}
queryParams={{
prompt: 'login',
}}
/>
</If>
</>

View File

@@ -28,6 +28,10 @@ export const OrderSchema = z.object({
title: z.string(),
isPackage: z.boolean(),
analysisOrderId: z.number(),
productMetadata: z.object({
analysisIdOriginal: z.string().nullable(),
analysisResultUnit: z.string().nullable(),
}).nullable(),
});
export type Order = z.infer<typeof OrderSchema>;

View File

@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
@@ -127,7 +126,7 @@ export async function login(_currentState: unknown, formData: FormData) {
}
}
export async function signout(countryCode: string) {
export async function medusaLogout(countryCode = 'ee') {
await sdk.auth.logout()
await removeAuthToken()
@@ -139,8 +138,6 @@ export async function signout(countryCode: string) {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
redirect(`/${countryCode}/account`)
}
export async function transferCart() {
@@ -260,62 +257,110 @@ export const updateCustomerAddress = async (
})
}
export async function medusaLoginOrRegister(credentials: {
email: string
password?: string
}) {
const { email, password } = credentials;
async function medusaLogin(email: string, password: string) {
const token = await sdk.auth.login("customer", "emailpass", { email, password });
await setAuthToken(token as string);
try {
const token = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(token as string);
await transferCart();
} catch (e) {
console.error("Failed to transfer cart", e);
}
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found for active session");
}
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
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()),
});
}
export async function medusaLoginOrRegister(credentials: {
email: string
supabaseUserId?: string
name?: string,
lastName?: string,
} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) {
const { email, supabaseUserId, name, lastName } = credentials;
const password = await (async () => {
if (credentials.isDevPasswordLogin) {
return credentials.password;
}
return customer.id;
} catch (error) {
console.error("Failed to login customer, attempting to register", error);
return generateDeterministicPassword(email, supabaseUserId);
})();
try {
return await medusaLogin(email, password);
} catch (loginError) {
console.error("Failed to login customer, attempting to register", loginError);
try {
const registerToken = await sdk.auth.register("customer", "emailpass", {
email: email,
password: password,
})
await setAuthToken(registerToken as string);
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.customer.create({ email }, {}, headers);
const loginToken = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(loginToken as string);
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
await transferCart();
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
}
return customer.id;
await medusaRegister({ email, password, name, lastName });
return await medusaLogin(email, password);
} catch (registerError) {
console.error("Failed to create Medusa account for user with email=${email}", registerError);
throw medusaError(registerError);
}
}
}
/**
* Generate a deterministic password based on user identifier
* This ensures the same user always gets the same password for Medusa
*/
async function generateDeterministicPassword(email: string, userId?: string): Promise<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!`;
}

View File

@@ -14,7 +14,7 @@ export const listProducts = async ({
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[] }
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_id?: string }
countryCode?: string
regionId?: string
}): Promise<{
@@ -63,12 +63,12 @@ export const listProducts = async ({
offset,
region_id: region?.id,
fields:
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,+status",
...queryParams,
},
headers,
next,
cache: "force-cache",
//cache: "force-cache",
}
)
.then(({ products, count }) => {

View File

@@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin"
import Package from "@modules/common/icons/package"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
import { signout } from "@lib/data/customer"
import { medusaLogout } from "@lib/data/customer"
const AccountNav = ({
customer,
@@ -21,7 +21,7 @@ const AccountNav = ({
const { countryCode } = useParams() as { countryCode: string }
const handleLogout = async () => {
await signout(countryCode)
await medusaLogout(countryCode)
}
return (

View File

@@ -5,8 +5,8 @@ import type { User } from '@supabase/supabase-js';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { ApplicationRole } from '@kit/accounts/types/accounts';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUser } from '@kit/supabase/hooks/use-user';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
const paths = {
home: pathsConfig.app.home,

View File

@@ -13,7 +13,7 @@ export function InfoTooltip({
content,
icon,
}: {
content?: string | null;
content?: JSX.Element | string | null;
icon?: JSX.Element;
}) {
if (!content) return null;
@@ -23,7 +23,7 @@ export function InfoTooltip({
<TooltipTrigger>
{icon || <Info className="size-4 cursor-pointer" />}
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
<TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);

View File

@@ -32,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({
providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
oAuth: ['google'],
oAuth: ['keycloak'],
},
} satisfies z.infer<typeof AuthConfigSchema>);

View File

@@ -4,6 +4,7 @@ import {
AuthError,
type EmailOtpType,
SupabaseClient,
User,
} from '@supabase/supabase-js';
/**
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
* @description Service for handling auth callbacks in Supabase
*/
class AuthCallbackService {
constructor(private readonly client: SupabaseClient) {}
constructor(private readonly client: SupabaseClient) { }
/**
* @name verifyTokenHash
@@ -128,89 +129,117 @@ class AuthCallbackService {
/**
* @name exchangeCodeForSession
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
* @param request
* @param params
* @param authCode
*/
async exchangeCodeForSession(
request: Request,
params: {
joinTeamPath: string;
redirectPath: string;
errorPath?: string;
},
): Promise<{
nextPath: string;
}> {
const requestUrl = new URL(request.url);
const searchParams = requestUrl.searchParams;
async exchangeCodeForSession(authCode: string): Promise<{
isSuccess: boolean;
user: User;
} | ErrorURLParameters> {
let user: User;
try {
const { data, error } =
await this.client.auth.exchangeCodeForSession(authCode);
const authCode = searchParams.get('code');
const error = searchParams.get('error');
const nextUrlPathFromParams = searchParams.get('next');
const inviteToken = searchParams.get('invite_token');
const errorPath = params.errorPath ?? '/auth/callback/error';
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
// if we have an invite token, we redirect to the join team page
// instead of the default next url. This is because the user is trying
// to join a team and we want to make sure they are redirected to the
// correct page.
if (inviteToken) {
const emailParam = searchParams.get('email');
const urlParams = new URLSearchParams({
invite_token: inviteToken,
email: emailParam ?? '',
});
nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
}
if (authCode) {
try {
const { error } =
await this.client.auth.exchangeCodeForSession(authCode);
// if we have an error, we redirect to the error page
if (error) {
return onError({
code: error.code,
error: error.message,
path: errorPath,
});
}
} catch (error) {
console.error(
{
error,
name: `auth.callback`,
},
`An error occurred while exchanging code for session`,
);
const message = error instanceof Error ? error.message : error;
return onError({
code: (error as AuthError)?.code,
error: message as string,
path: errorPath,
// if we have an error, we redirect to the error page
if (error) {
return getErrorURLParameters({
code: error.code,
error: error.message,
});
}
}
if (error) {
return onError({
error,
path: errorPath,
// Handle Keycloak users - set up Medusa integration
if (data?.user && this.isKeycloakUser(data.user)) {
await this.setupMedusaUserForKeycloak(data.user);
}
user = data.user;
} catch (error) {
console.error(
{
error,
name: `auth.callback`,
},
`An error occurred while exchanging code for session`,
);
const message = error instanceof Error ? error.message : error;
return getErrorURLParameters({
code: (error as AuthError)?.code,
error: message as string,
});
}
return {
nextPath: nextUrl,
isSuccess: true,
user,
};
}
/**
* Check if user is from Keycloak provider
*/
private isKeycloakUser(user: any): boolean {
return user?.app_metadata?.provider === 'keycloak' ||
user?.app_metadata?.providers?.includes('keycloak');
}
private async setupMedusaUserForKeycloak(user: any): Promise<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) {
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
url.host = host as string;
@@ -231,15 +260,19 @@ class AuthCallbackService {
}
}
function onError({
interface ErrorURLParameters {
error: string;
code?: string;
searchParams: string;
}
export function getErrorURLParameters({
error,
path,
code,
}: {
error: string;
path: string;
code?: string;
}) {
}): ErrorURLParameters {
const errorMessage = getAuthErrorMessage({ error, code });
console.error(
@@ -255,10 +288,10 @@ function onError({
code: code ?? '',
});
const nextPath = `${path}?${searchParams.toString()}`;
return {
nextPath,
error: errorMessage,
code: code ?? '',
searchParams: searchParams.toString(),
};
}

View File

@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
export function getSupabaseBrowserClient<GenericSchema = Database>() {
const keys = getSupabaseClientKeys();
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey, {
auth: {
flowType: 'pkce',
autoRefreshToken: true,
persistSession: true,
},
});
}

View File

@@ -20,6 +20,11 @@ export function createMiddlewareClient<GenericSchema = Database>(
const keys = getSupabaseClientKeys();
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
auth: {
flowType: 'pkce',
autoRefreshToken: true,
persistSession: true,
},
cookies: {
getAll() {
return request.cookies.getAll();

View File

@@ -15,6 +15,11 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
const keys = getSupabaseClientKeys();
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
auth: {
flowType: 'pkce',
autoRefreshToken: true,
persistSession: true,
},
cookies: {
async getAll() {
const cookieStore = await cookies();

View File

@@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() {
const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email,
password: credentials.password,
isDevPasswordLogin: true,
});
await client
.schema('medreport').from('accounts')

View File

@@ -9,7 +9,13 @@ export function useSignInWithProvider() {
const mutationKey = ['auth', 'sign-in-with-provider'];
const mutationFn = async (credentials: SignInWithOAuthCredentials) => {
const response = await client.auth.signInWithOAuth(credentials);
const response = await client.auth.signInWithOAuth({
...credentials,
options: {
...credentials.options,
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (response.error) {
throw response.error.message;

View File

@@ -6,8 +6,23 @@ export function useSignOut() {
const client = useSupabase();
return useMutation({
mutationFn: () => {
return client.auth.signOut();
mutationFn: async () => {
try {
try {
const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer');
await medusaLogout();
} catch (medusaError) {
console.warn('Medusa logout failed or not available:', medusaError);
}
const { error } = await client.auth.signOut();
if (error) {
throw error;
}
} catch (error) {
console.error('Logout error:', error);
throw error;
}
},
});
}

View File

@@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() {
const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email,
password: credentials.password,
isDevPasswordLogin: true,
});
await client
.schema('medreport').from('accounts')

57
pnpm-lock.yaml generated
View File

@@ -128,6 +128,9 @@ importers:
jsonwebtoken:
specifier: 9.0.2
version: 9.0.2
libphonenumber-js:
specifier: ^1.12.15
version: 1.12.15
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -475,10 +478,10 @@ importers:
dependencies:
'@keystatic/core':
specifier: 0.5.47
version: 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystatic/next':
specifier: ^5.0.4
version: 5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc':
specifier: ^0.5.1
version: 0.5.4(@types/react@19.1.4)(react@19.1.0)
@@ -1269,7 +1272,7 @@ importers:
dependencies:
'@sentry/nextjs':
specifier: ^9.19.0
version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
import-in-the-middle:
specifier: 1.13.2
version: 1.13.2
@@ -8174,6 +8177,9 @@ packages:
engines: {node: '>=16'}
hasBin: true
libphonenumber-js@1.12.15:
resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==}
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -11455,7 +11461,7 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
'@keystar/ui@0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@keystar/ui@0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@emotion/css': 11.13.5
@@ -11548,18 +11554,18 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- supports-color
'@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@braintree/sanitize-url': 6.0.4
'@emotion/weak-memoize': 0.3.1
'@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@internationalized/string': 3.2.7
'@keystar/ui': 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystar/ui': 0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0)
'@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -11630,13 +11636,13 @@ snapshots:
- next
- supports-color
'@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@keystatic/core': 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystatic/core': 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@types/react': 19.1.4
chokidar: 3.6.0
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
server-only: 0.0.1
@@ -17230,7 +17236,7 @@ snapshots:
'@sentry/core@9.46.0': {}
'@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
'@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.34.0
@@ -17243,7 +17249,7 @@ snapshots:
'@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
'@sentry/webpack-plugin': 3.5.0(webpack@5.101.3)
chalk: 3.0.0
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
resolve: 1.22.8
rollup: 4.35.0
stacktrace-parser: 0.1.11
@@ -20630,6 +20636,8 @@ snapshots:
dependencies:
isomorphic.js: 0.2.5
libphonenumber-js@1.12.15: {}
lightningcss-darwin-arm64@1.30.1:
optional: true
@@ -21265,31 +21273,6 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.5.2
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001723
postcss: 8.4.31
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@19.1.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.2
'@next/swc-darwin-x64': 15.5.2
'@next/swc-linux-arm64-gnu': 15.5.2
'@next/swc-linux-arm64-musl': 15.5.2
'@next/swc-linux-x64-gnu': 15.5.2
'@next/swc-linux-x64-musl': 15.5.2
'@next/swc-win32-arm64-msvc': 15.5.2
'@next/swc-win32-x64-msvc': 15.5.2
'@opentelemetry/api': 1.9.0
babel-plugin-react-compiler: 19.1.0-rc.2
sharp: 0.34.3
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
no-case@3.0.4:
dependencies:
lower-case: 2.0.2

View File

@@ -68,7 +68,7 @@
"acceptTermsAndConditions": "I accept the <TermsOfServiceLink /> and <PrivacyPolicyLink />",
"termsOfService": "Terms of Service",
"privacyPolicy": "Privacy Policy",
"orContinueWith": "Or continue with",
"orContinueWith": "Continue with",
"redirecting": "You're in! Please wait...",
"errors": {
"Invalid login credentials": "The credentials entered are invalid",

View File

@@ -128,6 +128,9 @@
"amount": "Amount",
"selectDate": "Select date"
},
"formFieldError": {
"invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)"
},
"wallet": {
"balance": "Your MedReport account balance",
"expiredAt": "Valid until {{expiredAt}}"

View File

@@ -68,7 +68,7 @@
"acceptTermsAndConditions": "Ma nõustun <TermsOfServiceLink /> ja <PrivacyPolicyLink />",
"termsOfService": "Kasutustingimused",
"privacyPolicy": "Privaatsuspoliitika",
"orContinueWith": "Või jätka koos",
"orContinueWith": "Jätka",
"redirecting": "Oled sees! Palun oota...",
"errors": {
"Invalid login credentials": "Sisestatud andmed on valed",

View File

@@ -128,6 +128,9 @@
"amount": "Summa",
"selectDate": "Vali kuupäev"
},
"formFieldError": {
"invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)"
},
"wallet": {
"balance": "Sinu MedReporti konto saldo",
"expiredAt": "Kehtiv kuni {{expiredAt}}"

View File

@@ -68,7 +68,7 @@
"acceptTermsAndConditions": "Я принимаю <TermsOfServiceLink /> и <PrivacyPolicyLink />",
"termsOfService": "Условия использования",
"privacyPolicy": "Политика конфиденциальности",
"orContinueWith": "Или продолжить через",
"orContinueWith": "Продолжить через",
"redirecting": "Вы вошли! Пожалуйста, подождите...",
"errors": {
"Invalid login credentials": "Введенные данные недействительны",

8
run-test-sync-local.sh Normal file → Executable file
View File

@@ -1,6 +1,6 @@
#!/bin/bash
MEDUSA_ORDER_ID="order_01K1TQQHZGPXKDHAH81TDSNGXR"
MEDUSA_ORDER_ID="order_01K2JFCZ609YF5G84ZKDCBWPM6"
# HOSTNAME="https://test.medreport.ee"
# JOBS_API_TOKEN="fd26ec26-70ed-11f0-9e95-431ac3b15a84"
@@ -33,7 +33,7 @@ function sync_analysis_groups_store() {
# Requirements
# 1. Sync analysis groups from Medipost to B2B
sync_analysis_groups
#sync_analysis_groups
# 2. Optional - sync all Medipost analysis groups from B2B to Medusa (or add manually)
#sync_analysis_groups_store
@@ -41,7 +41,7 @@ sync_analysis_groups
# 3. Set up products configurations in Medusa so B2B "Telli analüüs" page shows the product and you can do payment flow
# 4. After payment is done, run `send_medipost_test_response` to send the fake test results to Medipost
# send_medipost_test_response
#send_medipost_test_response
# 5. Run `sync_analysis_results` to sync the all new Medipost results to B2B
# sync_analysis_results
sync_analysis_results

5
scripts/build-docker.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
docker build -t medreport-b2b:latest .
# Get size of built image and display it

View File

@@ -105,9 +105,9 @@ file_size_limit = "50MiB"
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
site_url = "http://localhost:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000","http://localhost:3000/auth/callback", "http://localhost:3000/update-password"]
additional_redirect_urls = ["https://127.0.0.1:3000","http://127.0.0.1:3000","http://localhost:3000","http://localhost:3000/auth/callback", "http://localhost:3000/update-password"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
@@ -269,6 +269,14 @@ url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
[auth.external.keycloak]
enabled = true
client_id = "env(SUPABASE_AUTH_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_KEYCLOAK_SECRET)"
redirect_uri = "env(SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL)"
url = "env(SUPABASE_AUTH_KEYCLOAK_URL)"
skip_nonce_check = true
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false

View File

@@ -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;
$$;

View File

@@ -0,0 +1,64 @@
-- Enable required extensions for cron jobs and HTTP requests
create extension if not exists pg_cron;
create extension if not exists pg_net;
-- Enable the cron extension to be used in all databases
-- This needs to be run with superuser privileges or via Supabase Dashboard
grant usage on schema cron to postgres;
grant all privileges on all tables in schema cron to postgres;
-- Function to safely schedule the cron job with environment variables
-- This approach allows for easier management and avoids hardcoding sensitive values
create or replace function schedule_sync_analysis_results_cron(
api_url text,
api_token text
) returns text as $$
declare
job_exists boolean;
begin
-- Check if job already exists
select exists(
select 1 from cron.job
where jobname = 'sync-analysis-results-every-15-minutes'
) into job_exists;
-- If job exists, unschedule it first
if job_exists then
perform cron.unschedule('sync-analysis-results-every-15-minutes');
end if;
-- Schedule the new job
perform cron.schedule(
'sync-analysis-results-every-15-minutes',
'*/15 * * * *', -- Every 15 minutes
format(
'select net.http_post(url := ''%s/api/job/sync-analysis-results'', headers := jsonb_build_object(''Content-Type'', ''application/json'', ''x-jobs-api-key'', ''%s''), body := jsonb_build_object(''triggered_by'', ''supabase_cron'', ''timestamp'', now())) as request_id;',
api_url, api_token
)
);
return 'Cron job scheduled successfully';
end;
$$ language plpgsql;
-- Example usage (replace with your actual values):
-- select schedule_sync_analysis_results_cron(
-- 'https://your-domain.com',
-- 'your-jobs-api-token-here'
-- );
-- Utility queries for managing the cron job:
-- 1. Check if the job is scheduled
-- select * from cron.job where jobname = 'sync-analysis-results-every-15-minutes';
-- 2. View job execution history
-- select * from cron.job_run_details
-- where jobid = (select jobid from cron.job where jobname = 'sync-analysis-results-every-15-minutes')
-- order by start_time desc limit 10;
-- 3. Unschedule the job if needed
-- select cron.unschedule('sync-analysis-results-every-15-minutes');
-- 4. Check all scheduled jobs
-- select jobid, schedule, active, jobname from cron.job;

View File

@@ -0,0 +1,122 @@
# Supabase Cron Job Setup for sync-analysis-results
This document explains how to set up a Supabase cron job to automatically call the `sync-analysis-results` API endpoint every 15 minutes.
## Prerequisites
1. Supabase project with database access
2. Your application deployed and accessible via a public URL
3. `JOBS_API_TOKEN` environment variable configured
## Setup Steps
### 1. Enable Required Extensions
First, enable the required PostgreSQL extensions in your Supabase project:
```sql
create extension if not exists pg_cron;
create extension if not exists pg_net;
```
You can run this either:
- In the Supabase Dashboard → Database → SQL Editor
- Or deploy the migration file: `supabase/migrations/setup_sync_analysis_results_cron.sql`
### 2. Schedule the Cron Job
Use the helper function to schedule the cron job with your specific configuration:
```sql
select schedule_sync_analysis_results_cron(
'https://your-actual-domain.com', -- Replace with your API URL
'your-actual-jobs-api-token' -- Replace with your JOBS_API_TOKEN
);
```
### 3. Verify the Setup
Check if the job was scheduled successfully:
```sql
select * from cron.job where jobname = 'sync-analysis-results-every-15-minutes';
```
## Management Commands
### View Job Execution History
```sql
select * from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'sync-analysis-results-every-15-minutes')
order by start_time desc limit 10;
```
### Check All Scheduled Jobs
```sql
select jobid, schedule, active, jobname from cron.job;
```
### Unschedule the Job
```sql
select cron.unschedule('sync-analysis-results-every-15-minutes');
```
## Configuration Details
- **Schedule**: `*/15 * * * *` (every 15 minutes)
- **HTTP Method**: POST
- **Headers**:
- `Content-Type: application/json`
- `x-jobs-api-key: YOUR_JOBS_API_TOKEN`
- **Body**: JSON with metadata about the cron trigger
## Security Considerations
1. **API Token**: Store your `JOBS_API_TOKEN` securely and never commit it to version control
2. **Network Access**: Ensure your Supabase instance can reach your deployed application
3. **Rate Limiting**: The 15-minute interval should be appropriate for your use case
## Troubleshooting
### Job Not Running
1. Check if extensions are enabled:
```sql
select * from pg_extension where extname in ('pg_cron', 'pg_net');
```
2. Verify job is active:
```sql
select * from cron.job where jobname = 'sync-analysis-results-every-15-minutes';
```
3. Check for execution errors:
```sql
select * from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'sync-analysis-results-every-15-minutes')
and status = 'failed'
order by start_time desc;
```
### API Authentication Issues
1. Verify your `JOBS_API_TOKEN` is correct
2. Test the API endpoint manually:
```bash
curl -X POST https://your-domain.com/api/job/sync-analysis-results \
-H "Content-Type: application/json" \
-H "x-jobs-api-key: YOUR_JOBS_API_TOKEN"
```
## Alternative: Using Supabase Edge Functions
If you prefer using Supabase Edge Functions instead of pg_cron, you can:
1. Create an Edge Function that calls your API
2. Use Supabase's built-in cron triggers for Edge Functions
3. This approach provides better logging and error handling
Contact your team lead for assistance with Edge Functions setup if needed.