[
{
title: 'dashboard:gender',
- description: gender ?? 'dashboard:male',
+ description: gender ?? '-',
icon: ,
iconBg: 'bg-success',
},
@@ -84,7 +83,7 @@ const cards = ({
},
{
title: 'dashboard:bmi',
- description: bmiFromMetric(weight || 0, height || 0).toString(),
+ description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
icon: ,
iconBg: getBmiBackgroundColor(bmiStatus),
},
@@ -145,21 +144,26 @@ export default function Dashboard({
'id'
>[];
}) {
- const params = getPersonParameters(account.personal_code!);
- const bmiStatus = getBmiStatus(bmiThresholds, {
- age: params?.age || 0,
- height: account.accountParams?.height || 0,
- weight: account.accountParams?.weight || 0,
- });
+ const height = account.accountParams?.height || 0;
+ const weight = account.accountParams?.weight || 0;
+
+ let age: number = 0;
+ let gender: { label: string; value: string } | null = null;
+ try {
+ ({ age = 0, gender } = PersonalCode.parsePersonalCode(account.personal_code!));
+ } catch (e) {
+ console.error("Failed to parse personal code", e);
+ }
+ const bmiStatus = getBmiStatus(bmiThresholds, { age, height, weight });
return (
<>
{cards({
- gender: params?.gender,
- age: params?.age,
- height: account.accountParams?.height,
- weight: account.accountParams?.weight,
+ gender: gender?.label,
+ age,
+ height,
+ weight,
bmiStatus,
smoking: account.accountParams?.isSmoker,
}).map(
diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx
index 5ef16ea..bd7f8a7 100644
--- a/app/home/(user)/_components/order-analyses-cards.tsx
+++ b/app/home/(user)/_components/order-analyses-cards.tsx
@@ -21,7 +21,6 @@ import { formatCurrency } from '@/packages/shared/src/utils';
export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle'
> & {
- isAvailable: boolean;
variant: { id: string };
price: number | null;
};
@@ -58,13 +57,12 @@ export default function OrderAnalysesCards({
}
return (
-
+
{analyses.map(({
title,
variant,
description,
subtitle,
- isAvailable,
price,
}) => {
const formattedPrice = typeof price === 'number'
@@ -77,7 +75,7 @@ export default function OrderAnalysesCards({
return (
@@ -86,46 +84,44 @@ export default function OrderAnalysesCards({
>
- {isAvailable && (
-
-
-
- )}
+
+
+
-
-
- {title}
- {description && (
- <>
- {' '}
-
- {formattedPrice}
- {description}
-
- }
- />
- >
+
+
+
+ {title}
+ {description && (
+ <>
+ {' '}
+
+ {formattedPrice}
+ {description}
+
+ }
+ />
+ >
+ )}
+
+ {subtitle && (
+
+ {subtitle}
+
)}
-
- {isAvailable && subtitle && (
-
- {subtitle}
-
- )}
- {!isAvailable && (
-
-
-
- )}
+
+
+ {formattedPrice}
+
);
diff --git a/app/home/(user)/_components/order/cart-totals.tsx b/app/home/(user)/_components/order/cart-totals.tsx
index 2df2237..fb7b030 100644
--- a/app/home/(user)/_components/order/cart-totals.tsx
+++ b/app/home/(user)/_components/order/cart-totals.tsx
@@ -24,7 +24,7 @@ export default function CartTotals({ medusaOrder }: {
-
+
{formatCurrency({ value: subtotal ?? 0, currencyCode: currency_code, locale: language })}
@@ -32,7 +32,7 @@ export default function CartTotals({ medusaOrder }: {
{!!discount_total && (
-
+
)}
-
+ {/*
{formatCurrency({ value: tax_total ?? 0, currencyCode: currency_code, locale: language })}
-
+
*/}
{!!gift_card_total && (
-
+
-
+
-
- :{" "}
+
+
+ :{" "}
+
+
+ {order.medusa_order_id}
+
+
+
+
+
+ :{" "}
+
{formatDate(order.created_at, 'dd.MM.yyyy HH:mm')}
-
-
- : {order.medusa_order_id}
-
+
)
}
diff --git a/app/home/(user)/_components/orders/actions.ts b/app/home/(user)/_components/orders/actions.ts
index d201507..07bdfbc 100644
--- a/app/home/(user)/_components/orders/actions.ts
+++ b/app/home/(user)/_components/orders/actions.ts
@@ -4,7 +4,7 @@ import { createPageViewLog, PageViewAction } from "~/lib/services/audit/pageView
import { loadCurrentUserAccount } from "../../_lib/server/load-user-account";
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
- const account = await loadCurrentUserAccount();
+ const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts
index 1cc954c..cd16e61 100644
--- a/app/home/(user)/_lib/server/load-analyses.ts
+++ b/app/home/(user)/_lib/server/load-analyses.ts
@@ -41,7 +41,7 @@ async function analysesLoader() {
const categoryProducts = category
? await listProducts({
countryCode,
- queryParams: { limit: 100, category_id: category.id },
+ queryParams: { limit: 100, category_id: category.id, order: 'title' },
})
: null;
@@ -51,8 +51,10 @@ async function analysesLoader() {
return {
analyses:
- categoryProducts?.response.products.map(
- ({ title, description, subtitle, variants, status, metadata }) => {
+ categoryProducts?.response.products
+ .filter(({ status, metadata }) => status === 'published' && !!metadata?.analysisIdOriginal)
+ .map(
+ ({ title, description, subtitle, variants }) => {
const variant = variants![0]!;
return {
title,
@@ -61,8 +63,6 @@ async function analysesLoader() {
variant: {
id: variant.id,
},
- isAvailable:
- status === 'published' && !!metadata?.analysisIdOriginal,
price: variant.calculated_price?.calculated_amount ?? null,
};
},
diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts
index ca3fd5b..f7fffb9 100644
--- a/app/home/(user)/_lib/server/load-analysis-packages.ts
+++ b/app/home/(user)/_lib/server/load-analysis-packages.ts
@@ -1,5 +1,4 @@
import { cache } from 'react';
-import Isikukood, { Gender } from 'isikukood';
import { listProductTypes, listProducts } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
@@ -8,6 +7,7 @@ import type { StoreProduct } from '@medusajs/types';
import { loadCurrentUserAccount } from './load-user-account';
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
+import PersonalCode from '~/lib/utils';
async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) =>
@@ -32,27 +32,8 @@ function userSpecificVariantLoader({
if (!personalCode) {
throw new Error('Personal code not found');
}
- const parsed = new Isikukood(personalCode);
- const ageRange = (() => {
- const age = parsed.getAge();
- if (age >= 18 && age <= 29) {
- return '18-29';
- }
- if (age >= 30 && age <= 39) {
- return '30-39';
- }
- if (age >= 40 && age <= 49) {
- return '40-49';
- }
- if (age >= 50 && age <= 59) {
- return '50-59';
- }
- if (age >= 60) {
- return '60';
- }
- throw new Error('Age range not supported');
- })();
- const gender = parsed.getGender() === Gender.MALE ? 'M' : 'F';
+
+ const { ageRange, gender: { value: gender } } = PersonalCode.parsePersonalCode(personalCode);
return ({
product,
@@ -89,6 +70,7 @@ async function analysisPackageElementsLoader({
queryParams: {
id: analysisElementMedusaProductIds,
limit: 100,
+ order: "title",
},
});
@@ -140,8 +122,9 @@ async function analysisPackagesWithVariantLoader({
return [
...acc,
{
+ variant,
variantId: variant.id,
- nrOfAnalyses: getAnalysisElementMedusaProductIds([product]).length,
+ nrOfAnalyses: getAnalysisElementMedusaProductIds([{ ...product, variant }]).length,
price: variant.calculated_price?.calculated_amount ?? 0,
title: product.title,
subtitle: product.subtitle,
@@ -158,7 +141,7 @@ async function analysisPackagesWithVariantLoader({
}
async function analysisPackagesLoader() {
- const account = await loadCurrentUserAccount();
+ const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
diff --git a/app/home/(user)/_lib/server/load-user-account.ts b/app/home/(user)/_lib/server/load-user-account.ts
index 471def2..a16108a 100644
--- a/app/home/(user)/_lib/server/load-user-account.ts
+++ b/app/home/(user)/_lib/server/load-user-account.ts
@@ -16,14 +16,17 @@ export const loadUserAccount = cache(accountLoader);
export async function loadCurrentUserAccount() {
const user = await requireUserInServerComponent();
- return user?.identities?.[0]?.id
- ? await loadUserAccount(user?.identities?.[0]?.id)
- : null;
+ const userId = user?.id;
+ if (!userId) {
+ return { account: null, user: null };
+ }
+ const account = await loadUserAccount(userId);
+ return { account, user };
}
-async function accountLoader(accountId: string) {
+async function accountLoader(userId: string) {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
- return api.getAccount(accountId);
+ return api.getPersonalAccountByUserId(userId);
}
diff --git a/app/home/(user)/_lib/server/load-user-workspace.ts b/app/home/(user)/_lib/server/load-user-workspace.ts
index d7ace83..c8d011d 100644
--- a/app/home/(user)/_lib/server/load-user-workspace.ts
+++ b/app/home/(user)/_lib/server/load-user-workspace.ts
@@ -28,20 +28,15 @@ async function workspaceLoader() {
const workspacePromise = api.getAccountWorkspace();
- // TODO!: remove before deploy to prod
- const tempAccountsPromise = () => api.loadTempUserAccounts();
-
- const [accounts, workspace, user, tempVisibleAccounts] = await Promise.all([
+ const [accounts, workspace, user] = await Promise.all([
accountsPromise(),
workspacePromise,
requireUserInServerComponent(),
- tempAccountsPromise(),
]);
return {
accounts,
workspace,
user,
- tempVisibleAccounts,
};
}
diff --git a/app/home/(user)/settings/_components/account-settings-form.tsx b/app/home/(user)/settings/_components/account-settings-form.tsx
index 95db6cd..8513798 100644
--- a/app/home/(user)/settings/_components/account-settings-form.tsx
+++ b/app/home/(user)/settings/_components/account-settings-form.tsx
@@ -7,7 +7,6 @@ import { Trans } from 'react-i18next';
import { AccountWithParams } from '@kit/accounts/api';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { Button } from '@kit/ui/button';
-import { Card, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
@@ -25,7 +24,6 @@ import {
SelectValue,
} from '@kit/ui/select';
import { toast } from '@kit/ui/sonner';
-import { Switch } from '@kit/ui/switch';
import {
AccountSettings,
@@ -131,7 +129,11 @@ export default function AccountSettingsForm({
-
+
@@ -150,7 +152,11 @@ export default function AccountSettingsForm({
-
+
diff --git a/app/home/(user)/settings/_lib/account-settings.schema.ts b/app/home/(user)/settings/_lib/account-settings.schema.ts
index a6944a4..8c3cece 100644
--- a/app/home/(user)/settings/_lib/account-settings.schema.ts
+++ b/app/home/(user)/settings/_lib/account-settings.schema.ts
@@ -12,8 +12,8 @@ export const accountSettingsSchema = z.object({
email: z.email({ error: 'error:invalidEmail' }).nullable(),
phone: z.e164({ error: 'error:invalidPhone' }),
accountParams: z.object({
- height: z.coerce.number({ error: 'error:invalidNumber' }),
- weight: z.coerce.number({ error: 'error:invalidNumber' }),
+ height: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
+ weight: z.coerce.number({ error: 'error:invalidNumber' }).gt(0),
isSmoker: z.boolean().optional().nullable(),
}),
});
diff --git a/app/home/(user)/settings/page.tsx b/app/home/(user)/settings/page.tsx
index c1e81e1..bf65423 100644
--- a/app/home/(user)/settings/page.tsx
+++ b/app/home/(user)/settings/page.tsx
@@ -17,7 +17,7 @@ export const generateMetadata = async () => {
};
async function PersonalAccountSettingsPage() {
- const account = await loadCurrentUserAccount();
+ const { account } = await loadCurrentUserAccount();
return (
diff --git a/app/home/(user)/settings/preferences/page.tsx b/app/home/(user)/settings/preferences/page.tsx
index ec55fd6..4c0faeb 100644
--- a/app/home/(user)/settings/preferences/page.tsx
+++ b/app/home/(user)/settings/preferences/page.tsx
@@ -1,13 +1,9 @@
-import { CardTitle } from '@kit/ui/card';
-import { LanguageSelector } from '@kit/ui/language-selector';
-import { Trans } from '@kit/ui/trans';
-
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
import AccountPreferencesForm from '../_components/account-preferences-form';
import SettingsSectionHeader from '../_components/settings-section-header';
export default async function PreferencesPage() {
- const account = await loadCurrentUserAccount();
+ const { account } = await loadCurrentUserAccount();
return (
@@ -16,7 +12,6 @@ export default async function PreferencesPage() {
titleKey="account:preferencesTabLabel"
descriptionKey="account:preferencesTabDescription"
/>
-
diff --git a/app/home/[account]/_lib/server/load-team-account-health-details.ts b/app/home/[account]/_lib/server/load-team-account-health-details.ts
index 0c4ff72..1705770 100644
--- a/app/home/[account]/_lib/server/load-team-account-health-details.ts
+++ b/app/home/[account]/_lib/server/load-team-account-health-details.ts
@@ -31,11 +31,11 @@ export const getAccountHealthDetailsFields = (
>[],
members: Database['medreport']['Functions']['get_account_members']['Returns'],
): AccountHealthDetailsField[] => {
- const avarageWeight =
+ const averageWeight =
memberParams.reduce((sum, r) => sum + r.weight!, 0) / memberParams.length;
- const avarageHeight =
+ const averageHeight =
memberParams.reduce((sum, r) => sum + r.height!, 0) / memberParams.length;
- const avarageAge =
+ const averageAge =
members.reduce((sum, r) => {
const person = new Isikukood(r.personal_code);
return sum + person.getAge();
@@ -48,11 +48,11 @@ export const getAccountHealthDetailsFields = (
const person = new Isikukood(r.personal_code);
return person.getGender() === 'female';
}).length;
- const averageBMI = bmiFromMetric(avarageWeight, avarageHeight);
+ const averageBMI = bmiFromMetric(averageWeight, averageHeight);
const bmiStatus = getBmiStatus(bmiThresholds, {
- age: avarageAge,
- height: avarageHeight,
- weight: avarageWeight,
+ age: averageAge,
+ height: averageHeight,
+ weight: averageWeight,
});
const malePercentage = members.length
? (numberOfMaleMembers / members.length) * 100
@@ -76,7 +76,7 @@ export const getAccountHealthDetailsFields = (
},
{
title: 'teams:healthDetails.avgAge',
- value: avarageAge.toFixed(0),
+ value: averageAge.toFixed(0),
Icon: Clock,
iconBg: 'bg-success',
},
diff --git a/app/home/layout.tsx b/app/home/layout.tsx
index c483aec..088a68c 100644
--- a/app/home/layout.tsx
+++ b/app/home/layout.tsx
@@ -1,4 +1,3 @@
-import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
@@ -12,8 +11,7 @@ export default async function HomeLayout({
}) {
const client = getSupabaseServerClient();
- const user = await requireUserInServerComponent();
- const account = await loadCurrentUserAccount();
+ const { account, user } = await loadCurrentUserAccount();
const api = createAccountsApi(client);
const hasAccountTeamMembership = await api.hasAccountTeamMembership(
diff --git a/app/select-package/page.tsx b/app/select-package/page.tsx
index b45f213..532794f 100644
--- a/app/select-package/page.tsx
+++ b/app/select-package/page.tsx
@@ -1,4 +1,5 @@
import Link from 'next/link';
+import { redirect } from 'next/navigation';
import { CaretRightIcon } from '@radix-ui/react-icons';
import { Scale } from 'lucide-react';
@@ -27,6 +28,10 @@ async function SelectPackagePage() {
const { analysisPackageElements, analysisPackages, countryCode } =
await loadAnalysisPackages();
+ if (analysisPackageElements.length === 0) {
+ return redirect(pathsConfig.app.home);
+ }
+
return (
diff --git a/lib/actions/sign-out.tsx b/lib/actions/sign-out.tsx
index a168684..21bf5c0 100644
--- a/lib/actions/sign-out.tsx
+++ b/lib/actions/sign-out.tsx
@@ -3,9 +3,26 @@
import { redirect } from 'next/navigation';
import { createClient } from '@/utils/supabase/server';
+import { medusaLogout } from '@lib/data/customer';
export const signOutAction = async () => {
- const supabase = await createClient();
- await supabase.auth.signOut();
+ const client = await createClient();
+
+ try {
+ try {
+ await medusaLogout();
+ } catch (medusaError) {
+ console.warn('Medusa logout failed or not available:', medusaError);
+ }
+
+ const { error } = await client.auth.signOut();
+ if (error) {
+ throw error;
+ }
+ } catch (error) {
+ console.error('Logout error:', error);
+ throw error;
+ }
+
return redirect('/');
};
diff --git a/lib/constants.ts b/lib/constants.ts
index 7092724..93f3bca 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -1,2 +1,2 @@
export const DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
-export const DATE_FORMAT = "yyyy-mm-dd";
+export const DATE_FORMAT = "yyyy-MM-dd";
diff --git a/lib/i18n/i18n.settings.ts b/lib/i18n/i18n.settings.ts
index b970b7a..7bb07a8 100644
--- a/lib/i18n/i18n.settings.ts
+++ b/lib/i18n/i18n.settings.ts
@@ -37,6 +37,7 @@ export const defaultI18nNamespaces = [
'booking',
'order-analysis-package',
'order-analysis',
+ 'order-health-analysis',
'cart',
'orders',
'analysis-results',
diff --git a/lib/services/account.service.ts b/lib/services/account.service.ts
index f958c72..87eac94 100644
--- a/lib/services/account.service.ts
+++ b/lib/services/account.service.ts
@@ -1,6 +1,5 @@
import type { Tables } from '@/packages/supabase/src/database.types';
-import { AccountWithParams } from '@kit/accounts/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -26,6 +25,19 @@ export async function getAccount(id: string): Promise
{
return data as unknown as AccountWithMemberships;
}
+export async function getUserContactAdmin(userId: string) {
+ const { data } = await getSupabaseServerAdminClient()
+ .schema('medreport')
+ .from('accounts')
+ .select('name, last_name, email, preferred_locale')
+ .eq('primary_owner_user_id', userId)
+ .eq('is_personal_account', true)
+ .single()
+ .throwOnError();
+
+ return data;
+}
+
export async function getAccountAdmin({
primaryOwnerUserId,
}: {
diff --git a/lib/services/analyses.service.ts b/lib/services/analyses.service.ts
index 0127e09..790b201 100644
--- a/lib/services/analyses.service.ts
+++ b/lib/services/analyses.service.ts
@@ -2,7 +2,7 @@ import type { Tables } from '@/packages/supabase/src/database.types';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { IUuringElement } from "./medipost.types";
-type AnalysesWithGroupsAndElements = ({
+export type AnalysesWithGroupsAndElements = ({
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
@@ -105,7 +105,13 @@ export const createMedusaSyncSuccessEntry = async () => {
});
}
-export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise {
+export async function getAnalyses({
+ ids,
+ originalIds,
+}: {
+ ids?: number[];
+ originalIds?: string[];
+}): Promise {
const query = getSupabaseServerAdminClient()
.schema('medreport')
.from('analyses')
diff --git a/lib/services/audit/notificationEntries.service.ts b/lib/services/audit/notificationEntries.service.ts
index f83a736..980de2e 100644
--- a/lib/services/audit/notificationEntries.service.ts
+++ b/lib/services/audit/notificationEntries.service.ts
@@ -2,9 +2,12 @@ import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export enum NotificationAction {
- DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
- NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
- PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
+ DOCTOR_NEW_JOBS = 'DOCTOR_NEW_JOBS',
+ DOCTOR_PATIENT_RESULTS_RECEIVED = 'DOCTOR_PATIENT_RESULTS_RECEIVED',
+ PATIENT_DOCTOR_FEEDBACK_RECEIVED = 'PATIENT_DOCTOR_FEEDBACK_RECEIVED',
+ PATIENT_ORDER_PROCESSING = 'PATIENT_ORDER_PROCESSING',
+ PATIENT_FIRST_RESULTS_RECEIVED = 'PATIENT_FIRST_RESULTS_RECEIVED',
+ PATIENT_FULL_RESULTS_RECEIVED = 'PATIENT_FULL_RESULTS_RECEIVED',
}
export const createNotificationLog = async ({
diff --git a/lib/services/audit/pageView.service.ts b/lib/services/audit/pageView.service.ts
index efac5db..aa06aec 100644
--- a/lib/services/audit/pageView.service.ts
+++ b/lib/services/audit/pageView.service.ts
@@ -37,7 +37,6 @@ export const createPageViewLog = async ({
account_id: accountId,
action,
changed_by: user.id,
- extra_data: extraData,
})
.throwOnError();
} catch (error) {
diff --git a/lib/services/mailer.service.ts b/lib/services/mailer.service.ts
index b4a7ecc..8e2a7ea 100644
--- a/lib/services/mailer.service.ts
+++ b/lib/services/mailer.service.ts
@@ -13,7 +13,7 @@ type EmailTemplate = {
subject: string;
};
-type EmailRenderer = (params: T) => Promise;
+export type EmailRenderer = (params: T) => Promise;
export const sendEmailFromTemplate = async (
renderer: EmailRenderer,
diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts
index b6aec5d..6fb4302 100644
--- a/lib/services/medipost.service.ts
+++ b/lib/services/medipost.service.ts
@@ -5,23 +5,11 @@ import {
createClient as createCustomClient,
} from '@supabase/supabase-js';
-import {
- getAnalysisGroup,
- getClientInstitution,
- getClientPerson,
- getConfidentiality,
- getOrderEnteredPerson,
- getPais,
- getPatient,
- getProviderInstitution,
- getSpecimen,
-} from '@/lib/templates/medipost-order';
import { SyncStatus } from '@/lib/types/audit';
import {
AnalysisOrderStatus,
GetMessageListResponse,
IMedipostResponseXMLBase,
- MaterjalideGrupp,
MedipostAction,
MedipostOrderResponse,
MedipostPublicMessageResponse,
@@ -32,12 +20,11 @@ import {
import { toArray } from '@/lib/utils';
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
-import { uniqBy } from 'lodash';
import { Tables } from '@kit/supabase/database';
import { createAnalysisGroup } from './analysis-group.service';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
-import { getOrder, updateOrderStatus } from './order.service';
+import { getAnalysisOrder, updateAnalysisOrderStatus } from './order.service';
import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { getAnalyses } from './analyses.service';
import { getAccountAdmin } from './account.service';
@@ -47,6 +34,7 @@ import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import { MedipostValidationError } from './medipost/MedipostValidationError';
import { logMedipostDispatch } from './audit.service';
+import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service';
const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!;
@@ -206,12 +194,13 @@ export async function readPrivateMessageResponse({
excludedMessageIds,
}: {
excludedMessageIds: string[];
-}): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined }> {
+}): Promise<{ messageId: string | null; hasAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean; hasFullAnalysisResponse: boolean; medusaOrderId: string | undefined; analysisOrderId: number | undefined }> {
let messageId: string | null = null;
let hasAnalysisResponse = false;
let hasPartialAnalysisResponse = false;
let hasFullAnalysisResponse = false;
let medusaOrderId: string | undefined = undefined;
+ let analysisOrderId: number | undefined = undefined;
try {
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
@@ -224,6 +213,7 @@ export async function readPrivateMessageResponse({
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
+ analysisOrderId: undefined
};
}
@@ -232,16 +222,15 @@ export async function readPrivateMessageResponse({
);
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
- medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
+ analysisOrderId = Number(privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId);
- const hasInvalidOrderId = !medusaOrderId || !medusaOrderId.toString().startsWith('order_');
+ const hasInvalidOrderId = isNaN(analysisOrderId)
if (hasInvalidOrderId || !messageResponse) {
await createMedipostActionLog({
action: 'sync_analysis_results_from_medipost',
xml: privateMessageXml,
hasAnalysisResults: false,
- medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
});
return {
messageId,
@@ -249,12 +238,16 @@ export async function readPrivateMessageResponse({
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
+ analysisOrderId: hasInvalidOrderId ? undefined : analysisOrderId
};
}
+ const analysisOrder = await getAnalysisOrder({ analysisOrderId: analysisOrderId })
+ medusaOrderId = analysisOrder.medusa_order_id;
+
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
try {
- order = await getOrder({ medusaOrderId });
+ order = await getAnalysisOrder({ medusaOrderId });
} catch (e) {
await deletePrivateMessage(privateMessage.messageId);
throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`);
@@ -263,11 +256,11 @@ export async function readPrivateMessageResponse({
const status = await syncPrivateMessage({ messageResponse, order });
if (status.isPartial) {
- await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
+ await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
hasAnalysisResponse = true;
hasPartialAnalysisResponse = true;
} else if (status.isCompleted) {
- await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
+ await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' });
await deletePrivateMessage(privateMessage.messageId);
hasAnalysisResponse = true;
hasFullAnalysisResponse = true;
@@ -276,7 +269,7 @@ export async function readPrivateMessageResponse({
console.warn(`Failed to process private message id=${messageId}, message=${(e as Error).message}`);
}
- return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId };
+ return { messageId, hasAnalysisResponse, hasPartialAnalysisResponse, hasFullAnalysisResponse, medusaOrderId, analysisOrderId };
}
async function saveAnalysisGroup(
@@ -451,122 +444,6 @@ export async function syncPublicMessage(
}
}
-export async function composeOrderXML({
- person,
- orderedAnalysisElementsIds,
- orderedAnalysesIds,
- orderId,
- orderCreatedAt,
- comment,
-}: {
- person: {
- idCode: string;
- firstName: string;
- lastName: string;
- phone: string;
- };
- orderedAnalysisElementsIds: number[];
- orderedAnalysesIds: number[];
- orderId: string;
- orderCreatedAt: Date;
- comment?: string;
-}) {
- const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
- if (analysisElements.length !== orderedAnalysisElementsIds.length) {
- throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`);
- }
-
- const analyses = await getAnalyses({ ids: orderedAnalysesIds });
- if (analyses.length !== orderedAnalysesIds.length) {
- throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
- }
-
- const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
- uniqBy(
- (
- analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
- []
- ).concat(
- analyses?.flatMap(
- ({ analysis_elements }) => analysis_elements.analysis_groups,
- ) ?? [],
- ),
- 'id',
- );
-
- const specimenSection = [];
- const analysisSection = [];
- let order = 1;
- for (const currentGroup of analysisGroups) {
- let relatedAnalysisElement = analysisElements?.find(
- (element) => element.analysis_groups.id === currentGroup.id,
- );
- const relatedAnalyses = analyses?.filter((analysis) => {
- return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
- });
-
- if (!relatedAnalysisElement) {
- relatedAnalysisElement = relatedAnalyses?.find(
- (relatedAnalysis) =>
- relatedAnalysis.analysis_elements.analysis_groups.id ===
- currentGroup.id,
- )?.analysis_elements;
- }
-
- if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
- throw new Error(
- `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
- );
- }
-
- for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
- const materials = toArray(group.Materjal);
- const specimenXml = materials.flatMap(
- ({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => {
- return toArray(Konteiner).map((container) =>
- getSpecimen(
- MaterjaliTyypOID,
- MaterjaliTyyp,
- MaterjaliNimi,
- order,
- container.ProovinouKoodOID,
- container.ProovinouKood,
- ),
- );
- },
- );
-
- specimenSection.push(...specimenXml);
- }
-
- const groupXml = getAnalysisGroup(
- currentGroup.original_id,
- currentGroup.name,
- order,
- relatedAnalysisElement,
- );
- order++;
- analysisSection.push(groupXml);
- }
-
- return `
-
- ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
-
- ${orderId}
- ${getClientInstitution()}
- ${getProviderInstitution()}
- ${getClientPerson()}
- ${getOrderEnteredPerson()}
- ${comment ?? ''}
- ${getPatient(person)}
- ${getConfidentiality()}
- ${specimenSection.join('')}
- ${analysisSection?.join('')}
-
-`;
-}
-
function getLatestMessage({
messages,
excludedMessageIds,
@@ -694,7 +571,7 @@ async function syncPrivateMessage({
);
}
- const { data: allOrderResponseElements} = await supabase
+ const { data: allOrderResponseElements } = await supabase
.schema('medreport')
.from('analysis_response_elements')
.select('*')
@@ -714,21 +591,37 @@ export async function sendOrderToMedipost({
orderedAnalysisElements,
}: {
medusaOrderId: string;
- orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
+ orderedAnalysisElements: OrderedAnalysisElement[];
}) {
- const medreportOrder = await getOrder({ medusaOrderId });
+ const medreportOrder = await getAnalysisOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
+ const orderedAnalysesIds = orderedAnalysisElements
+ .map(({ analysisId }) => analysisId)
+ .filter(Boolean) as number[];
+ const orderedAnalysisElementsIds = orderedAnalysisElements
+ .map(({ analysisElementId }) => analysisElementId)
+ .filter(Boolean) as number[];
+
+ const analyses = await getAnalyses({ ids: orderedAnalysesIds });
+ if (analyses.length !== orderedAnalysesIds.length) {
+ throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
+ }
+ const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
+ if (analysisElements.length !== orderedAnalysisElementsIds.length) {
+ throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`);
+ }
+
const orderXml = await composeOrderXML({
+ analyses,
+ analysisElements,
person: {
idCode: account.personal_code!,
firstName: account.name ?? '',
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
- orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
- orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
- orderId: medusaOrderId,
+ orderId: medreportOrder.id,
orderCreatedAt: new Date(medreportOrder.created_at),
comment: '',
});
@@ -780,7 +673,7 @@ export async function sendOrderToMedipost({
hasAnalysisResults: false,
medusaOrderId,
});
- await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
+ await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
}
export async function getOrderedAnalysisIds({
@@ -826,7 +719,12 @@ export async function getOrderedAnalysisIds({
throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`);
}
- const ids = getAnalysisElementMedusaProductIds(orderedPackagesProducts);
+ const ids = getAnalysisElementMedusaProductIds(
+ orderedPackagesProducts.map(({ id, metadata }) => ({
+ metadata,
+ variant: orderedPackages.find(({ product }) => product?.id === id)?.variant,
+ })),
+ );
if (ids.length === 0) {
return [];
}
@@ -867,10 +765,10 @@ export async function createMedipostActionLog({
hasError = false,
}: {
action:
- | 'send_order_to_medipost'
- | 'sync_analysis_results_from_medipost'
- | 'send_fake_analysis_results_to_medipost'
- | 'send_analysis_results_to_medipost';
+ | 'send_order_to_medipost'
+ | 'sync_analysis_results_from_medipost'
+ | 'send_fake_analysis_results_to_medipost'
+ | 'send_analysis_results_to_medipost';
xml: string;
hasAnalysisResults?: boolean;
medusaOrderId?: string | null;
diff --git a/lib/services/medipostTest.service.ts b/lib/services/medipostTest.service.ts
index f4393f7..4b1d1ca 100644
--- a/lib/services/medipostTest.service.ts
+++ b/lib/services/medipostTest.service.ts
@@ -68,7 +68,7 @@ export async function composeOrderTestResponseXML({
};
orderedAnalysisElementsIds: number[];
orderedAnalysesIds: number[];
- orderId: string;
+ orderId: number;
orderCreatedAt: Date;
}) {
const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds });
@@ -100,7 +100,7 @@ export async function composeOrderTestResponseXML({
return `
- ${getPais(USER, RECIPIENT, orderCreatedAt, orderId, "AL")}
+ ${getPais(USER, RECIPIENT, orderId, "AL")}
${orderId}
${getClientInstitution({ index: 1 })}
diff --git a/lib/services/medipostXML.service.ts b/lib/services/medipostXML.service.ts
new file mode 100644
index 0000000..2b457d5
--- /dev/null
+++ b/lib/services/medipostXML.service.ts
@@ -0,0 +1,201 @@
+'use server';
+
+import {
+ getAnalysisGroup,
+ getClientInstitution,
+ getClientPerson,
+ getConfidentiality,
+ getOrderEnteredPerson,
+ getPais,
+ getPatient,
+ getProviderInstitution,
+ getSpecimen,
+} from '@/lib/templates/medipost-order';
+import {
+ MaterjalideGrupp,
+} from '@/lib/types/medipost';
+import { toArray } from '@/lib/utils';
+import { uniqBy } from 'lodash';
+
+import { Tables } from '@kit/supabase/database';
+import { AnalysisElement } from './analysis-element.service';
+import { AnalysesWithGroupsAndElements } from './analyses.service';
+
+const USER = process.env.MEDIPOST_USER!;
+const RECIPIENT = process.env.MEDIPOST_RECIPIENT!;
+
+export type OrderedAnalysisElement = {
+ analysisElementId?: number;
+ analysisId?: number;
+}
+
+export async function composeOrderXML({
+ analyses,
+ analysisElements,
+ person,
+ orderId,
+ orderCreatedAt,
+ comment,
+}: {
+ analyses: AnalysesWithGroupsAndElements;
+ analysisElements: AnalysisElement[];
+ person: {
+ idCode: string;
+ firstName: string;
+ lastName: string;
+ phone: string;
+ };
+ orderId: number;
+ orderCreatedAt: Date;
+ comment?: string;
+}) {
+ const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
+ uniqBy(
+ (
+ analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
+ []
+ ).concat(
+ analyses?.flatMap(
+ ({ analysis_elements }) => analysis_elements.analysis_groups,
+ ) ?? [],
+ ),
+ 'id',
+ );
+
+ // First, collect all unique materials across all analysis groups
+ const uniqueMaterials = new Map();
+
+ let specimenOrder = 1;
+
+ // Collect all materials from all analysis groups
+ for (const currentGroup of analysisGroups) {
+ let relatedAnalysisElement = analysisElements?.find(
+ (element) => element.analysis_groups.id === currentGroup.id,
+ );
+ const relatedAnalyses = analyses?.filter((analysis) => {
+ return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
+ });
+
+ if (!relatedAnalysisElement) {
+ relatedAnalysisElement = relatedAnalyses?.find(
+ (relatedAnalysis) =>
+ relatedAnalysis.analysis_elements.analysis_groups.id ===
+ currentGroup.id,
+ )?.analysis_elements;
+ }
+
+ if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
+ throw new Error(
+ `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
+ );
+ }
+
+ for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
+ const materials = toArray(group.Materjal);
+ for (const material of materials) {
+ const { MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner } = material;
+ const containers = toArray(Konteiner);
+
+ for (const container of containers) {
+ // Use MaterialTyyp as the key for deduplication
+ const materialKey = MaterjaliTyyp;
+
+ if (!uniqueMaterials.has(materialKey)) {
+ uniqueMaterials.set(materialKey, {
+ MaterjaliTyypOID,
+ MaterjaliTyyp,
+ MaterjaliNimi,
+ ProovinouKoodOID: container.ProovinouKoodOID,
+ ProovinouKood: container.ProovinouKood,
+ order: specimenOrder++,
+ });
+ }
+ }
+ }
+ }
+ }
+
+ // Generate specimen section from unique materials
+ const specimenSection = Array.from(uniqueMaterials.values()).map(material =>
+ getSpecimen(
+ material.MaterjaliTyypOID,
+ material.MaterjaliTyyp,
+ material.MaterjaliNimi,
+ material.order,
+ material.ProovinouKoodOID,
+ material.ProovinouKood,
+ )
+ );
+
+ // Generate analysis section with correct specimen references
+ const analysisSection = [];
+ for (const currentGroup of analysisGroups) {
+ let relatedAnalysisElement = analysisElements?.find(
+ (element) => element.analysis_groups.id === currentGroup.id,
+ );
+ const relatedAnalyses = analyses?.filter((analysis) => {
+ return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
+ });
+
+ if (!relatedAnalysisElement) {
+ relatedAnalysisElement = relatedAnalyses?.find(
+ (relatedAnalysis) =>
+ relatedAnalysis.analysis_elements.analysis_groups.id ===
+ currentGroup.id,
+ )?.analysis_elements;
+ }
+
+ if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
+ throw new Error(
+ `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
+ );
+ }
+
+ // Find the specimen order number for this analysis group
+ let specimenOrderNumber = 1;
+ for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
+ const materials = toArray(group.Materjal);
+ for (const material of materials) {
+ const materialKey = material.MaterjaliTyyp;
+ const uniqueMaterial = uniqueMaterials.get(materialKey);
+ if (uniqueMaterial) {
+ specimenOrderNumber = uniqueMaterial.order;
+ break; // Use the first material's order number
+ }
+ }
+ if (specimenOrderNumber > 1) break; // Found a specimen, use it
+ }
+
+ const groupXml = getAnalysisGroup(
+ currentGroup.original_id,
+ currentGroup.name,
+ specimenOrderNumber,
+ relatedAnalysisElement,
+ );
+ analysisSection.push(groupXml);
+ }
+
+ return `
+
+ ${getPais(USER, RECIPIENT, orderId)}
+
+ ${orderId}
+ ${getClientInstitution()}
+ ${getProviderInstitution()}
+ ${getClientPerson()}
+ ${getOrderEnteredPerson()}
+ ${comment ?? ''}
+ ${getPatient(person)}
+ ${getConfidentiality()}
+ ${specimenSection.join('')}
+ ${analysisSection?.join('')}
+
+`;
+}
diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts
index a416e04..c33ed0d 100644
--- a/lib/services/medusaCart.service.ts
+++ b/lib/services/medusaCart.service.ts
@@ -38,8 +38,7 @@ export async function handleAddToCart({
countryCode: string;
}) {
const supabase = getSupabaseServerClient();
- const user = await requireUserInServerComponent();
- const account = await loadCurrentUserAccount();
+ const { account, user } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
@@ -70,8 +69,7 @@ export async function handleDeleteCartItem({ lineId }: { lineId: string }) {
const supabase = getSupabaseServerClient();
const cartId = await getCartId();
- const user = await requireUserInServerComponent();
- const account = await loadCurrentUserAccount();
+ const { account, user } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
@@ -96,8 +94,7 @@ export async function handleNavigateToPayment({
paymentSessionId: string;
}) {
const supabase = getSupabaseServerClient();
- const user = await requireUserInServerComponent();
- const account = await loadCurrentUserAccount();
+ const { account, user } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
@@ -137,8 +134,7 @@ export async function handleLineItemTimeout({
lineItem: StoreCartLineItem;
}) {
const supabase = getSupabaseServerClient();
- const user = await requireUserInServerComponent();
- const account = await loadCurrentUserAccount();
+ const { account, user } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts
index 487153a..eced8f9 100644
--- a/lib/services/order.service.ts
+++ b/lib/services/order.service.ts
@@ -5,7 +5,7 @@ import type { StoreOrder } from '@medusajs/types';
export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>;
-export async function createOrder({
+export async function createAnalysisOrder({
medusaOrder,
orderedAnalysisElements,
}: {
@@ -38,7 +38,7 @@ export async function createOrder({
return orderResult.data.id;
}
-export async function updateOrder({
+export async function updateAnalysisOrder({
orderId,
orderStatus,
}: {
@@ -56,7 +56,7 @@ export async function updateOrder({
.throwOnError();
}
-export async function updateOrderStatus({
+export async function updateAnalysisOrderStatus({
orderId,
medusaOrderId,
orderStatus,
@@ -80,12 +80,12 @@ export async function updateOrderStatus({
.throwOnError();
}
-export async function getOrder({
+export async function getAnalysisOrder({
medusaOrderId,
- orderId,
+ analysisOrderId,
}: {
medusaOrderId?: string;
- orderId?: number;
+ analysisOrderId?: number;
}) {
const query = getSupabaseServerAdminClient()
.schema('medreport')
@@ -93,15 +93,15 @@ export async function getOrder({
.select('*')
if (medusaOrderId) {
query.eq('medusa_order_id', medusaOrderId);
- } else if (orderId) {
- query.eq('id', orderId);
+ } else if (analysisOrderId) {
+ query.eq('id', analysisOrderId);
} else {
throw new Error('Either medusaOrderId or orderId must be provided');
}
const { data: order, error } = await query.single();
if (error) {
- throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or orderId=${orderId}, message=${error.message}, data=${JSON.stringify(order)}`);
+ throw new Error(`Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`);
}
return order;
}
diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts
index 10ce573..f6da882 100644
--- a/lib/templates/medipost-order.ts
+++ b/lib/templates/medipost-order.ts
@@ -1,15 +1,14 @@
import { format } from 'date-fns';
-import Isikukood, { Gender } from 'isikukood';
import { Tables } from '@/packages/supabase/src/database.types';
import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants';
+import PersonalCode from '../utils';
const isProd = process.env.NODE_ENV === 'production';
export const getPais = (
sender: string,
recipient: string,
- createdAt: Date,
- orderId: string,
+ orderId: number,
packageName = "OL",
) => {
if (isProd) {
@@ -19,7 +18,7 @@ export const getPais = (
${packageName}
${sender}
${recipient}
- ${format(createdAt, DATE_TIME_FORMAT)}
+ ${format(new Date(), DATE_TIME_FORMAT)}
${orderId}
info@medreport.ee
`;
@@ -73,15 +72,15 @@ export const getPatient = ({
lastName: string,
firstName: string,
}) => {
- const isikukood = new Isikukood(idCode);
+ const { dob, gender } = PersonalCode.parsePersonalCode(idCode);
return `
1.3.6.1.4.1.28284.6.2.2.1
${idCode}
${lastName}
${firstName}
- ${format(isikukood.getBirthday(), DATE_FORMAT)}
+ ${format(dob, DATE_FORMAT)}
1.3.6.1.4.1.28284.6.2.3.16.2
- ${isikukood.getGender() === Gender.MALE ? 'M' : 'N'}
+ ${gender.value === 'M' ? 'M' : 'N'}
`;
};
diff --git a/lib/utils.ts b/lib/utils.ts
index 3448c2b..233042a 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -15,11 +15,12 @@ export function toArray(input?: T | T[] | null): T[] {
}
export function toTitleCase(str?: string) {
- if (!str) return '';
- return str.replace(
- /\w\S*/g,
- (text: string) =>
- text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
+ return (
+ str
+ ?.toLowerCase()
+ .replace(/[^-'’\s]+/g, (match) =>
+ match.replace(/^./, (first) => first.toUpperCase()),
+ ) ?? ""
);
}
@@ -40,8 +41,12 @@ export function sortByDate(
export const bmiFromMetric = (kg: number, cm: number) => {
const m = cm / 100;
- const bmi = kg / (m * m);
- return bmi ? Math.round(bmi) : NaN;
+ const m2 = m * m;
+ if (m2 === 0) {
+ return null;
+ }
+ const bmi = kg / m2;
+ return !Number.isNaN(bmi) ? Math.round(bmi) : null;
};
export function getBmiStatus(
@@ -58,7 +63,9 @@ export function getBmiStatus(
) || null;
const bmi = bmiFromMetric(params.weight, params.height);
- if (!thresholdByAge || Number.isNaN(bmi)) return null;
+ if (!thresholdByAge || bmi === null) {
+ return null;
+ }
if (bmi > thresholdByAge.obesity_min) return BmiCategory.OBESE;
if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT;
@@ -83,9 +90,61 @@ export function getBmiBackgroundColor(bmiStatus: BmiCategory | null): string {
}
}
-export function getGenderStringFromPersonalCode(personalCode: string) {
- const person = new Isikukood(personalCode);
- if (person.getGender() === Gender.FEMALE) return 'common:female';
- if (person.getGender() === Gender.MALE) return 'common:male';
- return 'common:unknown';
+type AgeRange = '18-29' | '30-39' | '40-49' | '50-59' | '60';
+export default class PersonalCode {
+ static getPersonalCode(personalCode: string | null) {
+ if (!personalCode) {
+ return null;
+ }
+ if (personalCode.toLowerCase().startsWith('ee')) {
+ return personalCode.substring(2);
+ }
+ return personalCode;
+ }
+
+ static parsePersonalCode(personalCode: string): {
+ ageRange: AgeRange;
+ gender: { label: string; value: string };
+ dob: Date;
+ age: number;
+ } {
+ const parsed = new Isikukood(personalCode);
+ const ageRange = (() => {
+ const age = parsed.getAge();
+ if (age >= 18 && age <= 29) {
+ return '18-29';
+ }
+ if (age >= 30 && age <= 39) {
+ return '30-39';
+ }
+ if (age >= 40 && age <= 49) {
+ return '40-49';
+ }
+ if (age >= 50 && age <= 59) {
+ return '50-59';
+ }
+ if (age >= 60) {
+ return '60';
+ }
+ throw new Error('Age range not supported, age=' + age);
+ })();
+ const gender = (() => {
+ const gender = parsed.getGender();
+ switch (gender) {
+ case Gender.FEMALE:
+ return { label: 'common:female', value: 'F' };
+ case Gender.MALE:
+ return { label: 'common:male', value: 'M' };
+ default:
+ throw new Error('Gender not supported');
+ }
+ })();
+
+ return {
+ ageRange,
+ gender,
+ dob: parsed.getBirthday(),
+ age: parsed.getAge(),
+ }
+ }
}
diff --git a/middleware.ts b/middleware.ts
index 1507e71..b000a1c 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -27,6 +27,8 @@ const getUser = (request: NextRequest, response: NextResponse) => {
export async function middleware(request: NextRequest) {
const secureHeaders = await createResponseWithSecureHeaders();
const response = NextResponse.next(secureHeaders);
+ const url = new URL(request.url);
+ const lang = url.searchParams.get('lang');
// set a unique request ID for each request
// this helps us log and trace requests
@@ -35,6 +37,10 @@ export async function middleware(request: NextRequest) {
// apply CSRF protection for mutating requests
const csrfResponse = await withCsrfMiddleware(request, response);
+ if (lang) {
+ csrfResponse.cookies.set('lang', lang);
+ }
+
// handle patterns for specific routes
const handlePattern = matchUrlPattern(request.url);
@@ -176,6 +182,14 @@ function getPatterns() {
return NextResponse.redirect(
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
);
+ } else if (
+ !['test', 'localhost'].some((pathString) =>
+ process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString),
+ )
+ ) {
+ return NextResponse.redirect(
+ new URL('https://medreport.ee', req.nextUrl.origin).href,
+ );
}
},
},
diff --git a/package.json b/package.json
index e449db7..33ec639 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"fast-xml-parser": "^5.2.5",
"isikukood": "3.1.7",
"jsonwebtoken": "9.0.2",
+ "libphonenumber-js": "^1.12.15",
"lodash": "^4.17.21",
"lucide-react": "^0.510.0",
"next": "15.3.2",
@@ -101,7 +102,7 @@
"babel-plugin-react-compiler": "19.1.0-rc.2",
"cssnano": "^7.0.7",
"dotenv": "^16.5.0",
- "pino-pretty": "^13.0.0",
+ "pino-pretty": "13.0.0",
"prettier": "^3.5.3",
"supabase": "^2.30.4",
"tailwindcss": "4.1.7",
diff --git a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts
index 5abe3de..9299ae1 100644
--- a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts
+++ b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts
@@ -1,20 +1,7 @@
import { SupabaseClient } from '@supabase/supabase-js';
-import {
- renderAllResultsReceivedEmail,
- renderFirstResultsReceivedEmail,
-} from '@kit/email-templates';
import { Database } from '@kit/supabase/database';
-import {
- getAssignedDoctorAccount,
- getDoctorAccounts,
-} from '../../../../../lib/services/account.service';
-import {
- NotificationAction,
- createNotificationLog,
-} from '../../../../../lib/services/audit/notificationEntries.service';
-import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service';
import { RecordChange, Tables } from '../record-change.type';
export function createDatabaseWebhookRouterService(
@@ -113,58 +100,13 @@ class DatabaseWebhookRouterService {
return;
}
- let action;
- try {
- const data = {
- analysisOrderId: record.id,
- language: 'et',
- };
+ const { createAnalysisOrderWebhooksService } = await import(
+ '@kit/notifications/webhooks/analysis-order-notifications.service'
+ );
- if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
- action = NotificationAction.NEW_JOBS_ALERT;
+ const service = createAnalysisOrderWebhooksService();
- const doctorAccounts = await getDoctorAccounts();
- const doctorEmails: string[] = doctorAccounts
- .map(({ email }) => email)
- .filter((email): email is string => !!email);
-
- await sendEmailFromTemplate(
- renderFirstResultsReceivedEmail,
- data,
- doctorEmails,
- );
- } else if (record.status === 'FULL_ANALYSIS_RESPONSE') {
- action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT;
- const doctorAccount = await getAssignedDoctorAccount(record.id);
- const assignedDoctorEmail = doctorAccount?.email;
-
- if (!assignedDoctorEmail) {
- return;
- }
-
- await sendEmailFromTemplate(
- renderAllResultsReceivedEmail,
- data,
- assignedDoctorEmail,
- );
- }
-
- if (action) {
- await createNotificationLog({
- action,
- status: 'SUCCESS',
- relatedRecordId: record.id,
- });
- }
- } catch (e: any) {
- if (action)
- await createNotificationLog({
- action,
- status: 'FAIL',
- comment: e?.message,
- relatedRecordId: record.id,
- });
- }
+ return service.handleStatusChangeWebhook(record);
}
}
}
diff --git a/packages/email-templates/src/emails/account-delete.email.tsx b/packages/email-templates/src/emails/account-delete.email.tsx
index 78fea85..a98c682 100644
--- a/packages/email-templates/src/emails/account-delete.email.tsx
+++ b/packages/email-templates/src/emails/account-delete.email.tsx
@@ -49,33 +49,28 @@ export async function renderAccountDeleteEmail(props: Props) {
-
- {previewText}
-
-
+
+ {previewText}
+
{t(`${namespace}:hello`, {
displayName: props.userDisplayName,
})}
-
{t(`${namespace}:paragraph1`, {
productName: props.productName,
})}
-
{t(`${namespace}:paragraph2`)}
-
{t(`${namespace}:paragraph3`, {
productName: props.productName,
})}
-
{t(`${namespace}:paragraph4`, {
productName: props.productName,
diff --git a/packages/email-templates/src/emails/all-results-received.email.tsx b/packages/email-templates/src/emails/all-results-received.email.tsx
index 0243fc4..0083376 100644
--- a/packages/email-templates/src/emails/all-results-received.email.tsx
+++ b/packages/email-templates/src/emails/all-results-received.email.tsx
@@ -5,7 +5,7 @@ import {
Preview,
Tailwind,
Text,
- render
+ render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
@@ -46,11 +46,10 @@ export async function renderAllResultsReceivedEmail({
-
- {previewText}
-
-
+
+ {previewText}
+
{t(`${namespace}:hello`)}
@@ -62,7 +61,6 @@ export async function renderAllResultsReceivedEmail({
>
{t(`${namespace}:linkText`)}
-
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
diff --git a/packages/email-templates/src/emails/company-offer.email.tsx b/packages/email-templates/src/emails/company-offer.email.tsx
index f13308c..68a5adc 100644
--- a/packages/email-templates/src/emails/company-offer.email.tsx
+++ b/packages/email-templates/src/emails/company-offer.email.tsx
@@ -55,23 +55,19 @@ export async function renderCompanyOfferEmail({
-
- {previewText}
-
-
+
+ {previewText}
+
{t(`${namespace}:companyName`)} {companyData.companyName}
-
{t(`${namespace}:contactPerson`)} {companyData.contactPerson}
-
{t(`${namespace}:email`)} {companyData.email}
-
{t(`${namespace}:phone`)} {companyData.phone || 'N/A'}
diff --git a/packages/email-templates/src/emails/doctor-summary-received.email.tsx b/packages/email-templates/src/emails/doctor-summary-received.email.tsx
index 69ce37e..19b2b65 100644
--- a/packages/email-templates/src/emails/doctor-summary-received.email.tsx
+++ b/packages/email-templates/src/emails/doctor-summary-received.email.tsx
@@ -2,6 +2,7 @@ import {
Body,
Head,
Html,
+ Link,
Preview,
Tailwind,
Text,
@@ -11,7 +12,6 @@ import {
import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content';
-import { EmailButton } from '../components/email-button';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
@@ -20,12 +20,10 @@ import { initializeEmailI18n } from '../lib/i18n';
export async function renderDoctorSummaryReceivedEmail({
language,
recipientName,
- orderNr,
analysisOrderId,
}: {
- language?: string;
+ language: string;
recipientName: string;
- orderNr: string;
analysisOrderId: number;
}) {
const namespace = 'doctor-summary-received-email';
@@ -35,13 +33,9 @@ export async function renderDoctorSummaryReceivedEmail({
namespace: [namespace, 'common'],
});
- const previewText = t(`${namespace}:previewText`, {
- orderNr,
- });
+ const previewText = t(`${namespace}:previewText`);
- const subject = t(`${namespace}:subject`, {
- orderNr,
- });
+ const subject = t(`${namespace}:subject`);
const html = await render(
@@ -54,29 +48,26 @@ export async function renderDoctorSummaryReceivedEmail({
-
- {previewText}
-
-
+
+ {previewText}
+
- {t(`${namespace}:hello`, {
- displayName: recipientName,
- })}
-
-
- {t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
+ {t(`common:helloName`, { name: recipientName })}
-
- {t(`${namespace}:linkText`, { orderNr })}
-
- {t(`${namespace}:ifButtonDisabled`)}{' '}
- {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
+ {t(`${namespace}:p1`)}{' '}
+
+ {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
+
+ {t(`${namespace}:p2`)}
+ {t(`${namespace}:p3`)}
+ {t(`${namespace}:p4`)}
+
diff --git a/packages/email-templates/src/emails/first-results-received.email.tsx b/packages/email-templates/src/emails/first-results-received.email.tsx
index 4f9f371..40ba596 100644
--- a/packages/email-templates/src/emails/first-results-received.email.tsx
+++ b/packages/email-templates/src/emails/first-results-received.email.tsx
@@ -46,11 +46,10 @@ export async function renderFirstResultsReceivedEmail({
-
- {previewText}
-
-
+
+ {previewText}
+
{t(`${namespace}:hello`)}
diff --git a/packages/email-templates/src/emails/invite.email.tsx b/packages/email-templates/src/emails/invite.email.tsx
index e59ba72..cd91424 100644
--- a/packages/email-templates/src/emails/invite.email.tsx
+++ b/packages/email-templates/src/emails/invite.email.tsx
@@ -74,20 +74,17 @@ export async function renderInviteEmail(props: Props) {
-
- {heading}
-
-
+
+ {heading}
+
{hello}
-
-
{props.teamLogo && (
@@ -102,20 +99,16 @@ export async function renderInviteEmail(props: Props) {
)}
-
-
+
-
{t(`${namespace}:copyPasteLink`)}{' '}
{props.link}
-
-
{t(`${namespace}:invitationIntendedFor`, {
invitedUserEmail: props.invitedUserEmail,
diff --git a/packages/email-templates/src/emails/new-jobs-available.email.tsx b/packages/email-templates/src/emails/new-jobs-available.email.tsx
index 23ca3f4..34fb7d9 100644
--- a/packages/email-templates/src/emails/new-jobs-available.email.tsx
+++ b/packages/email-templates/src/emails/new-jobs-available.email.tsx
@@ -6,7 +6,7 @@ import {
Preview,
Tailwind,
Text,
- render
+ render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
@@ -50,11 +50,10 @@ export async function renderNewJobsAvailableEmail({
-
- {previewText}
-
-
+
+ {previewText}
+
{t(`${namespace}:hello`)}
diff --git a/packages/email-templates/src/emails/order-processing.email.tsx b/packages/email-templates/src/emails/order-processing.email.tsx
new file mode 100644
index 0000000..8a7afb0
--- /dev/null
+++ b/packages/email-templates/src/emails/order-processing.email.tsx
@@ -0,0 +1,90 @@
+import {
+ Body,
+ Head,
+ Html,
+ Preview,
+ Tailwind,
+ Text,
+ render,
+} from '@react-email/components';
+
+import { BodyStyle } from '../components/body-style';
+import CommonFooter from '../components/common-footer';
+import { EmailContent } from '../components/content';
+import { EmailHeader } from '../components/header';
+import { EmailHeading } from '../components/heading';
+import { EmailWrapper } from '../components/wrapper';
+import { initializeEmailI18n } from '../lib/i18n';
+
+export async function renderOrderProcessingEmail({
+ language,
+ recipientName,
+ partnerLocation,
+ isUrine,
+}: {
+ language: string;
+ recipientName: string;
+ partnerLocation: string;
+ isUrine?: boolean;
+}) {
+ const namespace = 'order-processing-email';
+
+ const { t } = await initializeEmailI18n({
+ language,
+ namespace: [namespace, 'common'],
+ });
+
+ const previewText = t(`${namespace}:previewText`);
+
+ const subject = t(`${namespace}:subject`);
+
+ const p2 = t(`${namespace}:p2`);
+ const p4 = t(`${namespace}:p4`);
+ const p1Urine = t(`${namespace}:p1Urine`);
+
+ const html = await render(
+
+
+
+
+
+ {previewText}
+
+
+
+
+
+
+ {previewText}
+
+
+ {t(`common:helloName`, { name: recipientName })}
+
+
+ {t(`${namespace}:heading`)}
+
+ {t(`${namespace}:p1`, { partnerLocation })}
+
+ {t(`${namespace}:p3`)}
+
+ {isUrine && (
+ <>
+
+ {t(`${namespace}:p2Urine`)}
+ >
+ )}
+ {t(`${namespace}:p5`)}
+ {t(`${namespace}:p6`)}
+
+
+
+
+
+ ,
+ );
+
+ return {
+ html,
+ subject,
+ };
+}
diff --git a/packages/email-templates/src/emails/otp.email.tsx b/packages/email-templates/src/emails/otp.email.tsx
index ae6db76..04a8b49 100644
--- a/packages/email-templates/src/emails/otp.email.tsx
+++ b/packages/email-templates/src/emails/otp.email.tsx
@@ -60,18 +60,17 @@ export async function renderOtpEmail(props: Props) {
-
- {heading}
-
-
+
+ {heading}
+
{mainText}
{otpText}
-
+
diff --git a/packages/email-templates/src/emails/patient-first-results-received.email.tsx b/packages/email-templates/src/emails/patient-first-results-received.email.tsx
new file mode 100644
index 0000000..adeac31
--- /dev/null
+++ b/packages/email-templates/src/emails/patient-first-results-received.email.tsx
@@ -0,0 +1,81 @@
+import {
+ Body,
+ Head,
+ Html,
+ Link,
+ Preview,
+ Tailwind,
+ Text,
+ render,
+} from '@react-email/components';
+
+import { BodyStyle } from '../components/body-style';
+import CommonFooter from '../components/common-footer';
+import { EmailContent } from '../components/content';
+import { EmailHeader } from '../components/header';
+import { EmailHeading } from '../components/heading';
+import { EmailWrapper } from '../components/wrapper';
+import { initializeEmailI18n } from '../lib/i18n';
+
+export async function renderPatientFirstResultsReceivedEmail({
+ language,
+ recipientName,
+ analysisOrderId,
+}: {
+ language: string;
+ recipientName: string;
+ analysisOrderId: number;
+}) {
+ const namespace = 'patient-first-results-received-email';
+
+ const { t } = await initializeEmailI18n({
+ language,
+ namespace: [namespace, 'common'],
+ });
+
+ const previewText = t(`${namespace}:previewText`);
+
+ const subject = t(`${namespace}:subject`);
+
+ const html = await render(
+
+
+
+
+
+ {previewText}
+
+
+
+
+
+
+ {previewText}
+
+
+ {t(`common:helloName`, { name: recipientName })}
+
+
+ {t(`${namespace}:p1`)}{' '}
+
+ {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
+
+
+ {t(`${namespace}:p2`)}
+ {t(`${namespace}:p3`)}
+ {t(`${namespace}:p4`)}
+
+
+
+
+
+ ,
+ );
+
+ return {
+ html,
+ subject,
+ };
+}
diff --git a/packages/email-templates/src/emails/patient-full-results-received.email.tsx b/packages/email-templates/src/emails/patient-full-results-received.email.tsx
new file mode 100644
index 0000000..6f15224
--- /dev/null
+++ b/packages/email-templates/src/emails/patient-full-results-received.email.tsx
@@ -0,0 +1,82 @@
+import {
+ Body,
+ Head,
+ Html,
+ Link,
+ Preview,
+ Tailwind,
+ Text,
+ render,
+} from '@react-email/components';
+
+import { BodyStyle } from '../components/body-style';
+import CommonFooter from '../components/common-footer';
+import { EmailContent } from '../components/content';
+import { EmailHeader } from '../components/header';
+import { EmailHeading } from '../components/heading';
+import { EmailWrapper } from '../components/wrapper';
+import { initializeEmailI18n } from '../lib/i18n';
+
+export async function renderPatientFullResultsReceivedEmail({
+ language,
+ recipientName,
+ analysisOrderId,
+}: {
+ language: string;
+ recipientName: string;
+ analysisOrderId: number;
+}) {
+ const namespace = 'patient-full-results-received-email';
+
+ const { t } = await initializeEmailI18n({
+ language,
+ namespace: [namespace, 'common'],
+ });
+
+ const previewText = t(`${namespace}:previewText`);
+
+ const subject = t(`${namespace}:subject`);
+
+ const html = await render(
+
+
+
+
+
+ {previewText}
+
+
+
+
+
+
+ {previewText}
+
+
+ {t(`common:helloName`, { name: recipientName })}
+
+
+
+ {t(`${namespace}:p1`)}{' '}
+
+ {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
+
+
+ {t(`${namespace}:p2`)}
+ {t(`${namespace}:p3`)}
+
+
+
+
+
+
+ ,
+ );
+
+ return {
+ html,
+ subject,
+ };
+}
diff --git a/packages/email-templates/src/emails/synlab.email.tsx b/packages/email-templates/src/emails/synlab.email.tsx
index 29ff7d5..3605ac7 100644
--- a/packages/email-templates/src/emails/synlab.email.tsx
+++ b/packages/email-templates/src/emails/synlab.email.tsx
@@ -34,7 +34,7 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
const previewText = t(`${namespace}:previewText`, {
analysisPackageName: props.analysisPackageName,
});
-
+
const subject = t(`${namespace}:subject`, {
analysisPackageName: props.analysisPackageName,
});
@@ -70,15 +70,13 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
-
- {heading}
-
-
+
+ {heading}
+
{hello}
-
{lines.map((line, index) => (
))}
-
diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts
index 83e3021..cae4d3f 100644
--- a/packages/email-templates/src/index.ts
+++ b/packages/email-templates/src/index.ts
@@ -7,3 +7,6 @@ export * from './emails/doctor-summary-received.email';
export * from './emails/new-jobs-available.email';
export * from './emails/first-results-received.email';
export * from './emails/all-results-received.email';
+export * from './emails/order-processing.email';
+export * from './emails/patient-first-results-received.email';
+export * from './emails/patient-full-results-received.email';
diff --git a/packages/email-templates/src/locales/en/doctor-summary-received-email.json b/packages/email-templates/src/locales/en/doctor-summary-received-email.json
index ebefe9b..ed17242 100644
--- a/packages/email-templates/src/locales/en/doctor-summary-received-email.json
+++ b/packages/email-templates/src/locales/en/doctor-summary-received-email.json
@@ -1,8 +1,8 @@
{
- "subject": "Doctor feedback to order {{orderNr}} received",
- "previewText": "A doctor has submitted feedback on your analysis results.",
- "hello": "Hello {{displayName}},",
- "summaryReceivedForOrder": "A doctor has submitted feedback to your analysis results from order {{orderNr}}.",
- "linkText": "View summary",
- "ifButtonDisabled": "If clicking the button does not work, copy this link to your browser's url field:"
-}
\ No newline at end of file
+ "subject": "Doctor's summary has arrived",
+ "previewText": "The doctor has prepared a summary of the test results.",
+ "p1": "The doctor's summary has arrived:",
+ "p2": "It is recommended to have a comprehensive health check-up regularly, at least once a year, if you wish to maintain an active and fulfilling lifestyle.",
+ "p3": "MedReport makes it easy, convenient, and fast to view health data in one place and order health check-ups.",
+ "p4": "SYNLAB customer support phone: 17123"
+}
diff --git a/packages/email-templates/src/locales/en/order-processing-email.json b/packages/email-templates/src/locales/en/order-processing-email.json
new file mode 100644
index 0000000..b3472f0
--- /dev/null
+++ b/packages/email-templates/src/locales/en/order-processing-email.json
@@ -0,0 +1,13 @@
+{
+ "subject": "The referral has been sent to the laboratory. Please go to give samples.",
+ "heading": "Thank you for your order!",
+ "previewText": "The referral for tests has been sent to the laboratory.",
+ "p1": "The referral for tests has been sent to the laboratory digitally. Please go to give samples: {{partnerLocation}}.",
+ "p2": "If you are unable to go to the selected location to give samples, you may visit any other sampling point convenient for you - see locations and opening hours.",
+ "p3": "It is recommended to give samples preferably in the morning (before 12:00) and on an empty stomach without drinking or eating (you may drink water).",
+ "p4": "At the sampling point, please choose in the queue system: under referrals select specialist referral.",
+ "p5": "If you have any additional questions, please do not hesitate to contact us.",
+ "p6": "SYNLAB customer support phone: 17123",
+ "p1Urine": "The tests include a urine test. For the urine test, please collect the first morning urine.",
+ "p2Urine": "You can buy a sample container at the pharmacy and bring the sample with you (procedure performed at home), or ask for one at the sampling point (procedure performed in the point’s restroom)."
+}
diff --git a/packages/email-templates/src/locales/en/patient-first-results-received-email.json b/packages/email-templates/src/locales/en/patient-first-results-received-email.json
new file mode 100644
index 0000000..1a77006
--- /dev/null
+++ b/packages/email-templates/src/locales/en/patient-first-results-received-email.json
@@ -0,0 +1,8 @@
+{
+ "subject": "The first ordered test results have arrived",
+ "previewText": "The first test results have arrived.",
+ "p1": "The first test results have arrived:",
+ "p2": "We will send the next notification once all test results have been received in the system.",
+ "p3": "If you have any additional questions, please feel free to contact us.",
+ "p4": "SYNLAB customer support phone: 17123"
+}
diff --git a/packages/email-templates/src/locales/en/patient-full-results-received-email.json b/packages/email-templates/src/locales/en/patient-full-results-received-email.json
new file mode 100644
index 0000000..8fd6ed2
--- /dev/null
+++ b/packages/email-templates/src/locales/en/patient-full-results-received-email.json
@@ -0,0 +1,7 @@
+{
+ "subject": "All ordered test results have arrived. Awaiting doctor's summary.",
+ "previewText": "All test results have arrived.",
+ "p1": "All test results have arrived:",
+ "p2": "We will send the next notification once the doctor's summary has been prepared.",
+ "p3": "SYNLAB customer support phone: 17123"
+}
diff --git a/packages/email-templates/src/locales/et/common.json b/packages/email-templates/src/locales/et/common.json
index fc58e08..8b41d33 100644
--- a/packages/email-templates/src/locales/et/common.json
+++ b/packages/email-templates/src/locales/et/common.json
@@ -4,5 +4,7 @@
"lines2": "E-mail: info@medreport.ee",
"lines3": "Klienditugi: +372 5887 1517",
"lines4": "www.medreport.ee"
- }
+ },
+ "helloName": "Tere, {{name}}",
+ "hello": "Tere"
}
\ No newline at end of file
diff --git a/packages/email-templates/src/locales/et/doctor-summary-received-email.json b/packages/email-templates/src/locales/et/doctor-summary-received-email.json
index e7efdc3..9e81ab5 100644
--- a/packages/email-templates/src/locales/et/doctor-summary-received-email.json
+++ b/packages/email-templates/src/locales/et/doctor-summary-received-email.json
@@ -1,8 +1,8 @@
{
- "subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}",
- "previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.",
- "hello": "Tere, {{displayName}}",
- "summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.",
- "linkText": "Vaata kokkuvõtet",
- "ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:"
+ "subject": "Arsti kokkuvõte on saabunud",
+ "previewText": "Arst on koostanud kokkuvõte analüüsitulemustele.",
+ "p1": "Arsti kokkuvõte on saabunud:",
+ "p2": "Põhjalikul terviseuuringul on soovituslik käia regulaarselt, aga vähemalt üks kord aastas, kui soovite säilitada aktiivset ja täisväärtuslikku elustiili.",
+ "p3": "MedReport aitab lihtsalt, mugavalt ja kiirelt terviseandmeid ühest kohast vaadata ning tellida terviseuuringuid.",
+ "p4": "SYNLAB klienditoe telefon: 17123"
}
\ No newline at end of file
diff --git a/packages/email-templates/src/locales/et/order-processing-email.json b/packages/email-templates/src/locales/et/order-processing-email.json
new file mode 100644
index 0000000..a9e57c0
--- /dev/null
+++ b/packages/email-templates/src/locales/et/order-processing-email.json
@@ -0,0 +1,13 @@
+{
+ "subject": "Saatekiri on saadetud laborisse. Palun mine proove andma.",
+ "heading": "Täname tellimuse eest!",
+ "previewText": "Saatekiri uuringute tegemiseks on saadetud laborisse.",
+ "p1": "Saatekiri uuringute tegemiseks on saadetud laborisse digitaalselt. Palun mine proove andma: {{partnerLocation}}.",
+ "p2": "Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - vaata asukohti ja lahtiolekuaegasid.",
+ "p3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
+ "p4": "Proovivõtupunktis valige järjekorrasüsteemis: saatekirjad alt eriarsti saatekiri",
+ "p5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
+ "p6": "SYNLAB klienditoe telefon: 17123",
+ "p1Urine": "Analüüsides on ette nähtud uriinianalüüs. Uriinianalüüsiks võta hommikune esmane uriin.",
+ "p2Urine": "Proovitopsi võib soetada apteegist ja analüüsi kaasa võtta (teostada protseduur kodus) või küsida proovivõtupunktist (teostada protseduur proovipunkti wc-s)."
+}
\ No newline at end of file
diff --git a/packages/email-templates/src/locales/et/patient-first-results-received-email.json b/packages/email-templates/src/locales/et/patient-first-results-received-email.json
new file mode 100644
index 0000000..7d87e78
--- /dev/null
+++ b/packages/email-templates/src/locales/et/patient-first-results-received-email.json
@@ -0,0 +1,8 @@
+{
+ "subject": "Saabusid tellitud uuringute esimesed tulemused",
+ "previewText": "Esimesed uuringute tulemused on saabunud.",
+ "p1": "Esimesed uuringute tulemused on saabunud:",
+ "p2": "Saadame järgmise teavituse, kui kõik uuringute vastused on saabunud süsteemi.",
+ "p3": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
+ "p4": "SYNLAB klienditoe telefon: 17123"
+}
\ No newline at end of file
diff --git a/packages/email-templates/src/locales/et/patient-full-results-received-email.json b/packages/email-templates/src/locales/et/patient-full-results-received-email.json
new file mode 100644
index 0000000..4a1de1a
--- /dev/null
+++ b/packages/email-templates/src/locales/et/patient-full-results-received-email.json
@@ -0,0 +1,7 @@
+{
+ "subject": "Kõikide tellitud uuringute tulemused on saabunud. Ootab arsti kokkuvõtet.",
+ "previewText": "Kõikide uuringute tulemused on saabunud.",
+ "p1": "Kõikide uuringute tulemused on saabunud:",
+ "p2": "Saadame järgmise teavituse kui arsti kokkuvõte on koostatud.",
+ "p3": "SYNLAB klienditoe telefon: 17123"
+}
\ No newline at end of file
diff --git a/packages/email-templates/src/locales/ru/doctor-summary-received-email.json b/packages/email-templates/src/locales/ru/doctor-summary-received-email.json
index 09beb43..e233f55 100644
--- a/packages/email-templates/src/locales/ru/doctor-summary-received-email.json
+++ b/packages/email-templates/src/locales/ru/doctor-summary-received-email.json
@@ -1,8 +1,8 @@
{
- "subject": "Получено заключение врача по заказу {{orderNr}}",
- "previewText": "Врач отправил заключение по вашим результатам анализа.",
- "hello": "Здравствуйте, {{displayName}}",
- "summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.",
- "linkText": "Посмотреть заключение",
- "ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:"
+ "subject": "Заключение врача готово",
+ "previewText": "Врач подготовил заключение по результатам анализов.",
+ "p1": "Заключение врача готово:",
+ "p2": "Рекомендуется проходить комплексное обследование регулярно, но как минимум один раз в год, если вы хотите сохранить активный и полноценный образ жизни.",
+ "p3": "MedReport позволяет легко, удобно и быстро просматривать медицинские данные в одном месте и заказывать обследования.",
+ "p4": "Телефон службы поддержки SYNLAB: 17123"
}
\ No newline at end of file
diff --git a/packages/email-templates/src/locales/ru/order-processing-email.json b/packages/email-templates/src/locales/ru/order-processing-email.json
new file mode 100644
index 0000000..3a5d6ac
--- /dev/null
+++ b/packages/email-templates/src/locales/ru/order-processing-email.json
@@ -0,0 +1,13 @@
+{
+ "subject": "Направление отправлено в лабораторию. Пожалуйста, сдайте анализы.",
+ "heading": "Спасибо за заказ!",
+ "previewText": "Направление на обследование отправлено в лабораторию.",
+ "p1": "Направление на обследование было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: {{partnerLocation}}.",
+ "p2": "Если у вас нет возможности прийти в выбранный пункт сдачи анализов, вы можете обратиться в любой удобный для вас пункт – посмотреть адреса и часы работы.",
+ "p3": "Рекомендуется сдавать анализы утром (до 12:00) натощак, без еды и напитков (разрешается пить воду).",
+ "p4": "В пункте сдачи анализов выберите в системе очереди: в разделе направления → направление от специалиста.",
+ "p5": "Если у вас возникли дополнительные вопросы, пожалуйста, свяжитесь с нами.",
+ "p6": "Телефон службы поддержки SYNLAB: 17123",
+ "p1Urine": "В обследование входит анализ мочи. Для анализа необходимо собрать первую утреннюю мочу.",
+ "p2Urine": "Контейнер можно приобрести в аптеке и принести образец с собой (процедура проводится дома) или взять контейнер в пункте сдачи (процедура проводится в туалете пункта)."
+}
diff --git a/packages/email-templates/src/locales/ru/patient-first-results-received-email.json b/packages/email-templates/src/locales/ru/patient-first-results-received-email.json
new file mode 100644
index 0000000..975934f
--- /dev/null
+++ b/packages/email-templates/src/locales/ru/patient-first-results-received-email.json
@@ -0,0 +1,8 @@
+{
+ "subject": "Поступили первые результаты заказанных исследований",
+ "previewText": "Первые результаты исследований поступили.",
+ "p1": "Первые результаты исследований поступили:",
+ "p2": "Мы отправим следующее уведомление, когда все результаты исследований будут получены в системе.",
+ "p3": "Если у вас возникнут дополнительные вопросы, пожалуйста, свяжитесь с нами.",
+ "p4": "Телефон службы поддержки SYNLAB: 17123"
+}
diff --git a/packages/email-templates/src/locales/ru/patient-full-results-received-email.json b/packages/email-templates/src/locales/ru/patient-full-results-received-email.json
new file mode 100644
index 0000000..e47f161
--- /dev/null
+++ b/packages/email-templates/src/locales/ru/patient-full-results-received-email.json
@@ -0,0 +1,7 @@
+{
+ "subject": "Все заказанные результаты исследований поступили. Ожидается заключение врача.",
+ "previewText": "Все результаты исследований поступили.",
+ "p1": "Все результаты исследований поступили:",
+ "p2": "Мы отправим следующее уведомление, когда заключение врача будет подготовлено.",
+ "p3": "Телефон службы поддержки SYNLAB: 17123"
+}
\ No newline at end of file
diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx
index 2a77099..b88eea9 100644
--- a/packages/features/accounts/src/components/personal-account-dropdown.tsx
+++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx
@@ -79,15 +79,9 @@ export function PersonalAccountDropdown({
}) {
const { data: personalAccountData } = usePersonalAccountData(user.id);
- const signedInAsLabel = useMemo(() => {
- const email = user?.email ?? undefined;
- const phone = user?.phone ?? undefined;
-
- return email ?? phone;
- }, [user]);
-
- const displayName =
- personalAccountData?.name ?? account?.name ?? user?.email ?? '';
+ const { name, last_name } = personalAccountData ?? {};
+ const firstNameLabel = toTitleCase(name) ?? '-';
+ const fullNameLabel = name && last_name ? toTitleCase(`${name} ${last_name}`) : '-';
const hasTotpFactor = useMemo(() => {
const factors = user?.factors ?? [];
@@ -128,7 +122,7 @@ export function PersonalAccountDropdown({
@@ -142,7 +136,7 @@ export function PersonalAccountDropdown({
data-test={'account-dropdown-display-name'}
className={'truncate text-sm'}
>
- {toTitleCase(displayName)}
+ {firstNameLabel}
@@ -164,7 +158,7 @@ export function PersonalAccountDropdown({
- {signedInAsLabel}
+ {fullNameLabel}
diff --git a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx
index 6c302ff..b485d95 100644
--- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx
+++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx
@@ -265,11 +265,13 @@ function FactorQrCode({
z.object({
factorName: z.string().min(1),
qrCode: z.string().min(1),
+ totpSecret: z.string().min(1),
}),
),
defaultValues: {
factorName: '',
qrCode: '',
+ totpSecret: '',
},
});
@@ -319,6 +321,7 @@ function FactorQrCode({
if (data.type === 'totp') {
form.setValue('factorName', name);
form.setValue('qrCode', data.totp.qr_code);
+ form.setValue('totpSecret', data.totp.secret);
}
// dispatch event to set factor ID
@@ -331,7 +334,7 @@ function FactorQrCode({
return (
@@ -343,6 +346,10 @@ function FactorQrCode({
+
+
+ {form.getValues('totpSecret')}
+
);
}
diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts
index 4c9e467..d1faaef 100644
--- a/packages/features/accounts/src/server/api.ts
+++ b/packages/features/accounts/src/server/api.ts
@@ -3,6 +3,7 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
+import PersonalCode from '~/lib/utils';
export type AccountWithParams =
Database['medreport']['Tables']['accounts']['Row'] & {
@@ -48,6 +49,33 @@ class AccountsApi {
return data;
}
+ /**
+ * @name getPersonalAccountByUserId
+ * @description Get the personal account data for the given user ID.
+ * @param userId
+ */
+ async getPersonalAccountByUserId(userId: string): Promise
{
+ const { data, error } = await this.client
+ .schema('medreport')
+ .from('accounts')
+ .select(
+ '*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
+ )
+ .eq('primary_owner_user_id', userId)
+ .eq('is_personal_account', true)
+ .single();
+
+ if (error) {
+ throw error;
+ }
+
+ const { personal_code, ...rest } = data;
+ return {
+ ...rest,
+ personal_code: PersonalCode.getPersonalCode(personal_code),
+ };
+ }
+
/**
* @name getAccountWorkspace
* @description Get the account workspace data.
diff --git a/packages/features/admin/src/lib/server/schema/create-company.schema.ts b/packages/features/admin/src/lib/server/schema/create-company.schema.ts
index 42ef6cb..c2d33cf 100644
--- a/packages/features/admin/src/lib/server/schema/create-company.schema.ts
+++ b/packages/features/admin/src/lib/server/schema/create-company.schema.ts
@@ -10,7 +10,7 @@ const personalCodeSchema = z.string().refine(
}
},
{
- message: 'Invalid personal code',
+ message: 'common:formFieldError.invalidPersonalCode',
},
);
diff --git a/packages/features/auth/src/components/auth-layout.tsx b/packages/features/auth/src/components/auth-layout.tsx
index 2d83e61..003da16 100644
--- a/packages/features/auth/src/components/auth-layout.tsx
+++ b/packages/features/auth/src/components/auth-layout.tsx
@@ -7,9 +7,8 @@ export function AuthLayoutShell({
return (
{Logo ?
: null}
diff --git a/packages/features/auth/src/components/oauth-providers.tsx b/packages/features/auth/src/components/oauth-providers.tsx
index 454b552..2bb7830 100644
--- a/packages/features/auth/src/components/oauth-providers.tsx
+++ b/packages/features/auth/src/components/oauth-providers.tsx
@@ -25,8 +25,8 @@ import { AuthProviderButton } from './auth-provider-button';
* @see https://supabase.com/docs/guides/auth/social-login
*/
const OAUTH_SCOPES: Partial
> = {
- azure: 'email',
- keycloak: 'openid',
+ // azure: 'email',
+ // keycloak: 'openid',
// add your OAuth providers here
};
@@ -88,10 +88,12 @@ export const OauthProviders: React.FC<{
queryParams.set('invite_token', props.inviteToken);
}
- const redirectPath = [
- props.paths.callback,
- queryParams.toString(),
- ].join('?');
+ // signicat/keycloak will not allow redirect-uri with changing query params
+ const INCLUDE_QUERY_PARAMS = false as boolean;
+
+ const redirectPath = INCLUDE_QUERY_PARAMS
+ ? [props.paths.callback, queryParams.toString()].join('?')
+ : props.paths.callback;
const redirectTo = [origin, redirectPath].join('');
const scopes = OAUTH_SCOPES[provider] ?? undefined;
@@ -102,6 +104,7 @@ export const OauthProviders: React.FC<{
redirectTo,
queryParams: props.queryParams,
scopes,
+ // skipBrowserRedirect: false,
},
} satisfies SignInWithOAuthCredentials;
@@ -110,12 +113,16 @@ export const OauthProviders: React.FC<{
);
}}
>
-
+ {provider === 'keycloak' ? (
+
+ ) : (
+
+ )}
);
})}
diff --git a/packages/features/auth/src/components/password-sign-up-container.tsx b/packages/features/auth/src/components/password-sign-up-container.tsx
index 5cbe21a..631c7a5 100644
--- a/packages/features/auth/src/components/password-sign-up-container.tsx
+++ b/packages/features/auth/src/components/password-sign-up-container.tsx
@@ -10,9 +10,18 @@ import { useCaptchaToken } from '../captcha/client';
import { usePasswordSignUpFlow } from '../hooks/use-sign-up-flow';
import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignUpForm } from './password-sign-up-form';
+import { Spinner } from '@kit/ui/makerkit/spinner';
interface EmailPasswordSignUpContainerProps {
- displayTermsCheckbox?: boolean;
+ authConfig: {
+ providers: {
+ password: boolean;
+ magicLink: boolean;
+ oAuth: string[];
+ };
+ displayTermsCheckbox: boolean | undefined;
+ isMailerAutoconfirmEnabled: boolean;
+ };
defaultValues?: {
email: string;
};
@@ -21,10 +30,10 @@ interface EmailPasswordSignUpContainerProps {
}
export function EmailPasswordSignUpContainer({
+ authConfig,
defaultValues,
onSignUp,
emailRedirectTo,
- displayTermsCheckbox,
}: EmailPasswordSignUpContainerProps) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
@@ -43,7 +52,12 @@ export function EmailPasswordSignUpContainer({
return (
<>
-
+ {authConfig.isMailerAutoconfirmEnabled ? (
+
+
+
+ ) :
+ }
@@ -53,7 +67,7 @@ export function EmailPasswordSignUpContainer({
onSubmit={onSignupRequested}
loading={loading}
defaultValues={defaultValues}
- displayTermsCheckbox={displayTermsCheckbox}
+ displayTermsCheckbox={authConfig.displayTermsCheckbox}
/>
>
diff --git a/packages/features/auth/src/components/sign-in-methods-container.tsx b/packages/features/auth/src/components/sign-in-methods-container.tsx
index 83bda02..6b1e8c7 100644
--- a/packages/features/auth/src/components/sign-in-methods-container.tsx
+++ b/packages/features/auth/src/components/sign-in-methods-container.tsx
@@ -15,6 +15,12 @@ import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers';
import { PasswordSignInContainer } from './password-sign-in-container';
+export type Providers = {
+ password: boolean;
+ magicLink: boolean;
+ oAuth: Provider[];
+};
+
export function SignInMethodsContainer(props: {
inviteToken?: string;
@@ -25,11 +31,7 @@ export function SignInMethodsContainer(props: {
updateAccount: string;
};
- providers: {
- password: boolean;
- magicLink: boolean;
- oAuth: Provider[];
- };
+ providers: Providers;
}) {
const client = useSupabase();
const router = useRouter();
@@ -108,6 +110,9 @@ export function SignInMethodsContainer(props: {
callback: props.paths.callback,
returnPath: props.paths.returnPath,
}}
+ queryParams={{
+ prompt: 'login',
+ }}
/>
>
diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx
index c10054d..00782bd 100644
--- a/packages/features/auth/src/components/sign-up-methods-container.tsx
+++ b/packages/features/auth/src/components/sign-up-methods-container.tsx
@@ -1,8 +1,7 @@
'use client';
-import { redirect } from 'next/navigation';
-
import type { Provider } from '@supabase/supabase-js';
+import { useRouter } from 'next/navigation';
import { isBrowser } from '@kit/shared/utils';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -21,15 +20,20 @@ export function SignUpMethodsContainer(props: {
updateAccount: string;
};
- providers: {
- password: boolean;
- magicLink: boolean;
- oAuth: Provider[];
+ authConfig: {
+ providers: {
+ password: boolean;
+ magicLink: boolean;
+ oAuth: Provider[];
+ };
+ displayTermsCheckbox: boolean | undefined;
+ isMailerAutoconfirmEnabled: boolean;
};
- displayTermsCheckbox?: boolean;
inviteToken?: string;
}) {
+ const router = useRouter();
+
const redirectUrl = getCallbackUrl(props);
const defaultValues = getDefaultValues();
@@ -39,26 +43,33 @@ export function SignUpMethodsContainer(props: {
-
+
redirect(redirectUrl)}
+ authConfig={props.authConfig}
+ onSignUp={() => {
+ if (!props.authConfig.isMailerAutoconfirmEnabled) {
+ return;
+ }
+ setTimeout(() => {
+ router.replace(props.paths.updateAccount)
+ }, 2_500);
+ }}
/>
-
+
-
+
@@ -72,13 +83,16 @@ export function SignUpMethodsContainer(props: {
>
diff --git a/packages/features/auth/src/server/api.ts b/packages/features/auth/src/server/api.ts
index 6f8ead2..8462006 100644
--- a/packages/features/auth/src/server/api.ts
+++ b/packages/features/auth/src/server/api.ts
@@ -9,8 +9,8 @@ export interface AccountSubmitData {
email: string;
phone?: string;
city?: string;
- weight: number | null;
- height: number | null;
+ weight?: number | null | undefined;
+ height?: number | null | undefined;
userConsent: boolean;
}
@@ -68,6 +68,7 @@ class AuthApi {
p_name: data.firstName,
p_last_name: data.lastName,
p_personal_code: data.personalCode,
+ p_email: data.email || '',
p_phone: data.phone || '',
p_city: data.city || '',
p_has_consent_personal_data: data.userConsent,
diff --git a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts
index 4553578..610b70a 100644
--- a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts
+++ b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts
@@ -126,7 +126,7 @@ export const giveFeedbackAction = doctorAction(
if (isCompleted) {
await createNotificationLog({
- action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
+ action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED,
status: 'SUCCESS',
relatedRecordId: analysisOrderId,
});
@@ -136,7 +136,7 @@ export const giveFeedbackAction = doctorAction(
} catch (e: any) {
if (isCompleted) {
await createNotificationLog({
- action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
+ action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED,
status: 'FAIL',
comment: e?.message,
relatedRecordId: analysisOrderId,
diff --git a/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts b/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts
index 329d846..db8e2be 100644
--- a/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts
+++ b/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts
@@ -1,9 +1,9 @@
-import z from 'zod/v3';
import { Database } from '@kit/supabase/database';
+import z from 'zod';
export const doctorJobSelectSchema = z.object({
- userId: z.string().uuid(),
+ userId: z.uuid(),
analysisOrderId: z.number(),
});
export type DoctorJobSelect = z.infer
;
diff --git a/packages/features/medusa-storefront/src/lib/data/cookies.ts b/packages/features/medusa-storefront/src/lib/data/cookies.ts
index 7694904..ede7537 100644
--- a/packages/features/medusa-storefront/src/lib/data/cookies.ts
+++ b/packages/features/medusa-storefront/src/lib/data/cookies.ts
@@ -1,12 +1,20 @@
import "server-only"
+
import { cookies as nextCookies } from "next/headers"
+const CookieName = {
+ MEDUSA_CUSTOMER_ID: "_medusa_customer_id",
+ MEDUSA_JWT: "_medusa_jwt",
+ MEDUSA_CART_ID: "_medusa_cart_id",
+ MEDUSA_CACHE_ID: "_medusa_cache_id",
+}
+
export const getAuthHeaders = async (): Promise<
{ authorization: string } | {}
> => {
try {
const cookies = await nextCookies()
- const token = cookies.get("_medusa_jwt")?.value
+ const token = cookies.get(CookieName.MEDUSA_JWT)?.value
if (!token) {
return {}
@@ -23,7 +31,7 @@ export const getMedusaCustomerId = async (): Promise<
> => {
try {
const cookies = await nextCookies()
- const customerId = cookies.get("_medusa_customer_id")?.value
+ const customerId = cookies.get(CookieName.MEDUSA_CUSTOMER_ID)?.value
if (!customerId) {
return { customerId: null }
@@ -31,14 +39,14 @@ export const getMedusaCustomerId = async (): Promise<
return { customerId }
} catch {
- return { customerId: null}
+ return { customerId: null }
}
}
export const getCacheTag = async (tag: string): Promise => {
try {
const cookies = await nextCookies()
- const cacheId = cookies.get("_medusa_cache_id")?.value
+ const cacheId = cookies.get(CookieName.MEDUSA_CACHE_ID)?.value
if (!cacheId) {
return ""
@@ -66,51 +74,51 @@ export const getCacheOptions = async (
return { tags: [`${cacheTag}`] }
}
+const getCookieSharedOptions = () => ({
+ maxAge: 60 * 60 * 24 * 7,
+ httpOnly: false,
+ secure: process.env.NODE_ENV === "production",
+});
+const getCookieResetOptions = () => ({
+ maxAge: -1,
+});
+
export const setAuthToken = async (token: string) => {
const cookies = await nextCookies()
- cookies.set("_medusa_jwt", token, {
- maxAge: 60 * 60 * 24 * 7,
- httpOnly: true,
- sameSite: "strict",
- secure: process.env.NODE_ENV === "production",
+ cookies.set(CookieName.MEDUSA_JWT, token, {
+ ...getCookieSharedOptions(),
})
}
export const setMedusaCustomerId = async (customerId: string) => {
const cookies = await nextCookies()
- cookies.set("_medusa_customer_id", customerId, {
- maxAge: 60 * 60 * 24 * 7,
- httpOnly: true,
- sameSite: "strict",
- secure: process.env.NODE_ENV === "production",
+ cookies.set(CookieName.MEDUSA_CUSTOMER_ID, customerId, {
+ ...getCookieSharedOptions(),
})
}
export const removeAuthToken = async () => {
const cookies = await nextCookies()
- cookies.set("_medusa_jwt", "", {
- maxAge: -1,
+ cookies.set(CookieName.MEDUSA_JWT, "", {
+ ...getCookieResetOptions(),
})
}
export const getCartId = async () => {
const cookies = await nextCookies()
- return cookies.get("_medusa_cart_id")?.value
+ return cookies.get(CookieName.MEDUSA_CART_ID)?.value
}
export const setCartId = async (cartId: string) => {
const cookies = await nextCookies()
- cookies.set("_medusa_cart_id", cartId, {
- maxAge: 60 * 60 * 24 * 7,
- httpOnly: true,
- sameSite: "strict",
- secure: process.env.NODE_ENV === "production",
+ cookies.set(CookieName.MEDUSA_CART_ID, cartId, {
+ ...getCookieSharedOptions(),
})
}
export const removeCartId = async () => {
const cookies = await nextCookies()
- cookies.set("_medusa_cart_id", "", {
- maxAge: -1,
+ cookies.set(CookieName.MEDUSA_CART_ID, "", {
+ ...getCookieResetOptions(),
})
}
diff --git a/packages/features/medusa-storefront/src/lib/data/customer.ts b/packages/features/medusa-storefront/src/lib/data/customer.ts
index bf56d6e..3c05921 100644
--- a/packages/features/medusa-storefront/src/lib/data/customer.ts
+++ b/packages/features/medusa-storefront/src/lib/data/customer.ts
@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
-import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
@@ -127,21 +126,21 @@ export async function login(_currentState: unknown, formData: FormData) {
}
}
-export async function signout(countryCode?: string, shouldRedirect = true) {
+export async function medusaLogout(countryCode = 'ee', canRevalidateTags = true) {
await sdk.auth.logout()
await removeAuthToken()
- const customerCacheTag = await getCacheTag("customers")
- revalidateTag(customerCacheTag)
+ if (canRevalidateTags) {
+ const customerCacheTag = await getCacheTag("customers")
+ revalidateTag(customerCacheTag)
+ }
await removeCartId()
- const cartCacheTag = await getCacheTag("carts")
- revalidateTag(cartCacheTag)
-
- if (shouldRedirect) {
- redirect(`/${countryCode!}/account`)
+ if (canRevalidateTags) {
+ const cartCacheTag = await getCacheTag("carts")
+ revalidateTag(cartCacheTag)
}
}
@@ -262,72 +261,110 @@ export const updateCustomerAddress = async (
})
}
-export async function medusaLoginOrRegister(credentials: {
- email: string
- password?: string
-}) {
- const { email, password } = credentials;
+async function medusaLogin(email: string, password: string) {
+ const token = await sdk.auth.login("customer", "emailpass", { email, password });
+ await setAuthToken(token as string);
try {
- const token = await sdk.auth.login("customer", "emailpass", {
- email,
- password,
+ await transferCart();
+ } catch (e) {
+ console.error("Failed to transfer cart", e);
+ }
+
+ const customer = await retrieveCustomer();
+ if (!customer) {
+ throw new Error("Customer not found for active session");
+ }
+
+ return customer.id;
+}
+
+async function medusaRegister({
+ email,
+ password,
+ name,
+ lastName,
+}: {
+ email: string;
+ password: string;
+ name: string | undefined;
+ lastName: string | undefined;
+}) {
+ console.info(`Creating new Medusa account for Keycloak user with email=${email}`);
+
+ const registerToken = await sdk.auth.register("customer", "emailpass", { email, password });
+ await setAuthToken(registerToken);
+
+ console.info(`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`);
+ await sdk.store.customer.create(
+ { email, first_name: name, last_name: lastName },
+ {},
+ {
+ ...(await getAuthHeaders()),
});
- await setAuthToken(token as string);
+}
- try {
- await transferCart();
- } catch (e) {
- console.error("Failed to transfer cart", e);
+export async function medusaLoginOrRegister(credentials: {
+ email: string
+ supabaseUserId?: string
+ name?: string,
+ lastName?: string,
+} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) {
+ const { email, supabaseUserId, name, lastName } = credentials;
+
+
+ const password = await (async () => {
+ if (credentials.isDevPasswordLogin) {
+ return credentials.password;
}
- const customerCacheTag = await getCacheTag("customers");
- revalidateTag(customerCacheTag);
+ return generateDeterministicPassword(email, supabaseUserId);
+ })();
+
+ try {
+ return await medusaLogin(email, password);
+ } catch (loginError) {
+ console.error("Failed to login customer, attempting to register", loginError);
- const customer = await retrieveCustomer();
- if (!customer) {
- throw new Error("Customer not found");
- }
- return customer.id;
- } catch (error) {
- console.error("Failed to login customer, attempting to register", error);
try {
- const registerToken = await sdk.auth.register("customer", "emailpass", {
- email: email,
- password: password,
- })
-
- await setAuthToken(registerToken as string);
-
- const headers = {
- ...(await getAuthHeaders()),
- };
-
- await sdk.store.customer.create({ email }, {}, headers);
-
- const loginToken = await sdk.auth.login("customer", "emailpass", {
- email,
- password,
- });
-
- await setAuthToken(loginToken as string);
-
- const customerCacheTag = await getCacheTag("customers");
- revalidateTag(customerCacheTag);
-
- try {
- await transferCart();
- } catch (e) {
- console.error("Failed to transfer cart", e);
- }
-
- const customer = await retrieveCustomer();
- if (!customer) {
- throw new Error("Customer not found");
- }
- return customer.id;
+ await medusaRegister({ email, password, name, lastName });
+ return await medusaLogin(email, password);
} catch (registerError) {
+ console.error("Failed to create Medusa account for user with email=${email}", registerError);
throw medusaError(registerError);
}
}
}
+
+/**
+ * Generate a deterministic password based on user identifier
+ * This ensures the same user always gets the same password for Medusa
+ */
+async function generateDeterministicPassword(email: string, userId?: string): Promise {
+ // Use the user ID or email as the base for deterministic generation
+ const baseString = userId || email;
+ const secret = process.env.MEDUSA_PASSWORD_SECRET!;
+
+ // Create a deterministic password using HMAC
+ const encoder = new TextEncoder();
+ const keyData = encoder.encode(secret);
+ const messageData = encoder.encode(baseString);
+
+ // Import key for HMAC
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ keyData,
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign']
+ );
+ // Generate HMAC
+ const signature = await crypto.subtle.sign('HMAC', key, messageData);
+ // Convert to base64 and make it a valid password
+ const hashArray = Array.from(new Uint8Array(signature));
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+ // Take first 24 characters and add some complexity
+ const basePassword = hashHex.substring(0, 24);
+ // Add some required complexity for Medusa (uppercase, lowercase, numbers, symbols)
+ return `Mk${basePassword}9!`;
+}
diff --git a/packages/features/medusa-storefront/src/lib/data/orders.ts b/packages/features/medusa-storefront/src/lib/data/orders.ts
index c20931f..cf0231c 100644
--- a/packages/features/medusa-storefront/src/lib/data/orders.ts
+++ b/packages/features/medusa-storefront/src/lib/data/orders.ts
@@ -54,7 +54,6 @@ export const listOrders = async (
},
headers,
next,
- cache: "force-cache",
})
.then(({ orders }) => orders)
.catch((err) => medusaError(err))
diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts
index a8ea25d..b65efe9 100644
--- a/packages/features/medusa-storefront/src/lib/data/products.ts
+++ b/packages/features/medusa-storefront/src/lib/data/products.ts
@@ -14,7 +14,12 @@ export const listProducts = async ({
regionId,
}: {
pageParam?: number
- queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_id?: string }
+ queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & {
+ "type_id[0]"?: string;
+ id?: string[],
+ category_id?: string;
+ order?: 'title';
+ }
countryCode?: string
regionId?: string
}): Promise<{
@@ -68,7 +73,6 @@ export const listProducts = async ({
},
headers,
next,
- cache: "force-cache",
}
)
.then(({ products, count }) => {
diff --git a/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx b/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx
index 61dd0c2..338dd22 100644
--- a/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx
+++ b/packages/features/medusa-storefront/src/modules/account/components/account-nav/index.tsx
@@ -10,7 +10,7 @@ import MapPin from "@modules/common/icons/map-pin"
import Package from "@modules/common/icons/package"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
-import { signout } from "@lib/data/customer"
+import { medusaLogout } from "@lib/data/customer"
const AccountNav = ({
customer,
@@ -21,7 +21,7 @@ const AccountNav = ({
const { countryCode } = useParams() as { countryCode: string }
const handleLogout = async () => {
- await signout(countryCode)
+ await medusaLogout(countryCode)
}
return (
diff --git a/packages/features/notifications/package.json b/packages/features/notifications/package.json
index 5355d69..df31c57 100644
--- a/packages/features/notifications/package.json
+++ b/packages/features/notifications/package.json
@@ -11,7 +11,8 @@
"exports": {
"./api": "./src/server/api.ts",
"./components": "./src/components/index.ts",
- "./hooks": "./src/hooks/index.ts"
+ "./hooks": "./src/hooks/index.ts",
+ "./webhooks/*": "./src/server/services/webhooks/*.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
diff --git a/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts b/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts
new file mode 100644
index 0000000..643b1b7
--- /dev/null
+++ b/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts
@@ -0,0 +1,273 @@
+import {
+ renderAllResultsReceivedEmail,
+ renderFirstResultsReceivedEmail,
+ renderOrderProcessingEmail,
+ renderPatientFirstResultsReceivedEmail,
+ renderPatientFullResultsReceivedEmail,
+} from '@kit/email-templates';
+import { getLogger } from '@kit/shared/logger';
+import { getFullName } from '@kit/shared/utils';
+import { Database } from '@kit/supabase/database';
+import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
+
+import {
+ getAssignedDoctorAccount,
+ getDoctorAccounts,
+ getUserContactAdmin,
+} from '~/lib/services/account.service';
+import {
+ NotificationAction,
+ createNotificationLog,
+} from '~/lib/services/audit/notificationEntries.service';
+import {
+ EmailRenderer,
+ sendEmailFromTemplate,
+} from '~/lib/services/mailer.service';
+
+type AnalysisOrder = Database['medreport']['Tables']['analysis_orders']['Row'];
+
+export function createAnalysisOrderWebhooksService() {
+ return new AnalysisOrderWebhooksService();
+}
+
+class AnalysisOrderWebhooksService {
+ private readonly namespace = 'analysis_orders.webhooks';
+
+ async handleStatusChangeWebhook(analysisOrder: AnalysisOrder) {
+ const logger = await getLogger();
+
+ const ctx = {
+ analysisOrderId: analysisOrder.id,
+ namespace: this.namespace,
+ };
+
+ logger.info(ctx, 'Received status change update. Processing...');
+ let actions: NotificationAction[] = [];
+ try {
+ if (analysisOrder.status === 'PROCESSING') {
+ actions = [NotificationAction.PATIENT_ORDER_PROCESSING];
+ await this.sendProcessingNotification(analysisOrder);
+ }
+
+ if (analysisOrder.status === 'PARTIAL_ANALYSIS_RESPONSE') {
+ actions = [
+ NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED,
+ NotificationAction.DOCTOR_NEW_JOBS,
+ ];
+
+ await this.sendPartialAnalysisResultsNotifications(analysisOrder);
+ }
+
+ if (analysisOrder.status === 'FULL_ANALYSIS_RESPONSE') {
+ actions = [
+ NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED,
+ NotificationAction.PATIENT_FULL_RESULTS_RECEIVED,
+ ];
+ await this.sendFullAnalysisResultsNotifications(analysisOrder);
+ }
+
+ if (actions.length) {
+ return logger.info(ctx, 'Status change notifications sent.');
+ }
+
+ logger.info(ctx, 'Status change processed. No notifications to send.');
+ } catch (e: any) {
+ if (actions.length)
+ await Promise.all(
+ actions.map((action) =>
+ createNotificationLog({
+ action,
+ status: 'FAIL',
+ comment: e?.message,
+ relatedRecordId: analysisOrder.id,
+ }),
+ ),
+ );
+ logger.error(
+ ctx,
+ `Error while processing status change: ${JSON.stringify(e)}`,
+ );
+ }
+ }
+
+ async sendProcessingNotification(analysisOrder: AnalysisOrder) {
+ const logger = await getLogger();
+ const supabase = getSupabaseServerAdminClient();
+
+ const userContact = await getUserContactAdmin(analysisOrder.user_id);
+
+ if (!userContact?.email) {
+ await createNotificationLog({
+ action: NotificationAction.PATIENT_ORDER_PROCESSING,
+ status: 'FAIL',
+ comment: 'No email found for ' + analysisOrder.user_id,
+ relatedRecordId: analysisOrder.id,
+ });
+ logger.warn(
+ { analysisOrderId: analysisOrder.id, namespace: this.namespace },
+ 'No email found ',
+ );
+ return;
+ }
+
+ const [{ data: medusaOrder }, { data: analysisElements }] =
+ await Promise.all([
+ supabase
+ .from('order')
+ .select('id,metadata')
+ .eq('id', analysisOrder.medusa_order_id)
+ .single()
+ .throwOnError(),
+ supabase
+ .schema('medreport')
+ .from('analysis_elements')
+ .select('materialGroups:material_groups')
+ .in('id', analysisOrder.analysis_element_ids ?? [])
+ .throwOnError(),
+ ]);
+
+ let isUrine = false;
+ for (const analysisElement of analysisElements ?? []) {
+ logger.info({ group: analysisElement.materialGroups ?? [] });
+
+ const containsUrineSample = (analysisElement.materialGroups ?? [])?.some(
+ (element) =>
+ (element as { Materjal?: { MaterjaliNimi: string } })?.Materjal
+ ?.MaterjaliNimi === 'Uriin',
+ );
+
+ if (containsUrineSample) {
+ isUrine = true;
+ break;
+ }
+ }
+
+ const orderMetadata = medusaOrder.metadata as {
+ partner_location_name?: string;
+ };
+
+ await sendEmailFromTemplate(
+ renderOrderProcessingEmail,
+ {
+ language: userContact.preferred_locale ?? 'et',
+ recipientName: getFullName(userContact.name, userContact.last_name),
+ partnerLocation: orderMetadata.partner_location_name ?? 'SYNLAB',
+ isUrine,
+ },
+ userContact.email,
+ );
+
+ return createNotificationLog({
+ action: NotificationAction.PATIENT_ORDER_PROCESSING,
+ status: 'SUCCESS',
+ relatedRecordId: analysisOrder.id,
+ });
+ }
+
+ async sendPatientUpdateNotification(
+ analysisOrder: AnalysisOrder,
+ template: EmailRenderer,
+ action: NotificationAction,
+ ) {
+ const logger = await getLogger();
+
+ const userContact = await getUserContactAdmin(analysisOrder.user_id);
+
+ if (userContact?.email) {
+ await sendEmailFromTemplate(
+ template,
+ {
+ analysisOrderId: analysisOrder.id,
+ recipientName: getFullName(userContact.name, userContact.last_name),
+ language: userContact.preferred_locale ?? 'et',
+ },
+ userContact.email,
+ );
+ await createNotificationLog({
+ action,
+ status: 'SUCCESS',
+ relatedRecordId: analysisOrder.id,
+ });
+ logger.info(
+ { analysisOrderId: analysisOrder.id, namespace: this.namespace },
+ 'Sent notification email',
+ );
+ } else {
+ await createNotificationLog({
+ action,
+ status: 'FAIL',
+ comment: 'No email found for ' + analysisOrder.user_id,
+ relatedRecordId: analysisOrder.id,
+ });
+ logger.warn(
+ { analysisOrderId: analysisOrder.id, namespace: this.namespace },
+ 'No email found ',
+ );
+ }
+ }
+
+ async sendPartialAnalysisResultsNotifications(analysisOrder: AnalysisOrder) {
+ const logger = await getLogger();
+
+ await this.sendPatientUpdateNotification(
+ analysisOrder,
+ renderPatientFirstResultsReceivedEmail,
+ NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED,
+ );
+
+ const doctorAccounts = await getDoctorAccounts();
+ const doctorEmails: string[] = doctorAccounts
+ .map(({ email }) => email)
+ .filter((email): email is string => !!email);
+
+ await sendEmailFromTemplate(
+ renderFirstResultsReceivedEmail,
+ {
+ analysisOrderId: analysisOrder.id,
+ language: 'et',
+ },
+ doctorEmails,
+ );
+
+ logger.info(
+ { analysisOrderId: analysisOrder.id, namespace: this.namespace },
+ 'Sent out partial analysis results notifications for doctors',
+ );
+
+ await createNotificationLog({
+ action: NotificationAction.DOCTOR_NEW_JOBS,
+ status: 'SUCCESS',
+ relatedRecordId: analysisOrder.id,
+ });
+ }
+
+ async sendFullAnalysisResultsNotifications(analysisOrder: AnalysisOrder) {
+ await this.sendPatientUpdateNotification(
+ analysisOrder,
+ renderPatientFullResultsReceivedEmail,
+ NotificationAction.PATIENT_FULL_RESULTS_RECEIVED,
+ );
+
+ const doctorAccount = await getAssignedDoctorAccount(analysisOrder.id);
+ const assignedDoctorEmail = doctorAccount?.email;
+
+ if (!assignedDoctorEmail) {
+ return;
+ }
+
+ await sendEmailFromTemplate(
+ renderAllResultsReceivedEmail,
+ {
+ analysisOrderId: analysisOrder.id,
+ language: 'et',
+ },
+ assignedDoctorEmail,
+ );
+
+ return createNotificationLog({
+ action: NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED,
+ status: 'SUCCESS',
+ relatedRecordId: analysisOrder.id,
+ });
+ }
+}
diff --git a/packages/shared/src/components/select-analysis-package.tsx b/packages/shared/src/components/select-analysis-package.tsx
index ef05d0a..f46d2bc 100644
--- a/packages/shared/src/components/select-analysis-package.tsx
+++ b/packages/shared/src/components/select-analysis-package.tsx
@@ -5,12 +5,10 @@ import { useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
-import { StoreProduct } from '@medusajs/types';
-import { Button } from '@medusajs/ui';
+import type { AdminProductVariant, StoreProduct } from '@medusajs/types';
import { useTranslation } from 'react-i18next';
-import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
-import { toast } from '@kit/ui/sonner';
+import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
@@ -18,18 +16,27 @@ import {
CardFooter,
CardHeader,
} from '@kit/ui/card';
+import { toast } from '@kit/ui/sonner';
+import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
-import { ButtonTooltip } from './ui/button-tooltip';
-import { PackageHeader } from './package-header';
-import { pathsConfig } from '../config';
-export type AnalysisPackageWithVariant = Pick & {
+import { handleAddToCart } from '~/lib/services/medusaCart.service';
+
+import { pathsConfig } from '../config';
+import { PackageHeader } from './package-header';
+import { ButtonTooltip } from './ui/button-tooltip';
+
+export type AnalysisPackageWithVariant = Pick<
+ StoreProduct,
+ 'title' | 'description' | 'subtitle' | 'metadata'
+> & {
variantId: string;
nrOfAnalyses: number;
price: number;
isStandard: boolean;
isStandardPlus: boolean;
isPremium: boolean;
+ variant: Pick;
};
export default function SelectAnalysisPackage({
@@ -37,7 +44,7 @@ export default function SelectAnalysisPackage({
countryCode,
}: {
analysisPackage: AnalysisPackageWithVariant;
- countryCode: string,
+ countryCode: string;
}) {
const router = useRouter();
const {
@@ -46,8 +53,15 @@ export default function SelectAnalysisPackage({
} = useTranslation();
const [isAddingToCart, setIsAddingToCart] = useState(false);
-
- const { nrOfAnalyses, variantId, title, subtitle = '', description = '', price } = analysisPackage;
+
+ const {
+ nrOfAnalyses,
+ variantId,
+ title,
+ subtitle = '',
+ description = '',
+ price,
+ } = analysisPackage;
const handleSelect = async () => {
setIsAddingToCart(true);
@@ -57,10 +71,16 @@ export default function SelectAnalysisPackage({
countryCode,
});
setIsAddingToCart(false);
- toast.success();
+ toast.success(
+ ,
+ );
router.push(pathsConfig.app.cart);
} catch (e) {
- toast.error();
+ toast.error(
+ ,
+ );
setIsAddingToCart(false);
console.error(e);
}
@@ -86,7 +106,7 @@ export default function SelectAnalysisPackage({
{subtitle}
-
diff --git a/packages/shared/src/components/ui/info-tooltip.tsx b/packages/shared/src/components/ui/info-tooltip.tsx
index 10a7ae3..1217c24 100644
--- a/packages/shared/src/components/ui/info-tooltip.tsx
+++ b/packages/shared/src/components/ui/info-tooltip.tsx
@@ -23,7 +23,7 @@ export function InfoTooltip({
{icon || }
- {content}
+ {content}
);
diff --git a/packages/shared/src/config/auth-providers.service.ts b/packages/shared/src/config/auth-providers.service.ts
new file mode 100644
index 0000000..179e7da
--- /dev/null
+++ b/packages/shared/src/config/auth-providers.service.ts
@@ -0,0 +1,144 @@
+import type { Provider } from '@supabase/supabase-js';
+import authConfig from './auth.config';
+
+type SupabaseExternalProvider = Provider | 'email';
+interface SupabaseAuthSettings {
+ external: Record;
+ disable_signup: boolean;
+ mailer_autoconfirm: boolean;
+}
+
+export class AuthProvidersService {
+ private supabaseUrl: string;
+ private cache: Map = new Map();
+ private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+
+ constructor(supabaseUrl: string) {
+ this.supabaseUrl = supabaseUrl;
+ }
+
+ async fetchAuthSettings(): Promise {
+ try {
+ const cacheKey = 'auth-settings';
+ const cached = this.cache.get(cacheKey);
+
+ if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
+ return cached.data;
+ }
+
+ const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+ if (!anonKey) {
+ throw new Error('NEXT_PUBLIC_SUPABASE_ANON_KEY is required');
+ }
+
+ const response = await fetch(`${this.supabaseUrl}/auth/v1/settings?apikey=${anonKey}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ console.warn('Failed to fetch auth settings from Supabase:', response.status);
+ return null;
+ }
+
+ const settings: SupabaseAuthSettings = await response.json();
+
+ this.cache.set(cacheKey, { data: settings, timestamp: Date.now() });
+
+ return settings;
+ } catch (error) {
+ console.warn('Error fetching auth settings from Supabase:', error);
+ return null;
+ }
+ }
+
+ isPasswordEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean {
+ if (settings) {
+ return settings.external.email === true && !settings.disable_signup;
+ }
+
+ return process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true';
+ }
+
+ isMailerAutoconfirmEnabled({ settings }: { settings: SupabaseAuthSettings | null }): boolean {
+ return settings?.mailer_autoconfirm === true;
+ }
+
+ isMagicLinkEnabled(): boolean {
+ return process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true';
+ }
+
+ isOAuthProviderEnabled({
+ provider,
+ settings,
+ }: {
+ provider: SupabaseExternalProvider;
+ settings: SupabaseAuthSettings | null;
+ }): boolean {
+ if (settings && settings.external) {
+ return settings.external[provider] === true;
+ }
+
+ return false;
+ }
+
+ getEnabledOAuthProviders({ settings }: { settings: SupabaseAuthSettings | null }): SupabaseExternalProvider[] {
+ const enabledProviders: SupabaseExternalProvider[] = [];
+
+ if (settings && settings.external) {
+ for (const [providerName, isEnabled] of Object.entries(settings.external)) {
+ if (isEnabled && providerName !== 'email') {
+ enabledProviders.push(providerName as SupabaseExternalProvider);
+ }
+ }
+ return enabledProviders;
+ }
+
+ const potentialProviders: SupabaseExternalProvider[] = ['keycloak'];
+ const enabledFallback: SupabaseExternalProvider[] = [];
+
+ for (const provider of potentialProviders) {
+ if (provider !== 'email' && this.isOAuthProviderEnabled({ provider, settings })) {
+ enabledFallback.push(provider);
+ }
+ }
+
+ return enabledFallback;
+ }
+
+ async getAuthConfig() {
+ const settings = await this.fetchAuthSettings();
+ const [passwordEnabled, magicLinkEnabled, oAuthProviders, isMailerAutoconfirmEnabled] = await Promise.all([
+ this.isPasswordEnabled({ settings }),
+ this.isMagicLinkEnabled(),
+ this.getEnabledOAuthProviders({ settings }),
+ this.isMailerAutoconfirmEnabled({ settings }),
+ ]);
+
+ return {
+ providers: {
+ password: passwordEnabled,
+ magicLink: magicLinkEnabled,
+ oAuth: oAuthProviders,
+ },
+ displayTermsCheckbox: authConfig.displayTermsCheckbox,
+ isMailerAutoconfirmEnabled,
+ };
+ }
+
+ clearCache(): void {
+ this.cache.clear();
+ }
+}
+
+export function createAuthProvidersService(): AuthProvidersService {
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+
+ if (!supabaseUrl) {
+ throw new Error('NEXT_PUBLIC_SUPABASE_URL is required');
+ }
+
+ return new AuthProvidersService(supabaseUrl);
+}
diff --git a/packages/shared/src/config/auth.config.ts b/packages/shared/src/config/auth.config.ts
index 9e73291..ab460ee 100644
--- a/packages/shared/src/config/auth.config.ts
+++ b/packages/shared/src/config/auth.config.ts
@@ -32,7 +32,7 @@ const authConfig = AuthConfigSchema.parse({
providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
- oAuth: ['google'],
+ oAuth: ['keycloak'],
},
} satisfies z.infer);
diff --git a/packages/shared/src/config/dynamic-auth.config.ts b/packages/shared/src/config/dynamic-auth.config.ts
new file mode 100644
index 0000000..571516a
--- /dev/null
+++ b/packages/shared/src/config/dynamic-auth.config.ts
@@ -0,0 +1,114 @@
+import type { Provider } from '@supabase/supabase-js';
+import { z } from 'zod';
+import { createAuthProvidersService } from './auth-providers.service';
+
+const providers: z.ZodType = getProviders();
+
+const DynamicAuthConfigSchema = z.object({
+ providers: z.object({
+ password: z.boolean().describe('Enable password authentication.'),
+ magicLink: z.boolean().describe('Enable magic link authentication.'),
+ oAuth: providers.array(),
+ }),
+ displayTermsCheckbox: z.boolean().describe('Whether to display the terms checkbox during sign-up.'),
+ isMailerAutoconfirmEnabled: z.boolean().describe('Whether Supabase sends confirmation email automatically.'),
+});
+
+export type DynamicAuthConfig = {
+ providers: {
+ password: boolean;
+ magicLink: boolean;
+ oAuth: Provider[];
+ };
+ displayTermsCheckbox: boolean | undefined;
+ isMailerAutoconfirmEnabled: boolean;
+}
+
+export async function getDynamicAuthConfig() {
+ const authService = createAuthProvidersService();
+ const dynamicProviders = await authService.getAuthConfig();
+
+ const config = {
+ providers: dynamicProviders.providers,
+ displayTermsCheckbox: dynamicProviders.displayTermsCheckbox,
+ isMailerAutoconfirmEnabled: dynamicProviders.isMailerAutoconfirmEnabled,
+ };
+
+ return DynamicAuthConfigSchema.parse(config);
+}
+
+export async function getCachedAuthConfig() {
+ if (typeof window !== 'undefined') {
+ const cached = sessionStorage.getItem('auth-config');
+ if (cached) {
+ try {
+ const { data, timestamp } = JSON.parse(cached);
+ // Cache for 5 minutes
+ if (Date.now() - timestamp < 5 * 60 * 1000) {
+ return data;
+ }
+ } catch (error) {
+ console.warn('Invalid auth config cache:', error);
+ }
+ }
+ }
+
+ const config = await getDynamicAuthConfig();
+
+ if (typeof window !== 'undefined') {
+ try {
+ sessionStorage.setItem('auth-config', JSON.stringify({
+ data: config,
+ timestamp: Date.now(),
+ }));
+ } catch (error) {
+ console.warn('Failed to cache auth config:', error);
+ }
+ }
+
+ return config;
+}
+
+export async function getServerAuthConfig() {
+ return getDynamicAuthConfig();
+}
+
+export async function isProviderEnabled(provider: 'password' | 'magicLink' | Provider): Promise {
+ const authService = createAuthProvidersService();
+ const settings = await authService.fetchAuthSettings();
+
+ switch (provider) {
+ case 'password':
+ return authService.isPasswordEnabled({ settings });
+ case 'magicLink':
+ return authService.isMagicLinkEnabled();
+ default:
+ return authService.isOAuthProviderEnabled({ provider, settings });
+ }
+}
+
+function getProviders() {
+ return z.enum([
+ 'apple',
+ 'azure',
+ 'bitbucket',
+ 'discord',
+ 'facebook',
+ 'figma',
+ 'github',
+ 'gitlab',
+ 'google',
+ 'kakao',
+ 'keycloak',
+ 'linkedin',
+ 'linkedin_oidc',
+ 'notion',
+ 'slack',
+ 'spotify',
+ 'twitch',
+ 'twitter',
+ 'workos',
+ 'zoom',
+ 'fly',
+ ]);
+}
diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts
index 516ecc7..d1737bb 100644
--- a/packages/shared/src/config/index.ts
+++ b/packages/shared/src/config/index.ts
@@ -8,6 +8,7 @@ import {
createPath,
getTeamAccountSidebarConfig,
} from './team-account-navigation.config';
+import { DynamicAuthConfig, getCachedAuthConfig, getServerAuthConfig } from './dynamic-auth.config';
export {
appConfig,
@@ -18,4 +19,7 @@ export {
getTeamAccountSidebarConfig,
pathsConfig,
personalAccountNavigationConfig,
+ getCachedAuthConfig,
+ getServerAuthConfig,
+ type DynamicAuthConfig,
};
diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts
index 95e4bfd..b551263 100644
--- a/packages/shared/src/hooks/index.ts
+++ b/packages/shared/src/hooks/index.ts
@@ -1,2 +1,3 @@
export * from './use-csrf-token';
export * from './use-current-locale-language-names';
+export * from './use-auth-config';
diff --git a/packages/shared/src/hooks/use-auth-config.ts b/packages/shared/src/hooks/use-auth-config.ts
new file mode 100644
index 0000000..5282554
--- /dev/null
+++ b/packages/shared/src/hooks/use-auth-config.ts
@@ -0,0 +1,76 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import type { Provider } from '@supabase/supabase-js';
+import { getCachedAuthConfig } from '../config/dynamic-auth.config';
+import { authConfig } from '../config';
+
+interface AuthConfig {
+ providers: {
+ password: boolean;
+ magicLink: boolean;
+ oAuth: Provider[];
+ };
+}
+
+interface UseAuthConfigResult {
+ config: AuthConfig | null;
+ loading: boolean;
+ error: Error | null;
+ refetch: () => Promise;
+}
+
+export function useAuthConfig(): UseAuthConfigResult {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchConfig = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const authConfig = await getCachedAuthConfig();
+ setConfig(authConfig);
+ } catch (err) {
+ console.error('Failed to fetch auth config', err);
+ setError(err instanceof Error ? err : new Error('Failed to fetch auth config'));
+ setConfig(authConfig);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchConfig();
+ }, []);
+
+ return {
+ config,
+ loading,
+ error,
+ refetch: fetchConfig,
+ };
+}
+
+export function useProviderEnabled(provider: 'password' | 'magicLink' | Provider) {
+ const { config, loading, error } = useAuthConfig();
+
+ const isEnabled = (() => {
+ if (!config) return false;
+
+ switch (provider) {
+ case 'password':
+ return config.providers.password;
+ case 'magicLink':
+ return config.providers.magicLink;
+ default:
+ return config.providers.oAuth.includes(provider);
+ }
+ })();
+
+ return {
+ enabled: isEnabled,
+ loading,
+ error,
+ };
+}
diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts
index 971a03e..877cbca 100644
--- a/packages/shared/src/utils.ts
+++ b/packages/shared/src/utils.ts
@@ -1,5 +1,5 @@
import { format } from 'date-fns';
-import Isikukood, { Gender } from 'isikukood';
+import Isikukood from 'isikukood';
/**
* Check if the code is running in a browser environment.
diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json
index c4697e9..f4e91a9 100644
--- a/packages/shared/tsconfig.json
+++ b/packages/shared/tsconfig.json
@@ -1,7 +1,10 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
- "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
+ "paths": {
+ "~/lib/*": ["../../lib/*"]
+ }
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
diff --git a/packages/supabase/src/auth-callback.service.ts b/packages/supabase/src/auth-callback.service.ts
index 1190bc2..c1d8126 100644
--- a/packages/supabase/src/auth-callback.service.ts
+++ b/packages/supabase/src/auth-callback.service.ts
@@ -4,6 +4,7 @@ import {
AuthError,
type EmailOtpType,
SupabaseClient,
+ User,
} from '@supabase/supabase-js';
/**
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
* @description Service for handling auth callbacks in Supabase
*/
class AuthCallbackService {
- constructor(private readonly client: SupabaseClient) {}
+ constructor(private readonly client: SupabaseClient) { }
/**
* @name verifyTokenHash
@@ -128,89 +129,117 @@ class AuthCallbackService {
/**
* @name exchangeCodeForSession
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
- * @param request
- * @param params
+ * @param authCode
*/
- async exchangeCodeForSession(
- request: Request,
- params: {
- joinTeamPath: string;
- redirectPath: string;
- errorPath?: string;
- },
- ): Promise<{
- nextPath: string;
- }> {
- const requestUrl = new URL(request.url);
- const searchParams = requestUrl.searchParams;
+ async exchangeCodeForSession(authCode: string): Promise<{
+ isSuccess: boolean;
+ user: User;
+ } | ErrorURLParameters> {
+ let user: User;
+ try {
+ const { data, error } =
+ await this.client.auth.exchangeCodeForSession(authCode);
- const authCode = searchParams.get('code');
- const error = searchParams.get('error');
- const nextUrlPathFromParams = searchParams.get('next');
- const inviteToken = searchParams.get('invite_token');
- const errorPath = params.errorPath ?? '/auth/callback/error';
-
- let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
-
- // if we have an invite token, we redirect to the join team page
- // instead of the default next url. This is because the user is trying
- // to join a team and we want to make sure they are redirected to the
- // correct page.
- if (inviteToken) {
- const emailParam = searchParams.get('email');
-
- const urlParams = new URLSearchParams({
- invite_token: inviteToken,
- email: emailParam ?? '',
- });
-
- nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
- }
-
- if (authCode) {
- try {
- const { error } =
- await this.client.auth.exchangeCodeForSession(authCode);
-
- // if we have an error, we redirect to the error page
- if (error) {
- return onError({
- code: error.code,
- error: error.message,
- path: errorPath,
- });
- }
- } catch (error) {
- console.error(
- {
- error,
- name: `auth.callback`,
- },
- `An error occurred while exchanging code for session`,
- );
-
- const message = error instanceof Error ? error.message : error;
-
- return onError({
- code: (error as AuthError)?.code,
- error: message as string,
- path: errorPath,
+ // if we have an error, we redirect to the error page
+ if (error) {
+ return getErrorURLParameters({
+ code: error.code,
+ error: error.message,
});
}
- }
- if (error) {
- return onError({
- error,
- path: errorPath,
+ // Handle Keycloak users - set up Medusa integration
+ if (data?.user && this.isKeycloakUser(data.user)) {
+ await this.setupMedusaUserForKeycloak(data.user);
+ }
+
+ user = data.user;
+ } catch (error) {
+ console.error(
+ {
+ error,
+ name: `auth.callback`,
+ },
+ `An error occurred while exchanging code for session`,
+ );
+
+ const message = error instanceof Error ? error.message : error;
+
+ return getErrorURLParameters({
+ code: (error as AuthError)?.code,
+ error: message as string,
});
}
return {
- nextPath: nextUrl,
+ isSuccess: true,
+ user,
};
}
+ /**
+ * Check if user is from Keycloak provider
+ */
+ private isKeycloakUser(user: any): boolean {
+ return user?.app_metadata?.provider === 'keycloak' ||
+ user?.app_metadata?.providers?.includes('keycloak');
+ }
+
+ private async setupMedusaUserForKeycloak(user: any): Promise {
+ if (!user.email) {
+ console.warn('Keycloak user has no email, skipping Medusa setup');
+ return;
+ }
+
+ try {
+ // Check if user already has medusa_account_id
+ const { data: accountData, error: fetchError } = await this.client
+ .schema('medreport')
+ .from('accounts')
+ .select('medusa_account_id, name, last_name')
+ .eq('primary_owner_user_id', user.id)
+ .eq('is_personal_account', true)
+ .single();
+
+ if (fetchError && fetchError.code !== 'PGRST116') {
+ console.error('Error fetching account data for Keycloak user:', fetchError);
+ return;
+ }
+
+ // If user already has Medusa account, we're done
+ if (accountData?.medusa_account_id) {
+ console.log('Keycloak user already has Medusa account:', accountData.medusa_account_id);
+ return;
+ }
+
+ const { medusaLoginOrRegister } = await import('../../features/medusa-storefront/src/lib/data/customer');
+
+ const medusaAccountId = await medusaLoginOrRegister({
+ email: user.email,
+ supabaseUserId: user.id,
+ name: accountData?.name ?? '-',
+ lastName: accountData?.last_name ?? '-',
+ });
+
+ // Update the account with the Medusa account ID
+ const { error: updateError } = await this.client
+ .schema('medreport')
+ .from('accounts')
+ .update({ medusa_account_id: medusaAccountId })
+ .eq('primary_owner_user_id', user.id)
+ .eq('is_personal_account', true);
+
+ if (updateError) {
+ console.error('Error updating account with Medusa ID:', updateError);
+ return;
+ }
+
+ console.log('Successfully set up Medusa account for Keycloak user:', medusaAccountId);
+ } catch (error) {
+ console.error('Error setting up Medusa account for Keycloak user:', error);
+ }
+ }
+
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
url.host = host as string;
@@ -231,15 +260,19 @@ class AuthCallbackService {
}
}
-function onError({
+interface ErrorURLParameters {
+ error: string;
+ code?: string;
+ searchParams: string;
+}
+
+export function getErrorURLParameters({
error,
- path,
code,
}: {
error: string;
- path: string;
code?: string;
-}) {
+}): ErrorURLParameters {
const errorMessage = getAuthErrorMessage({ error, code });
console.error(
@@ -255,10 +288,10 @@ function onError({
code: code ?? '',
});
- const nextPath = `${path}?${searchParams.toString()}`;
-
return {
- nextPath,
+ error: errorMessage,
+ code: code ?? '',
+ searchParams: searchParams.toString(),
};
}
diff --git a/packages/supabase/src/clients/browser-client.ts b/packages/supabase/src/clients/browser-client.ts
index 747945e..69bb463 100644
--- a/packages/supabase/src/clients/browser-client.ts
+++ b/packages/supabase/src/clients/browser-client.ts
@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
export function getSupabaseBrowserClient() {
const keys = getSupabaseClientKeys();
- return createBrowserClient(keys.url, keys.anonKey);
+ return createBrowserClient(keys.url, keys.anonKey, {
+ auth: {
+ flowType: 'pkce',
+ autoRefreshToken: true,
+ persistSession: true,
+ },
+ });
}
diff --git a/packages/supabase/src/clients/middleware-client.ts b/packages/supabase/src/clients/middleware-client.ts
index 608dc3b..e9c0a31 100644
--- a/packages/supabase/src/clients/middleware-client.ts
+++ b/packages/supabase/src/clients/middleware-client.ts
@@ -20,6 +20,11 @@ export function createMiddlewareClient(
const keys = getSupabaseClientKeys();
return createServerClient(keys.url, keys.anonKey, {
+ auth: {
+ flowType: 'pkce',
+ autoRefreshToken: true,
+ persistSession: true,
+ },
cookies: {
getAll() {
return request.cookies.getAll();
diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts
index cd4c82c..c9b8d7e 100644
--- a/packages/supabase/src/clients/server-client.ts
+++ b/packages/supabase/src/clients/server-client.ts
@@ -15,6 +15,11 @@ export function getSupabaseServerClient() {
const keys = getSupabaseClientKeys();
return createServerClient(keys.url, keys.anonKey, {
+ auth: {
+ flowType: 'pkce',
+ autoRefreshToken: true,
+ persistSession: true,
+ },
cookies: {
async getAll() {
const cookieStore = await cookies();
diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts
index a4f8cc1..a09d6b8 100644
--- a/packages/supabase/src/database.types.ts
+++ b/packages/supabase/src/database.types.ts
@@ -1257,6 +1257,26 @@ export type Database = {
},
]
}
+ medipost_actions: {
+ Row: {
+ created_at: string
+ id: number
+ action: string
+ xml: string
+ has_analysis_results: boolean
+ medusa_order_id: string
+ response_xml: string
+ has_error: boolean
+ }
+ Insert: {
+ action: string
+ xml: string
+ has_analysis_results: boolean
+ medusa_order_id: string
+ response_xml: string
+ has_error: boolean
+ }
+ }
medreport_product_groups: {
Row: {
created_at: string
@@ -2053,6 +2073,7 @@ export type Database = {
p_personal_code: string
p_phone: string
p_uid: string
+ p_email: string
}
Returns: undefined
}
diff --git a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts
index 6ed91c9..549bc1b 100644
--- a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts
+++ b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts
@@ -28,6 +28,7 @@ export function useSignInWithEmailPassword() {
const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email,
password: credentials.password,
+ isDevPasswordLogin: true,
});
await client
.schema('medreport').from('accounts')
diff --git a/packages/supabase/src/hooks/use-sign-in-with-provider.ts b/packages/supabase/src/hooks/use-sign-in-with-provider.ts
index d68700b..7361549 100644
--- a/packages/supabase/src/hooks/use-sign-in-with-provider.ts
+++ b/packages/supabase/src/hooks/use-sign-in-with-provider.ts
@@ -9,7 +9,13 @@ export function useSignInWithProvider() {
const mutationKey = ['auth', 'sign-in-with-provider'];
const mutationFn = async (credentials: SignInWithOAuthCredentials) => {
- const response = await client.auth.signInWithOAuth(credentials);
+ const response = await client.auth.signInWithOAuth({
+ ...credentials,
+ options: {
+ ...credentials.options,
+ redirectTo: `${window.location.origin}/auth/callback`,
+ },
+ });
if (response.error) {
throw response.error.message;
diff --git a/packages/supabase/src/hooks/use-sign-out.ts b/packages/supabase/src/hooks/use-sign-out.ts
index fbe65ee..7a64bd3 100644
--- a/packages/supabase/src/hooks/use-sign-out.ts
+++ b/packages/supabase/src/hooks/use-sign-out.ts
@@ -1,15 +1,28 @@
import { useMutation } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
-import { signout } from '../../../features/medusa-storefront/src/lib/data/customer';
export function useSignOut() {
const client = useSupabase();
return useMutation({
mutationFn: async () => {
- await signout(undefined, false);
- return client.auth.signOut();
+ try {
+ try {
+ const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer');
+ await medusaLogout(undefined, false);
+ } catch (medusaError) {
+ console.warn('Medusa logout failed or not available:', medusaError);
+ }
+
+ const { error } = await client.auth.signOut();
+ if (error) {
+ throw error;
+ }
+ } catch (error) {
+ console.error('Logout error:', error);
+ throw error;
+ }
},
});
}
diff --git a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts
index f6dc21f..59a864c 100644
--- a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts
+++ b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts
@@ -43,6 +43,7 @@ export function useSignUpWithEmailAndPassword() {
const medusaAccountId = await medusaLoginOrRegister({
email: credentials.email,
password: credentials.password,
+ isDevPasswordLogin: true,
});
await client
.schema('medreport').from('accounts')
diff --git a/packages/supabase/src/hooks/use-user.ts b/packages/supabase/src/hooks/use-user.ts
index 0986775..9a9cdd9 100644
--- a/packages/supabase/src/hooks/use-user.ts
+++ b/packages/supabase/src/hooks/use-user.ts
@@ -28,8 +28,8 @@ export function useUser(initialData?: User | null) {
queryFn,
queryKey,
initialData,
- refetchInterval: false,
- refetchOnMount: false,
- refetchOnWindowFocus: false,
+ refetchInterval: 2_000,
+ refetchOnMount: true,
+ refetchOnWindowFocus: true,
});
}
diff --git a/packages/ui/src/makerkit/app-breadcrumbs.tsx b/packages/ui/src/makerkit/app-breadcrumbs.tsx
index 31296b2..dc38b90 100644
--- a/packages/ui/src/makerkit/app-breadcrumbs.tsx
+++ b/packages/ui/src/makerkit/app-breadcrumbs.tsx
@@ -1,6 +1,7 @@
'use client';
import { Fragment } from 'react';
+import clsx from 'clsx';
import { usePathname } from 'next/navigation';
@@ -52,9 +53,13 @@ export function AppBreadcrumbs(props: {
/>
);
+ const isLast = index === visiblePaths.length - 1;
+
return (
-
+
{label}
@@ -77,7 +83,7 @@ export function AppBreadcrumbs(props: {
>
)}
-
+
diff --git a/packages/ui/src/makerkit/language-selector.tsx b/packages/ui/src/makerkit/language-selector.tsx
index f7542f0..6ac5855 100644
--- a/packages/ui/src/makerkit/language-selector.tsx
+++ b/packages/ui/src/makerkit/language-selector.tsx
@@ -53,6 +53,7 @@ export function LanguageSelector({
}
if (!userId) {
+ localStorage.setItem('lang', locale);
return i18n.changeLanguage(locale);
}
diff --git a/packages/ui/src/makerkit/page.tsx b/packages/ui/src/makerkit/page.tsx
index 7a4ee42..aaf48ad 100644
--- a/packages/ui/src/makerkit/page.tsx
+++ b/packages/ui/src/makerkit/page.tsx
@@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) {
>
{MobileNavigation}
-
@@ -106,7 +106,7 @@ export function PageBody(
}>,
) {
const className = cn(
- 'flex w-full flex-1 flex-col space-y-6 lg:px-4',
+ 'flex w-full flex-1 flex-col space-y-6',
props.className,
);
@@ -119,8 +119,8 @@ export function PageNavigation(props: React.PropsWithChildren) {
export function PageDescription(props: React.PropsWithChildren) {
return (
-
-
+
@@ -158,7 +158,7 @@ export function PageHeader({
return (
@@ -168,7 +168,7 @@ export function PageHeader({
-
+
{displaySidebarTrigger ? (
) : null}
diff --git a/packages/ui/src/shadcn/card.tsx b/packages/ui/src/shadcn/card.tsx
index fec5a6b..9841c3e 100644
--- a/packages/ui/src/shadcn/card.tsx
+++ b/packages/ui/src/shadcn/card.tsx
@@ -34,7 +34,7 @@ const CardHeader: React.FC
> = ({
className,
...props
}) => (
-
+
);
CardHeader.displayName = 'CardHeader';
@@ -60,14 +60,14 @@ CardDescription.displayName = 'CardDescription';
const CardContent: React.FC> = ({
className,
...props
-}) => ;
+}) => ;
CardContent.displayName = 'CardContent';
const CardFooter: React.FC> = ({
className,
...props
}) => (
-
+
);
CardFooter.displayName = 'CardFooter';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c7b691e..77d0440 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,6 +128,9 @@ importers:
jsonwebtoken:
specifier: 9.0.2
version: 9.0.2
+ libphonenumber-js:
+ specifier: ^1.12.15
+ version: 1.12.15
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -217,8 +220,8 @@ importers:
specifier: ^16.5.0
version: 16.6.1
pino-pretty:
- specifier: ^13.0.0
- version: 13.1.1
+ specifier: 13.0.0
+ version: 13.0.0
prettier:
specifier: ^3.5.3
version: 3.6.2
@@ -8174,6 +8177,9 @@ packages:
engines: {node: '>=16'}
hasBin: true
+ libphonenumber-js@1.12.15:
+ resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==}
+
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -8887,8 +8893,8 @@ packages:
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
- pino-pretty@13.1.1:
- resolution: {integrity: sha512-TNNEOg0eA0u+/WuqH0MH0Xui7uqVk9D74ESOpjtebSQYbNWJk/dIxCXIxFsNfeN53JmtWqYHP2OrIZjT/CBEnA==}
+ pino-pretty@13.0.0:
+ resolution: {integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==}
hasBin: true
pino-std-serializers@7.0.0:
@@ -9589,8 +9595,8 @@ packages:
scroll-into-view-if-needed@3.1.0:
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
- secure-json-parse@4.0.0:
- resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==}
+ secure-json-parse@2.7.0:
+ resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
@@ -9814,10 +9820,6 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
- strip-json-comments@5.0.3:
- resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
- engines: {node: '>=14.16'}
-
stripe@18.5.0:
resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==}
engines: {node: '>=12.*'}
@@ -20630,6 +20632,8 @@ snapshots:
dependencies:
isomorphic.js: 0.2.5
+ libphonenumber-js@1.12.15: {}
+
lightningcss-darwin-arm64@1.30.1:
optional: true
@@ -21523,7 +21527,7 @@ snapshots:
dependencies:
split2: 4.2.0
- pino-pretty@13.1.1:
+ pino-pretty@13.0.0:
dependencies:
colorette: 2.0.20
dateformat: 4.6.3
@@ -21535,9 +21539,9 @@ snapshots:
on-exit-leak-free: 2.1.2
pino-abstract-transport: 2.0.0
pump: 3.0.3
- secure-json-parse: 4.0.0
+ secure-json-parse: 2.7.0
sonic-boom: 4.2.0
- strip-json-comments: 5.0.3
+ strip-json-comments: 3.1.1
pino-std-serializers@7.0.0: {}
@@ -22491,7 +22495,7 @@ snapshots:
dependencies:
compute-scroll-into-view: 3.1.1
- secure-json-parse@4.0.0: {}
+ secure-json-parse@2.7.0: {}
selderee@0.11.0:
dependencies:
@@ -22813,8 +22817,6 @@ snapshots:
strip-json-comments@3.1.1: {}
- strip-json-comments@5.0.3: {}
-
stripe@18.5.0(@types/node@24.3.0):
dependencies:
qs: 6.14.0
diff --git a/public/locales/en/account.json b/public/locales/en/account.json
index 8e7020c..2872ede 100644
--- a/public/locales/en/account.json
+++ b/public/locales/en/account.json
@@ -130,7 +130,10 @@
"description": "Please enter your personal details to continue",
"button": "Continue",
"userConsentLabel": "I agree to the use of personal data on the platform",
- "userConsentUrlTitle": "View privacy policy"
+ "userConsentUrlTitle": "View privacy policy",
+ "updateAccountLoading": "Updating account details...",
+ "updateAccountSuccess": "Account details updated",
+ "updateAccountError": "Updating account details error"
},
"consentModal": {
"title": "Before we start",
diff --git a/public/locales/en/auth.json b/public/locales/en/auth.json
index 7db0925..5c89064 100644
--- a/public/locales/en/auth.json
+++ b/public/locales/en/auth.json
@@ -22,6 +22,7 @@
"alreadyHaveAccountStatement": "I already have an account, I want to sign in instead",
"doNotHaveAccountStatement": "I do not have an account, I want to sign up instead",
"signInWithProvider": "Sign in with {{provider}}",
+ "signInWithKeycloak": "Smart-ID/Mobile-ID/ID-card",
"signInWithPhoneNumber": "Sign in with Phone Number",
"signInWithEmail": "Sign in with Email",
"signUpWithEmail": "Sign up with Email",
diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json
index 84221a4..223d3f1 100644
--- a/public/locales/en/cart.json
+++ b/public/locales/en/cart.json
@@ -3,9 +3,6 @@
"description": "View your cart",
"emptyCartMessage": "Your cart is empty",
"emptyCartMessageDescription": "Add items to your cart to continue.",
- "subtotal": "Subtotal",
- "total": "Total",
- "promotionsTotal": "Promotions total",
"table": {
"item": "Item",
"quantity": "Quantity",
@@ -25,13 +22,19 @@
"timeoutAction": "Continue"
},
"discountCode": {
- "title": "Gift card or promotion code",
- "label": "Add Promotion Code(s)",
+ "title": "Gift card or promo code",
+ "label": "Add Promo Code(s)",
"apply": "Apply",
- "subtitle": "If you wish, you can add a promotion code",
- "placeholder": "Enter promotion code",
- "remove": "Remove promotion code",
- "appliedCodes": "Promotion(s) applied:"
+ "subtitle": "If you wish, you can add a promo code",
+ "placeholder": "Enter promo code",
+ "remove": "Remove promo code",
+ "appliedCodes": "Promotions(s) applied:",
+ "removeError": "Failed to remove promo code",
+ "removeSuccess": "Promo code removed",
+ "removeLoading": "Removing promo code...",
+ "addError": "Failed to add promo code",
+ "addSuccess": "Promo code added",
+ "addLoading": "Setting promo code..."
},
"items": {
"synlabAnalyses": {
@@ -52,7 +55,11 @@
}
},
"order": {
- "title": "Order"
+ "title": "Order",
+ "promotionsTotal": "Promotions total",
+ "subtotal": "Subtotal",
+ "total": "Total",
+ "giftCard": "Gift card"
},
"orderConfirmed": {
"title": "Order confirmed",
@@ -64,7 +71,8 @@
"orderDate": "Order date",
"orderNumber": "Order number",
"orderStatus": "Order status",
- "paymentStatus": "Payment status"
+ "paymentStatus": "Payment status",
+ "discount": "Discount"
},
"montonioCallback": {
"title": "Montonio checkout",
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index ed8d175..cf41acd 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -128,6 +128,11 @@
"amount": "Amount",
"selectDate": "Select date"
},
+ "formFieldError": {
+ "invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)",
+ "invalidPersonalCode": "Please enter a valid Estonian personal code",
+ "stringNonEmpty": "This field is required"
+ },
"wallet": {
"balance": "Your MedReport account balance",
"expiredAt": "Valid until {{expiredAt}}"
diff --git a/public/locales/en/order-analysis.json b/public/locales/en/order-analysis.json
index 2031316..11d1145 100644
--- a/public/locales/en/order-analysis.json
+++ b/public/locales/en/order-analysis.json
@@ -1,7 +1,6 @@
{
"title": "Select analysis",
"description": "All analysis results will appear within 1-3 days after the blood test.",
- "analysisNotAvailable": "Analysis is not available currently",
"analysisAddedToCart": "Analysis added to cart",
"analysisAddToCartError": "Adding analysis to cart failed"
}
\ No newline at end of file
diff --git a/public/locales/en/order-health-analysis.json b/public/locales/en/order-health-analysis.json
new file mode 100644
index 0000000..60633ac
--- /dev/null
+++ b/public/locales/en/order-health-analysis.json
@@ -0,0 +1,4 @@
+{
+ "title": "Order health analysis",
+ "description": "Select a suitable date and book your appointment time."
+}
\ No newline at end of file
diff --git a/public/locales/en/orders.json b/public/locales/en/orders.json
index f846b0a..7aa958c 100644
--- a/public/locales/en/orders.json
+++ b/public/locales/en/orders.json
@@ -1,6 +1,7 @@
{
"title": "Orders",
"description": "View your orders",
+ "noOrders": "No orders found",
"table": {
"analysisPackage": "Analysis package",
"otherOrders": "Order",
diff --git a/public/locales/et/account.json b/public/locales/et/account.json
index e3e824c..86b36e3 100644
--- a/public/locales/et/account.json
+++ b/public/locales/et/account.json
@@ -130,7 +130,10 @@
"description": "Jätkamiseks palun sisestage enda isikuandmed",
"button": "Jätka",
"userConsentLabel": "Nõustun isikuandmete kasutamisega platvormil",
- "userConsentUrlTitle": "Vaata isikuandmete töötlemise põhimõtteid"
+ "userConsentUrlTitle": "Vaata isikuandmete töötlemise põhimõtteid",
+ "updateAccountLoading": "Konto andmed uuendatakse...",
+ "updateAccountSuccess": "Konto andmed uuendatud",
+ "updateAccountError": "Konto andmete uuendamine ebaõnnestus"
},
"consentModal": {
"title": "Enne alustamist",
diff --git a/public/locales/et/auth.json b/public/locales/et/auth.json
index d9ebf9b..4919b25 100644
--- a/public/locales/et/auth.json
+++ b/public/locales/et/auth.json
@@ -2,7 +2,7 @@
"signUpHeading": "Loo konto",
"signUp": "Loo konto",
"signUpSubheading": "Täida allolev vorm, et luua konto.",
- "signInHeading": "Logi oma kontole sisse",
+ "signInHeading": "Logi sisse",
"signInSubheading": "Tere tulemast tagasi! Palun sisesta oma andmed",
"signIn": "Logi sisse",
"getStarted": "Alusta",
@@ -22,6 +22,7 @@
"alreadyHaveAccountStatement": "Mul on juba konto, ma tahan sisse logida",
"doNotHaveAccountStatement": "Mul pole kontot, ma tahan registreeruda",
"signInWithProvider": "Logi sisse teenusega {{provider}}",
+ "signInWithKeycloak": "Smart-ID/Mobiil-ID/ID-kaart",
"signInWithPhoneNumber": "Logi sisse telefoninumbriga",
"signInWithEmail": "Logi sisse e-posti aadressiga",
"signUpWithEmail": "Registreeru e-posti aadressiga",
@@ -68,7 +69,7 @@
"acceptTermsAndConditions": "Ma nõustun ja ",
"termsOfService": "Kasutustingimused",
"privacyPolicy": "Privaatsuspoliitika",
- "orContinueWith": "Või jätka koos",
+ "orContinueWith": "Või",
"redirecting": "Oled sees! Palun oota...",
"errors": {
"Invalid login credentials": "Sisestatud andmed on valed",
diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json
index a7bcb19..36b69d0 100644
--- a/public/locales/et/cart.json
+++ b/public/locales/et/cart.json
@@ -3,9 +3,6 @@
"description": "Vaata oma ostukorvi",
"emptyCartMessage": "Sinu ostukorv on tühi",
"emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.",
- "subtotal": "Vahesumma",
- "promotionsTotal": "Soodustuse summa",
- "total": "Summa",
"table": {
"item": "Toode",
"quantity": "Kogus",
@@ -34,8 +31,10 @@
"appliedCodes": "Rakendatud sooduskoodid:",
"removeError": "Sooduskoodi eemaldamine ebaõnnestus",
"removeSuccess": "Sooduskood eemaldatud",
+ "removeLoading": "Sooduskoodi eemaldamine",
"addError": "Sooduskoodi rakendamine ebaõnnestus",
- "addSuccess": "Sooduskood rakendatud"
+ "addSuccess": "Sooduskood rakendatud",
+ "addLoading": "Rakendan sooduskoodi..."
},
"items": {
"synlabAnalyses": {
@@ -56,7 +55,11 @@
}
},
"order": {
- "title": "Tellimus"
+ "title": "Tellimus",
+ "promotionsTotal": "Soodustuse summa",
+ "subtotal": "Vahesumma",
+ "total": "Summa",
+ "giftCard": "Kinkekaart"
},
"orderConfirmed": {
"title": "Tellimus on edukalt esitatud",
@@ -68,7 +71,8 @@
"orderDate": "Tellimuse kuupäev",
"orderNumber": "Tellimuse number",
"orderStatus": "Tellimuse olek",
- "paymentStatus": "Makse olek"
+ "paymentStatus": "Makse olek",
+ "discount": "Soodus"
},
"montonioCallback": {
"title": "Montonio makseprotsess",
diff --git a/public/locales/et/common.json b/public/locales/et/common.json
index 3a8f55c..96f3572 100644
--- a/public/locales/et/common.json
+++ b/public/locales/et/common.json
@@ -46,7 +46,7 @@
"skip": "Jäta vahele",
"signedInAs": "Sisselogitud kasutajana",
"pageOfPages": "Leht {{page}} / {{total}}",
- "noData": "Andmeid puudub",
+ "noData": "Andmed puuduvad",
"pageNotFoundHeading": "Ups! :|",
"errorPageHeading": "Ups! :|",
"notifications": "Teavitused",
@@ -128,6 +128,11 @@
"amount": "Summa",
"selectDate": "Vali kuupäev"
},
+ "formFieldError": {
+ "invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)",
+ "invalidPersonalCode": "Palun sisesta Eesti isikukood",
+ "stringNonEmpty": "See väli on kohustuslik"
+ },
"wallet": {
"balance": "Sinu MedReporti konto saldo",
"expiredAt": "Kehtiv kuni {{expiredAt}}"
diff --git a/public/locales/et/order-analysis.json b/public/locales/et/order-analysis.json
index 9c7b750..8ff008d 100644
--- a/public/locales/et/order-analysis.json
+++ b/public/locales/et/order-analysis.json
@@ -1,7 +1,6 @@
{
"title": "Vali analüüs",
"description": "Kõikide analüüside tulemused ilmuvad 1–3 tööpäeva jooksul peale vere andmist.",
- "analysisNotAvailable": "Analüüsi tellimine ei ole hetkel saadaval",
"analysisAddedToCart": "Analüüs lisatud ostukorvi",
"analysisAddToCartError": "Analüüsi lisamine ostukorvi ebaõnnestus"
}
\ No newline at end of file
diff --git a/public/locales/et/order-health-analysis.json b/public/locales/et/order-health-analysis.json
new file mode 100644
index 0000000..f267e09
--- /dev/null
+++ b/public/locales/et/order-health-analysis.json
@@ -0,0 +1,4 @@
+{
+ "title": "Telli terviseuuring",
+ "description": "Vali kalendrist sobiv kuupäev ja broneeri endale vastuvõtuaeg."
+}
\ No newline at end of file
diff --git a/public/locales/et/orders.json b/public/locales/et/orders.json
index 4811a2e..822c6b1 100644
--- a/public/locales/et/orders.json
+++ b/public/locales/et/orders.json
@@ -1,6 +1,7 @@
{
"title": "Tellimused",
"description": "Vaata oma tellimusi",
+ "noOrders": "Tellimusi ei leitud",
"table": {
"analysisPackage": "Analüüsi pakett",
"otherOrders": "Tellimus",
diff --git a/public/locales/ru/account.json b/public/locales/ru/account.json
index 9bfae35..bb3785b 100644
--- a/public/locales/ru/account.json
+++ b/public/locales/ru/account.json
@@ -130,7 +130,10 @@
"description": "Пожалуйста, введите личные данные для продолжения",
"button": "Продолжить",
"userConsentLabel": "Я согласен на использование персональных данных на платформе",
- "userConsentUrlTitle": "Посмотреть политику конфиденциальности"
+ "userConsentUrlTitle": "Посмотреть политику конфиденциальности",
+ "updateAccountLoading": "Обновление данных аккаунта...",
+ "updateAccountSuccess": "Данные аккаунта обновлены",
+ "updateAccountError": "Не удалось обновить данные аккаунта"
},
"consentModal": {
"title": "Перед началом",
diff --git a/public/locales/ru/auth.json b/public/locales/ru/auth.json
index 8634403..5dc5e1d 100644
--- a/public/locales/ru/auth.json
+++ b/public/locales/ru/auth.json
@@ -22,6 +22,7 @@
"alreadyHaveAccountStatement": "У меня уже есть аккаунт, я хочу войти",
"doNotHaveAccountStatement": "У меня нет аккаунта, я хочу зарегистрироваться",
"signInWithProvider": "Войти через {{provider}}",
+ "signInWithKeycloak": "Smart-ID/Mobiil-ID/ID-kaart",
"signInWithPhoneNumber": "Войти по номеру телефона",
"signInWithEmail": "Войти по Email",
"signUpWithEmail": "Зарегистрироваться по Email",
diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json
index 9aaeb3f..289ff31 100644
--- a/public/locales/ru/cart.json
+++ b/public/locales/ru/cart.json
@@ -3,8 +3,6 @@
"description": "Просмотрите свою корзину",
"emptyCartMessage": "Ваша корзина пуста",
"emptyCartMessageDescription": "Добавьте товары в корзину, чтобы продолжить.",
- "subtotal": "Промежуточный итог",
- "total": "Сумма",
"table": {
"item": "Товар",
"quantity": "Количество",
@@ -28,7 +26,15 @@
"label": "Добавить промокод",
"apply": "Применить",
"subtitle": "Если хотите, можете добавить промокод",
- "placeholder": "Введите промокод"
+ "placeholder": "Введите промокод",
+ "remove": "Удалить промокод",
+ "appliedCodes": "Примененные промокоды:",
+ "removeError": "Не удалось удалить промокод",
+ "removeSuccess": "Промокод удален",
+ "removeLoading": "Удаление промокода...",
+ "addError": "Не удалось применить промокод",
+ "addSuccess": "Промокод применен",
+ "addLoading": "Применение промокода..."
},
"items": {
"synlabAnalyses": {
@@ -49,7 +55,11 @@
}
},
"order": {
- "title": "Заказ"
+ "title": "Заказ",
+ "promotionsTotal": "Скидка",
+ "subtotal": "Промежуточный итог",
+ "total": "Сумма",
+ "giftCard": "Подарочная карта"
},
"orderConfirmed": {
"title": "Заказ успешно оформлен",
@@ -61,7 +71,8 @@
"orderDate": "Дата заказа",
"orderNumber": "Номер заказа",
"orderStatus": "Статус заказа",
- "paymentStatus": "Статус оплаты"
+ "paymentStatus": "Статус оплаты",
+ "discount": "Скидка"
},
"montonioCallback": {
"title": "Процесс оплаты Montonio",
diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json
index 545b9e6..28b5d6b 100644
--- a/public/locales/ru/common.json
+++ b/public/locales/ru/common.json
@@ -128,6 +128,11 @@
"amount": "Сумма",
"selectDate": "Выберите дату"
},
+ "formFieldError": {
+ "invalidPhoneNumber": "Пожалуйста, введите действительный номер телефона (должен включать код страны +372)",
+ "invalidPersonalCode": "Пожалуйста, введите действительный персональный код",
+ "stringNonEmpty": "Это поле обязательно"
+ },
"wallet": {
"balance": "Баланс вашего счета MedReport",
"expiredAt": "Действительно до {{expiredAt}}"
diff --git a/public/locales/ru/order-analysis.json b/public/locales/ru/order-analysis.json
index ea36b5c..c837255 100644
--- a/public/locales/ru/order-analysis.json
+++ b/public/locales/ru/order-analysis.json
@@ -1,7 +1,6 @@
{
"title": "Выберите анализ",
"description": "Результаты всех анализов будут доступны в течение 1–3 рабочих дней после сдачи крови.",
- "analysisNotAvailable": "Заказ анализа в данный момент недоступен",
"analysisAddedToCart": "Анализ добавлен в корзину",
"analysisAddToCartError": "Не удалось добавить анализ в корзину"
}
\ No newline at end of file
diff --git a/public/locales/ru/order-health-analysis.json b/public/locales/ru/order-health-analysis.json
new file mode 100644
index 0000000..75c65f7
--- /dev/null
+++ b/public/locales/ru/order-health-analysis.json
@@ -0,0 +1,4 @@
+{
+ "title": "Заказать анализ здоровья",
+ "description": "Выберите подходящую дату и забронируйте время для вашего приёма."
+}
\ No newline at end of file
diff --git a/public/locales/ru/orders.json b/public/locales/ru/orders.json
index c42a230..6669aff 100644
--- a/public/locales/ru/orders.json
+++ b/public/locales/ru/orders.json
@@ -1,6 +1,7 @@
{
"title": "Заказы",
"description": "Просмотрите ваши заказы",
+ "noOrders": "Заказы не найдены",
"table": {
"analysisPackage": "Пакет анализов",
"otherOrders": "Заказ",
diff --git a/supabase/migrations/20250907000001_update_keycloak_user_creation.sql b/supabase/migrations/20250907000001_update_keycloak_user_creation.sql
new file mode 100644
index 0000000..ccc7834
--- /dev/null
+++ b/supabase/migrations/20250907000001_update_keycloak_user_creation.sql
@@ -0,0 +1,94 @@
+-- Update the user creation trigger to properly handle Keycloak user metadata
+CREATE OR REPLACE FUNCTION kit.setup_new_user()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO ''
+AS $$
+DECLARE
+ user_name text;
+ picture_url text;
+ personal_code text;
+ full_name text;
+ given_name text;
+ family_name text;
+ preferred_username text;
+BEGIN
+ -- Extract data from Keycloak user metadata
+ -- Check raw_user_meta_data first (this is where Keycloak data is stored)
+ IF new.raw_user_meta_data IS NOT NULL THEN
+ -- Try full_name first, then name field
+ full_name := new.raw_user_meta_data ->> 'full_name';
+ IF full_name IS NULL THEN
+ full_name := new.raw_user_meta_data ->> 'name';
+ END IF;
+
+ -- Extract individual name components
+ given_name := new.raw_user_meta_data -> 'custom_claims' ->> 'given_name';
+ family_name := new.raw_user_meta_data -> 'custom_claims' ->> 'family_name';
+ preferred_username := new.raw_user_meta_data -> 'custom_claims' ->> 'preferred_username';
+
+ -- Use given_name (first name) for the name field
+ IF given_name IS NOT NULL THEN
+ user_name := given_name;
+ ELSIF full_name IS NOT NULL THEN
+ user_name := full_name;
+ ELSIF preferred_username IS NOT NULL THEN
+ user_name := preferred_username;
+ END IF;
+
+ -- Extract personal code from preferred_username (Keycloak provides Estonian personal codes here)
+ IF preferred_username IS NOT NULL THEN
+ personal_code := preferred_username;
+ END IF;
+
+ -- Also try personalCode field as fallback
+ IF personal_code IS NULL THEN
+ personal_code := new.raw_user_meta_data ->> 'personalCode';
+ END IF;
+ END IF;
+
+ -- Fall back to email if no name found
+ IF user_name IS NULL AND new.email IS NOT NULL THEN
+ user_name := split_part(new.email, '@', 1);
+ END IF;
+
+ -- Default empty string if still no name
+ IF user_name IS NULL THEN
+ user_name := '';
+ END IF;
+
+ -- Extract picture URL
+ IF new.raw_user_meta_data ->> 'avatar_url' IS NOT NULL THEN
+ picture_url := new.raw_user_meta_data ->> 'avatar_url';
+ ELSE
+ picture_url := null;
+ END IF;
+
+ -- Insert into medreport.accounts
+ INSERT INTO medreport.accounts (
+ id,
+ primary_owner_user_id,
+ name,
+ last_name,
+ is_personal_account,
+ picture_url,
+ email,
+ personal_code,
+ application_role
+ )
+ VALUES (
+ new.id,
+ new.id,
+ user_name,
+ family_name,
+ true,
+ picture_url,
+ NULL, -- Keycloak email !== customer personal email, they will set this later
+ personal_code,
+ 'user' -- Default role for new users
+ );
+
+ RETURN new;
+END;
+$$;
diff --git a/supabase/migrations/20250908145900_update_account_email_keycloak.sql b/supabase/migrations/20250908145900_update_account_email_keycloak.sql
new file mode 100644
index 0000000..9e44e06
--- /dev/null
+++ b/supabase/migrations/20250908145900_update_account_email_keycloak.sql
@@ -0,0 +1,28 @@
+CREATE OR REPLACE FUNCTION medreport.update_account(p_name character varying, p_last_name text, p_personal_code text, p_phone text, p_city text, p_has_consent_personal_data boolean, p_uid uuid, p_email character varying)
+ RETURNS void
+ LANGUAGE plpgsql
+AS $function$begin
+ update medreport.accounts
+ set name = coalesce(p_name, name),
+ last_name = coalesce(p_last_name, last_name),
+ personal_code = coalesce(p_personal_code, personal_code),
+ phone = coalesce(p_phone, phone),
+ city = coalesce(p_city, city),
+ has_consent_personal_data = coalesce(p_has_consent_personal_data,
+ has_consent_personal_data),
+ email = coalesce(p_email, email)
+ where id = p_uid;
+end;$function$
+;
+
+grant
+execute on function medreport.update_account(
+ p_name character varying,
+ p_last_name text,
+ p_personal_code text,
+ p_phone text,
+ p_city text,
+ p_has_consent_personal_data boolean,
+ p_uid uuid,
+ p_email character varying) to authenticated,
+service_role;
diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts
index 6cbbb3b..b08ef73 100644
--- a/utils/medusa-product.ts
+++ b/utils/medusa-product.ts
@@ -1,21 +1,43 @@
-export const getAnalysisElementMedusaProductIds = (products: ({
+import type { AdminProductVariant, StoreProduct } from "@medusajs/types";
+
+type Product = {
metadata?: {
analysisElementMedusaProductIds?: string;
} | null;
-} | null)[]) => {
+ variant?: {
+ metadata?: {
+ analysisElementMedusaProductIds?: string;
+ } | null;
+ } | null;
+} | null;
+
+export const getAnalysisElementMedusaProductIds = (products: (Pick & { variant?: Pick })[]) => {
if (!products) {
return [];
}
const mapped = products
.flatMap((product) => {
- const value = product?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
+ const value = (product as Product)?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
+ const value_variant = (product as Product)?.variant?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"');
+
+ const result: string[] = [];
try {
- return JSON.parse(value as string);
+ if (value) {
+ result.push(...JSON.parse(value as string));
+ }
+ } catch (e) {
+ console.error("Failed to parse analysisElementMedusaProductIds from analysis package variant, possibly invalid format", e);
+ }
+ try {
+ if (value_variant) {
+ result.push(...JSON.parse(value_variant as string));
+ }
} catch (e) {
console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e);
- return [];
}
+
+ return result;
})
.filter(Boolean) as string[];