Merge pull request #63 from MR-medreport/main

update develop
This commit is contained in:
danelkungla
2025-08-29 09:50:32 +03:00
committed by GitHub
40 changed files with 898 additions and 294 deletions

View File

@@ -58,5 +58,5 @@ export default async function syncAnalysisResults() {
} }
return acc; return acc;
}, {} as GroupedResults); }, {} as GroupedResults);
console.info(`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults, undefined, 2)}`); console.info(`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`);
} }

View File

@@ -71,19 +71,19 @@ export default async function syncConnectedOnline() {
return { return {
id: service.ID, id: service.ID,
clinic_id: service.ClinicID, clinic_id: service.ClinicID,
code: service.Code, sync_id: service.SyncID,
description: service.Description || null,
display: service.Display,
duration: service.Duration,
has_free_codes: !!service.HasFreeCodes,
name: service.Name, name: service.Name,
description: service.Description || null,
price: service.Price,
requires_payment: !!service.RequiresPayment,
duration: service.Duration,
neto_duration: service.NetoDuration, neto_duration: service.NetoDuration,
display: service.Display,
price_periods: service.PricePeriods || null,
online_hide_duration: service.OnlineHideDuration, online_hide_duration: service.OnlineHideDuration,
online_hide_price: service.OnlineHidePrice, online_hide_price: service.OnlineHidePrice,
price: service.Price, code: service.Code,
price_periods: service.PricePeriods || null, has_free_codes: !!service.HasFreeCodes,
requires_payment: !!service.RequiresPayment,
sync_id: service.SyncID,
}; };
}); });

View File

@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
const analysisOrders = await getAnalysisOrdersAdmin({ orderStatus: 'QUEUED' }); const analysisOrders = await getAnalysisOrdersAdmin({ orderStatus: 'PROCESSING' });
console.error(`Sending test responses for ${analysisOrders.length} analysis orders`); console.error(`Sending test responses for ${analysisOrders.length} analysis orders`);
for (const medreportOrder of analysisOrders) { for (const medreportOrder of analysisOrders) {

View File

@@ -53,6 +53,7 @@ export default function AnalysisView({
feedback?: DoctorFeedback; feedback?: DoctorFeedback;
}) { }) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isDraftSubmitting, setIsDraftSubmitting] = useState(false);
const { data: user } = useUser(); const { data: user } = useUser();
@@ -106,28 +107,22 @@ export default function AnalysisView({
}; };
const handleDraftSubmit = async (e: React.FormEvent) => { const handleDraftSubmit = async (e: React.FormEvent) => {
setIsDraftSubmitting(true);
e.preventDefault(); e.preventDefault();
form.formState.errors.feedbackValue = undefined; form.formState.errors.feedbackValue = undefined;
const formData = form.getValues(); const formData = form.getValues();
onSubmit(formData, 'DRAFT'); await onSubmit(formData, 'DRAFT');
setIsDraftSubmitting(false);
}; };
const handleCompleteSubmit = async (e: React.FormEvent) => { const handleCompleteSubmit = form.handleSubmit(async () => {
e.preventDefault();
const isValid = await form.trigger();
if (!isValid) {
return;
}
setIsConfirmOpen(true); setIsConfirmOpen(true);
}; });
const confirmComplete = () => { const confirmComplete = form.handleSubmit(async (data) => {
const formData = form.getValues(); await onSubmit(data, 'COMPLETED');
onSubmit(formData, 'COMPLETED'); });
};
return ( return (
<> <>
@@ -179,7 +174,11 @@ export default function AnalysisView({
<div className="font-bold"> <div className="font-bold">
<Trans i18nKey="doctor:bmi" /> <Trans i18nKey="doctor:bmi" />
</div> </div>
<div>{bmiFromMetric(patient?.weight ?? 0, patient?.height ?? 0)}</div> <div>
{patient?.weight && patient?.height
? bmiFromMetric(patient.weight, patient.height)
: '-'}
</div>
<div className="font-bold"> <div className="font-bold">
<Trans i18nKey="doctor:smoking" /> <Trans i18nKey="doctor:smoking" />
</div> </div>
@@ -245,7 +244,9 @@ export default function AnalysisView({
type="button" type="button"
variant="outline" variant="outline"
onClick={handleDraftSubmit} onClick={handleDraftSubmit}
disabled={isReadOnly} disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full" className="xs:w-1/4 w-full"
> >
<Trans i18nKey="common:saveAsDraft" /> <Trans i18nKey="common:saveAsDraft" />
@@ -253,7 +254,9 @@ export default function AnalysisView({
<Button <Button
type="button" type="button"
onClick={handleCompleteSubmit} onClick={handleCompleteSubmit}
disabled={isReadOnly} disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full" className="xs:w-1/4 w-full"
> >
<Trans i18nKey="common:save" /> <Trans i18nKey="common:save" />

View File

@@ -0,0 +1,41 @@
import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header';
import { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('booking:title');
return {
title,
};
};
async function BookingHandlePage({ params }: { params: { handle: string } }) {
const handle = await params.handle;
const { category } = await loadCategory({ handle });
return (
<>
<AppBreadcrumbs
values={{
[handle]: category?.name || handle,
}}
/>
<HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />}
/>
<PageBody></PageBody>
</>
);
}
export default withI18n(BookingHandlePage);

View File

@@ -1,12 +1,16 @@
import { use } from 'react';
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
import { PageBody } from '@kit/ui/page'; import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import OrderCards from '../../_components/order-cards'; import OrderCards from '../../_components/order-cards';
import ServiceCategories from '../../_components/service-categories';
import { loadTtoServices } from '../../_lib/server/load-tto-services';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -18,15 +22,30 @@ export const generateMetadata = async () => {
}; };
function BookingPage() { function BookingPage() {
const { heroCategories, ttoCategories } = use(loadTtoServices());
if (!heroCategories.length && !ttoCategories.length) {
return (
<>
<AppBreadcrumbs />
<h3 className="mt-8">
<Trans i18nKey="booking:noCategories" />
</h3>
</>
);
}
return ( return (
<> <>
<AppBreadcrumbs />
<HomeLayoutPageHeader <HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />} title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />} description={<Trans i18nKey={'booking:description'} />}
/> />
<PageBody> <PageBody className="space-y-2">
<OrderCards /> <OrderCards heroCategories={heroCategories} />
<ServiceCategories categories={ttoCategories} />
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,74 +1,68 @@
"use client"; 'use client';
import { ChevronRight, HeartPulse } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { cn } from '@/lib/utils';
import { pathsConfig } from '@/packages/shared/src/config';
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
Card, Card,
CardHeader,
CardDescription, CardDescription,
CardProps,
CardFooter, CardFooter,
CardHeader,
CardProps,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@/lib/utils';
const dummyCards = [ import { ServiceCategory } from './service-categories';
{
title: 'booking:analysisPackages.title',
description: 'booking:analysisPackages.description',
descriptionColor: 'text-primary',
icon: (
<Link href={'/home/order-analysis-package'}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
),
cardVariant: 'gradient-success' as CardProps['variant'],
iconBg: 'bg-warning',
},
];
export default function OrderCards() { export default function OrderCards({
heroCategories,
}: {
heroCategories: ServiceCategory[];
}) {
return ( return (
<div className="grid grid-cols-3 gap-6 mt-4"> <div className="xs:grid-cols-3 mt-4 grid grid-cols-1 gap-2">
{dummyCards.map(({ {heroCategories.map(({ name, description, color, handle }) => (
title,
description,
icon,
cardVariant,
descriptionColor,
iconBg,
}) => (
<Card <Card
key={title} key={name}
variant={cardVariant} variant={`gradient-${color}` as CardProps['variant']}
className="flex flex-col justify-between" className="flex flex-col justify-between"
> >
<CardHeader className="items-end-safe"> <CardHeader className="relative flex flex-row justify-between">
<div <div
className={cn( className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white', 'flex size-8 items-center-safe justify-center-safe rounded-full',
iconBg, `text-${color}`,
`bg-${color}/10`,
{
'bg-primary/10': color === 'success',
},
)} )}
> >
{icon} <ComponentInstanceIcon
className={cn('size-4', `fill-${color}`)}
/>
</div>
<div className="absolute top-2 right-2 flex size-8 items-center-safe justify-center-safe rounded-xl text-white">
<Link
href={pathsConfig.app.bookingHandle.replace('[handle]', handle)}
>
<Button
size="icon"
variant="outline"
className="px-2 text-black"
>
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
</div> </div>
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-start gap-2"> <CardFooter className="mt-5 flex flex-col items-start gap-2">
<div <h5>{name}</h5>
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'} <CardDescription>{description}</CardDescription>
>
<HeartPulse className="size-4 fill-green-500" />
</div>
<h5>
<Trans i18nKey={title} />
</h5>
<CardDescription className={descriptionColor}>
<Trans i18nKey={description} />
</CardDescription>
</CardFooter> </CardFooter>
</Card> </Card>
))} ))}

View File

@@ -0,0 +1,61 @@
'use client';
import React from 'react';
import { redirect } from 'next/navigation';
import { createPath, pathsConfig } from '@/packages/shared/src/config';
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
import { cn } from '@kit/ui/shadcn';
import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card';
export interface ServiceCategory {
name: string;
handle: string;
color: string;
description: string;
}
const ServiceCategories = ({
categories,
}: {
categories: ServiceCategory[];
}) => {
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{categories.map((category, index) => (
<Card
key={index}
className="flex cursor-pointer gap-2 p-4 shadow hover:shadow-md"
onClick={() => {
redirect(
pathsConfig.app.bookingHandle.replace(
'[handle]',
category.handle,
),
);
}}
>
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full',
`bg-${category.color}/10`,
`text-${category.color}`,
)}
>
<ComponentInstanceIcon />
</div>
<div>
<h5 className="mb-2 text-lg font-semibold">{category.name}</h5>
<CardDescription className="">
{category.description}
</CardDescription>
</div>
</Card>
))}
</div>
);
};
export default ServiceCategories;

View File

@@ -1,9 +1,11 @@
import { cache } from 'react'; import { cache } from 'react';
import { listProductTypes } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
import { getProductCategories } from '@lib/data/categories'; import { getProductCategories } from '@lib/data/categories';
import { listProductTypes } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards'; import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
import { ServiceCategory } from '../../_components/service-categories';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>
@@ -14,7 +16,9 @@ async function countryCodesLoader() {
export const loadCountryCodes = cache(countryCodesLoader); export const loadCountryCodes = cache(countryCodesLoader);
async function productCategoriesLoader() { async function productCategoriesLoader() {
const productCategories = await getProductCategories({ fields: "*products, *products.variants" }); const productCategories = await getProductCategories({
fields: '*products, *products.variants, is_active',
});
return productCategories.product_categories ?? []; return productCategories.product_categories ?? [];
} }
export const loadProductCategories = cache(productCategoriesLoader); export const loadProductCategories = cache(productCategoriesLoader);
@@ -32,22 +36,31 @@ async function analysesLoader() {
]); ]);
const countryCode = countryCodes[0]!; const countryCode = countryCodes[0]!;
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis'); const category = productCategories.find(
({ metadata }) => metadata?.page === 'order-analysis',
);
const serviceCategories = productCategories.filter(
({ parent_category }) => parent_category?.handle === 'tto-categories',
);
return { return {
analyses: category?.products?.map<OrderAnalysisCard>(({ title, description, subtitle, variants, status, metadata }) => { analyses:
const variant = variants![0]!; category?.products?.map<OrderAnalysisCard>(
return { ({ title, description, subtitle, variants, status, metadata }) => {
title, const variant = variants![0]!;
description, return {
subtitle, title,
variant: { description,
id: variant.id, subtitle,
variant: {
id: variant.id,
},
isAvailable:
status === 'published' && !!metadata?.analysisIdOriginal,
};
}, },
isAvailable: status === 'published' && !!metadata?.analysisIdOriginal, ) ?? [],
};
}) ?? [],
countryCode, countryCode,
} };
} }
export const loadAnalyses = cache(analysesLoader); export const loadAnalyses = cache(analysesLoader);

View File

@@ -0,0 +1,31 @@
import { cache } from 'react';
import { getProductCategories } from '@lib/data';
import { ServiceCategory } from '../../_components/service-categories';
async function categoryLoader({
handle,
}: {
handle: string;
}): Promise<{ category: ServiceCategory | null }> {
const response = await getProductCategories({
handle,
fields: '*products, is_active, metadata',
});
const category = response.product_categories[0];
return {
category: {
color:
typeof category?.metadata?.color === 'string'
? category?.metadata?.color
: 'primary',
description: category?.description || '',
handle: category?.handle || '',
name: category?.name || '',
},
};
}
export const loadCategory = cache(categoryLoader);

View File

@@ -0,0 +1,49 @@
import { cache } from 'react';
import { getProductCategories } from '@lib/data';
import { ServiceCategory } from '../../_components/service-categories';
async function ttoServicesLoader() {
const response = await getProductCategories({
fields: '*products, is_active, metadata',
});
const heroCategories = response.product_categories?.filter(
({ parent_category, is_active, metadata }) =>
parent_category?.handle === 'tto-categories' &&
is_active &&
metadata?.isHero,
);
const ttoCategories = response.product_categories?.filter(
({ parent_category, is_active, metadata }) =>
parent_category?.handle === 'tto-categories' &&
is_active &&
!metadata?.isHero,
);
return {
heroCategories:
heroCategories.map<ServiceCategory>(
({ name, handle, metadata, description }) => ({
name,
handle,
color:
typeof metadata?.color === 'string' ? metadata.color : 'primary',
description,
}),
) ?? [],
ttoCategories:
ttoCategories.map<ServiceCategory>(
({ name, handle, metadata, description }) => ({
name,
handle,
color:
typeof metadata?.color === 'string' ? metadata.color : 'primary',
description,
}),
) ?? [],
};
}
export const loadTtoServices = cache(ttoServicesLoader);

View File

@@ -0,0 +1,35 @@
import { Database } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export enum NotificationAction {
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
}
export const createNotificationLog = async ({
action,
status,
comment,
relatedRecordId,
}: {
action: NotificationAction;
status: Database['audit']['Enums']['action_status'];
comment?: string;
relatedRecordId?: string | number;
}) => {
try {
const supabase = getSupabaseServerClient();
await supabase
.schema('audit')
.from('notification_entries')
.insert({
action,
status,
comment,
related_record_key: relatedRecordId?.toString(),
})
.throwOnError();
} catch (error) {
console.error('Failed to insert doctor page view log', error);
}
};

View File

@@ -1,10 +1,34 @@
'use server'; 'use server';
import { CompanySubmitData } from '@/lib/types/company';
import { emailSchema } from '@/lib/validations/email.schema';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getMailer } from '@kit/mailers'; import { getMailer } from '@kit/mailers';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { CompanySubmitData } from '../types/company'; export const sendDoctorSummaryCompletedEmail = async (
import { emailSchema } from '../validations/email.schema'; language: string,
recipientName: string,
recipientEmail: string,
orderNr: string,
orderId: number,
) => {
const { html, subject } = await renderDoctorSummaryReceivedEmail({
language,
recipientName,
recipientEmail,
orderNr,
orderId,
});
await sendEmail({
subject,
html,
to: recipientEmail,
});
};
export const sendCompanyOfferEmail = async ( export const sendCompanyOfferEmail = async (
data: CompanySubmitData, data: CompanySubmitData,
@@ -26,13 +50,24 @@ export const sendCompanyOfferEmail = async (
export const sendEmail = enhanceAction( export const sendEmail = enhanceAction(
async ({ subject, html, to }) => { async ({ subject, html, to }) => {
const mailer = await getMailer(); const mailer = await getMailer();
const log = await getLogger();
await mailer.sendEmail({ if (!process.env.EMAIL_USER) {
log.error('Sending email failed, as no sender found in env.')
throw new Error('No email user configured');
}
const result = await mailer.sendEmail({
from: process.env.EMAIL_USER,
to, to,
subject, subject,
html, html,
}); });
log.info(
`Sent email with subject "${subject}", result: ${JSON.stringify(result)}`,
);
return {}; return {};
}, },
{ {

View File

@@ -1,42 +1,45 @@
'use server'; 'use server';
import { z } from 'zod';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
import { getCartId } from '@lib/data/cookies'; import { getCartId } from '@lib/data/cookies';
import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types';
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src'; import { z } from 'zod';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { requireUserInServerComponent } from '../server/require-user-in-server-component'; import { requireUserInServerComponent } from '../server/require-user-in-server-component';
const env = () => z const env = () =>
.object({ z
medusaBackendPublicUrl: z .object({
.string({ medusaBackendPublicUrl: z
required_error: 'MEDUSA_BACKEND_PUBLIC_URL is required', .string({
}) required_error: 'MEDUSA_BACKEND_PUBLIC_URL is required',
.min(1), })
siteUrl: z .min(1),
.string({ siteUrl: z
required_error: 'NEXT_PUBLIC_SITE_URL is required', .string({
}) required_error: 'NEXT_PUBLIC_SITE_URL is required',
.min(1), })
}) .min(1),
.parse({ })
medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, .parse({
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!,
}); siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
});
export async function handleAddToCart({ export async function handleAddToCart({
selectedVariant, selectedVariant,
countryCode, countryCode,
}: { }: {
selectedVariant: Pick<StoreProductVariant, 'id'> selectedVariant: Pick<StoreProductVariant, 'id'>;
countryCode: string countryCode: string;
}) { }) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent(); const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount() const account = await loadCurrentUserAccount();
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error('Account not found');
} }
@@ -48,16 +51,13 @@ export async function handleAddToCart({
countryCode, countryCode,
}); });
const { error } = await supabase const { error } = await supabase.schema('audit').from('cart_entries').insert({
.schema('audit') variant_id: selectedVariant.id,
.from('cart_entries') operation: 'ADD_TO_CART',
.insert({ account_id: account.id,
variant_id: selectedVariant.id, cart_id: cart.id,
operation: 'ADD_TO_CART', changed_by: user.id,
account_id: account.id, });
cart_id: cart.id,
changed_by: user.id,
});
if (error) { if (error) {
throw new Error('Error logging cart entry: ' + error.message); throw new Error('Error logging cart entry: ' + error.message);
} }
@@ -65,68 +65,65 @@ export async function handleAddToCart({
return cart; return cart;
} }
export async function handleDeleteCartItem({ export async function handleDeleteCartItem({ lineId }: { lineId: string }) {
lineId,
}: {
lineId: string;
}) {
await deleteLineItem(lineId); await deleteLineItem(lineId);
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
const cartId = await getCartId(); const cartId = await getCartId();
const user = await requireUserInServerComponent(); const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount() const account = await loadCurrentUserAccount();
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error('Account not found');
} }
const { error } = await supabase const { error } = await supabase.schema('audit').from('cart_entries').insert({
.schema('audit') variant_id: lineId,
.from('cart_entries') operation: 'REMOVE_FROM_CART',
.insert({ account_id: account.id,
variant_id: lineId, cart_id: cartId!,
operation: 'REMOVE_FROM_CART', changed_by: user.id,
account_id: account.id, });
cart_id: cartId!,
changed_by: user.id,
});
if (error) { if (error) {
throw new Error('Error logging cart entry: ' + error.message); throw new Error('Error logging cart entry: ' + error.message);
} }
} }
export async function handleNavigateToPayment({ language, paymentSessionId }: { language: string, paymentSessionId: string }) { export async function handleNavigateToPayment({
language,
paymentSessionId,
}: {
language: string;
paymentSessionId: string;
}) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent(); const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount() const account = await loadCurrentUserAccount();
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error('Account not found');
} }
const cart = await retrieveCart(); const cart = await retrieveCart();
if (!cart) { if (!cart) {
throw new Error("No cart found"); throw new Error('No cart found');
} }
const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({ const paymentLink =
notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`, await new MontonioOrderHandlerService().getMontonioPaymentLink({
returnUrl: `${env().siteUrl}/home/cart/montonio-callback`, notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`,
amount: cart.total, returnUrl: `${env().siteUrl}/home/cart/montonio-callback`,
currency: cart.currency_code.toUpperCase(), amount: cart.total,
description: `Order from Medreport`, currency: cart.currency_code.toUpperCase(),
locale: language, description: `Order from Medreport`,
merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`, locale: language,
}); merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`,
const { error } = await supabase
.schema('audit')
.from('cart_entries')
.insert({
operation: 'NAVIGATE_TO_PAYMENT',
account_id: account.id,
cart_id: cart.id,
changed_by: user.id,
}); });
const { error } = await supabase.schema('audit').from('cart_entries').insert({
operation: 'NAVIGATE_TO_PAYMENT',
account_id: account.id,
cart_id: cart.id,
changed_by: user.id,
});
if (error) { if (error) {
throw new Error('Error logging cart entry: ' + error.message); throw new Error('Error logging cart entry: ' + error.message);
} }
@@ -137,26 +134,23 @@ export async function handleNavigateToPayment({ language, paymentSessionId }: {
export async function handleLineItemTimeout({ export async function handleLineItemTimeout({
lineItem, lineItem,
}: { }: {
lineItem: StoreCartLineItem lineItem: StoreCartLineItem;
}) { }) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
const user = await requireUserInServerComponent(); const user = await requireUserInServerComponent();
const account = await loadCurrentUserAccount() const account = await loadCurrentUserAccount();
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error('Account not found');
} }
await deleteLineItem(lineItem.id); await deleteLineItem(lineItem.id);
const { error } = await supabase const { error } = await supabase.schema('audit').from('cart_entries').insert({
.schema('audit') operation: 'LINE_ITEM_TIMEOUT',
.from('cart_entries') account_id: account.id,
.insert({ cart_id: lineItem.cart_id,
operation: 'LINE_ITEM_TIMEOUT', changed_by: user.id,
account_id: account.id, });
cart_id: lineItem.cart_id,
changed_by: user.id,
});
if (error) { if (error) {
throw new Error('Error logging cart entry: ' + error.message); throw new Error('Error logging cart entry: ' + error.message);
} }

View File

@@ -3,5 +3,5 @@ import { z } from 'zod';
export const emailSchema = z.object({ export const emailSchema = z.object({
to: z.string().email(), to: z.string().email(),
subject: z.string().min(1).max(200), subject: z.string().min(1).max(200),
html: z.string().min(1).max(5000), html: z.string().min(1),
}); });

View File

@@ -0,0 +1,97 @@
import {
Body,
Button,
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 renderDoctorSummaryReceivedEmail({
language,
recipientEmail,
recipientName,
orderNr,
orderId,
}: {
language?: string;
recipientName: string;
recipientEmail: string;
orderNr: string;
orderId: number;
}) {
const namespace = 'doctor-summary-received-email';
const { t } = await initializeEmailI18n({
language,
namespace: [namespace, 'common'],
});
const to = recipientEmail;
const previewText = t(`${namespace}:previewText`, {
orderNr,
});
const subject = t(`${namespace}:subject`, {
orderNr,
});
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`, {
displayName: recipientName,
})}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
</Text>
<Link
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
>
<Button> {t(`${namespace}:linkText`, { orderNr })}</Button>
</Link>
<Text>
{t(`${namespace}:ifButtonDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
to,
};
}

View File

@@ -3,3 +3,4 @@ export * from './emails/account-delete.email';
export * from './emails/otp.email'; export * from './emails/otp.email';
export * from './emails/company-offer.email'; export * from './emails/company-offer.email';
export * from './emails/synlab.email'; export * from './emails/synlab.email';
export * from './emails/doctor-summary-received.email';

View File

@@ -0,0 +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:"
}

View File

@@ -0,0 +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:"
}

View File

@@ -0,0 +1,8 @@
{
"footer": {
"lines1": "MedReport",
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
}
}

View File

@@ -0,0 +1,8 @@
{
"subject": "Uus ettevõtte liitumispäring",
"previewText": "Ettevõte {{companyName}} soovib pakkumist",
"companyName": "Ettevõtte nimi:",
"contactPerson": "Kontaktisik:",
"email": "E-mail:",
"phone": "Telefon:"
}

View File

@@ -0,0 +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:"
}

View File

@@ -0,0 +1,12 @@
{
"subject": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"previewText": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"heading": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
"hello": "Tere {{personName}},",
"lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: Synlab - {{partnerLocationName}}",
"lines2": "<i>Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href=\"https://medreport.ee/et/verevotupunktid\">vaata asukohti ja lahtiolekuaegasid</a>.</i>",
"lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
"lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.",
"lines5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
"lines6": "SYNLAB klienditoe telefon: <a href=\"tel:+37217123\">17123</a>"
}

View File

@@ -5,6 +5,10 @@ import { revalidatePath } from 'next/cache';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import {
NotificationAction,
createNotificationLog,
} from '../../../../../../../lib/services/audit/notificationEntries.service';
import { import {
DoctorAnalysisFeedbackTable, DoctorAnalysisFeedbackTable,
DoctorJobSelect, DoctorJobSelect,
@@ -107,6 +111,7 @@ export const giveFeedbackAction = doctorAction(
status: DoctorAnalysisFeedbackTable['status']; status: DoctorAnalysisFeedbackTable['status'];
}) => { }) => {
const logger = await getLogger(); const logger = await getLogger();
const isCompleted = status === 'COMPLETED';
try { try {
logger.info( logger.info(
@@ -118,8 +123,25 @@ export const giveFeedbackAction = doctorAction(
logger.info({ analysisOrderId }, `Successfully submitted feedback`); logger.info({ analysisOrderId }, `Successfully submitted feedback`);
revalidateDoctorAnalysis(); revalidateDoctorAnalysis();
if (isCompleted) {
await createNotificationLog({
action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
status: 'SUCCESS',
relatedRecordId: analysisOrderId,
});
}
return { success: true }; return { success: true };
} catch (e) { } catch (e: any) {
if (isCompleted) {
await createNotificationLog({
action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
status: 'FAIL',
comment: e?.message,
relatedRecordId: analysisOrderId,
});
}
logger.error('Failed to give feedback', e); logger.error('Failed to give feedback', e);
return { success: false, reason: ErrorReason.UNKNOWN }; return { success: false, reason: ErrorReason.UNKNOWN };
} }

View File

@@ -2,8 +2,10 @@ import 'server-only';
import { isBefore } from 'date-fns'; import { isBefore } from 'date-fns';
import { getFullName } from '@kit/shared/utils';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { sendDoctorSummaryCompletedEmail } from '../../../../../../../lib/services/mailer.service';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema'; import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
import { import {
AnalysisResponseBase, AnalysisResponseBase,
@@ -635,5 +637,42 @@ export async function submitFeedback(
throw new Error('Something went wrong'); throw new Error('Something went wrong');
} }
if (status === 'COMPLETED') {
const [{ data: recipient }, { data: medusaOrderIds }] = await Promise.all([
supabase
.schema('medreport')
.from('accounts')
.select('name, last_name, email, preferred_locale')
.eq('is_personal_account', true)
.eq('primary_owner_user_id', userId)
.throwOnError(),
supabase
.schema('medreport')
.from('analysis_orders')
.select('medusa_order_id, id')
.eq('id', analysisOrderId)
.limit(1)
.throwOnError(),
]);
if (!recipient?.[0]?.email) {
throw new Error('Could not find user email.');
}
if (!medusaOrderIds?.[0]?.id) {
throw new Error('Could not retrieve order.');
}
const { preferred_locale, name, last_name, email } = recipient[0];
await sendDoctorSummaryCompletedEmail(
preferred_locale ?? 'et',
getFullName(name, last_name),
email,
medusaOrderIds?.[0]?.medusa_order_id ?? '',
medusaOrderIds[0].id,
);
}
return data; return data;
} }

View File

@@ -1,13 +1,13 @@
import { sdk } from "@lib/config" import { sdk } from "@lib/config";
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types";
import { getCacheOptions } from "./cookies" import { getCacheOptions } from "./cookies";
export const listCategories = async (query?: Record<string, any>) => { export const listCategories = async (query?: Record<string, any>) => {
const next = { const next = {
...(await getCacheOptions("categories")), ...(await getCacheOptions("categories")),
} };
const limit = query?.limit || 100 const limit = query?.limit || 100;
return sdk.client return sdk.client
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>( .fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
@@ -23,8 +23,8 @@ export const listCategories = async (query?: Record<string, any>) => {
cache: "force-cache", cache: "force-cache",
} }
) )
.then(({ product_categories }) => product_categories) .then(({ product_categories }) => product_categories);
} };
export const getCategoryByHandle = async (categoryHandle: string[]) => { export const getCategoryByHandle = async (categoryHandle: string[]) => {
const { product_categories } = await getProductCategories({ const { product_categories } = await getProductCategories({
@@ -32,7 +32,7 @@ export const getCategoryByHandle = async (categoryHandle: string[]) => {
limit: 1, limit: 1,
}); });
return product_categories[0]; return product_categories[0];
} };
export const getProductCategories = async ({ export const getProductCategories = async ({
handle, handle,
@@ -45,19 +45,18 @@ export const getProductCategories = async ({
} = {}) => { } = {}) => {
const next = { const next = {
...(await getCacheOptions("categories")), ...(await getCacheOptions("categories")),
} };
return sdk.client return sdk.client.fetch<HttpTypes.StoreProductCategoryListResponse>(
.fetch<HttpTypes.StoreProductCategoryListResponse>( `/store/product-categories`,
`/store/product-categories`, {
{ query: {
query: { fields,
fields, handle,
handle, limit,
limit, },
}, next,
next, //cache: "force-cache",
//cache: "force-cache", }
} );
); };
}

View File

@@ -38,7 +38,7 @@ export default function ConfirmationModal({
<Trans i18nKey={descriptionKey} /> <Trans i18nKey={descriptionKey} />
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter className='gap-3'>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
<Trans i18nKey={cancelKey} /> <Trans i18nKey={cancelKey} />
</Button> </Button>

View File

@@ -16,6 +16,7 @@ const PathsSchema = z.object({
home: z.string().min(1), home: z.string().min(1),
selectPackage: z.string().min(1), selectPackage: z.string().min(1),
booking: z.string().min(1), booking: z.string().min(1),
bookingHandle: z.string().min(1),
myOrders: z.string().min(1), myOrders: z.string().min(1),
analysisResults: z.string().min(1), analysisResults: z.string().min(1),
orderAnalysisPackage: z.string().min(1), orderAnalysisPackage: z.string().min(1),
@@ -64,6 +65,7 @@ const pathsConfig = PathsSchema.parse({
joinTeam: '/join', joinTeam: '/join',
selectPackage: '/select-package', selectPackage: '/select-package',
booking: '/home/booking', booking: '/home/booking',
bookingHandle: '/home/booking/[handle]',
orderAnalysisPackage: '/home/order-analysis-package', orderAnalysisPackage: '/home/order-analysis-package',
myOrders: '/home/order', myOrders: '/home/order',
analysisResults: '/home/analysis-results', analysisResults: '/home/analysis-results',

View File

@@ -108,6 +108,90 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
medipost_dispatch: {
Row: {
changed_by: string | null
created_at: string
error_message: string | null
id: number
is_medipost_error: boolean
is_success: boolean
medusa_order_id: string
}
Insert: {
changed_by?: string | null
created_at?: string
error_message?: string | null
id?: number
is_medipost_error: boolean
is_success: boolean
medusa_order_id: string
}
Update: {
changed_by?: string | null
created_at?: string
error_message?: string | null
id?: number
is_medipost_error?: boolean
is_success?: boolean
medusa_order_id?: string
}
Relationships: []
}
medusa_action: {
Row: {
action: string
created_at: string
id: number
medusa_user_id: string
page: string | null
user_email: string
}
Insert: {
action: string
created_at?: string
id?: number
medusa_user_id: string
page?: string | null
user_email: string
}
Update: {
action?: string
created_at?: string
id?: number
medusa_user_id?: string
page?: string | null
user_email?: string
}
Relationships: []
}
notification_entries: {
Row: {
action: string
comment: string | null
created_at: string
id: number
related_record_key: string | null
status: Database["audit"]["Enums"]["action_status"]
}
Insert: {
action: string
comment?: string | null
created_at?: string
id?: number
related_record_key?: string | null
status: Database["audit"]["Enums"]["action_status"]
}
Update: {
action?: string
comment?: string | null
created_at?: string
id?: number
related_record_key?: string | null
status?: Database["audit"]["Enums"]["action_status"]
}
Relationships: []
}
page_views: { page_views: {
Row: { Row: {
account_id: string account_id: string
@@ -201,28 +285,6 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
medusa_action: {
Row: {
id: number
medusa_user_id: string
user_email: string
action: string
page: string
created_at: string
}
Insert: {
medusa_user_id: string
user_email: string
action: string
page: string
}
Update: {
medusa_user_id?: string
user_email?: string
action?: string
page?: string
}
}
} }
Views: { Views: {
[_ in never]: never [_ in never]: never
@@ -231,6 +293,7 @@ export type Database = {
[_ in never]: never [_ in never]: never
} }
Enums: { Enums: {
action_status: "SUCCESS" | "FAIL"
doctor_page_view_action: doctor_page_view_action:
| "VIEW_ANALYSIS_RESULTS" | "VIEW_ANALYSIS_RESULTS"
| "VIEW_DASHBOARD" | "VIEW_DASHBOARD"
@@ -329,14 +392,14 @@ export type Database = {
id: string id: string
is_personal_account: boolean is_personal_account: boolean
last_name: string | null last_name: string | null
medusa_account_id: string | null
name: string name: string
personal_code: string | null personal_code: string | null
phone: string | null phone: string | null
picture_url: string | null picture_url: string | null
preferred_locale: Database["medreport"]["Enums"]["locale"] | null
primary_owner_user_id: string primary_owner_user_id: string
public_data: Json
slug: string | null slug: string | null
medusa_account_id: string | null
updated_at: string | null updated_at: string | null
updated_by: string | null updated_by: string | null
} }
@@ -351,14 +414,14 @@ export type Database = {
id?: string id?: string
is_personal_account?: boolean is_personal_account?: boolean
last_name?: string | null last_name?: string | null
medusa_account_id?: string | null
name: string name: string
personal_code?: string | null personal_code?: string | null
phone?: string | null phone?: string | null
picture_url?: string | null picture_url?: string | null
preferred_locale?: Database["medreport"]["Enums"]["locale"] | null
primary_owner_user_id?: string primary_owner_user_id?: string
public_data?: Json
slug?: string | null slug?: string | null
medusa_account_id?: string | null
updated_at?: string | null updated_at?: string | null
updated_by?: string | null updated_by?: string | null
} }
@@ -373,14 +436,14 @@ export type Database = {
id?: string id?: string
is_personal_account?: boolean is_personal_account?: boolean
last_name?: string | null last_name?: string | null
medusa_account_id?: string | null
name?: string name?: string
personal_code?: string | null personal_code?: string | null
phone?: string | null phone?: string | null
picture_url?: string | null picture_url?: string | null
preferred_locale?: Database["medreport"]["Enums"]["locale"] | null
primary_owner_user_id?: string primary_owner_user_id?: string
public_data?: Json
slug?: string | null slug?: string | null
medusa_account_id?: string | null
updated_at?: string | null updated_at?: string | null
updated_by?: string | null updated_by?: string | null
} }
@@ -393,6 +456,7 @@ export type Database = {
created_at: string created_at: string
created_by: string | null created_by: string | null
has_seen_confirmation: boolean has_seen_confirmation: boolean
id: string
updated_at: string updated_at: string
updated_by: string | null updated_by: string | null
user_id: string user_id: string
@@ -403,6 +467,7 @@ export type Database = {
created_at?: string created_at?: string
created_by?: string | null created_by?: string | null
has_seen_confirmation?: boolean has_seen_confirmation?: boolean
id?: string
updated_at?: string updated_at?: string
updated_by?: string | null updated_by?: string | null
user_id: string user_id: string
@@ -413,6 +478,7 @@ export type Database = {
created_at?: string created_at?: string
created_by?: string | null created_by?: string | null
has_seen_confirmation?: boolean has_seen_confirmation?: boolean
id?: string
updated_at?: string updated_at?: string
updated_by?: string | null updated_by?: string | null
user_id?: string user_id?: string
@@ -1022,7 +1088,7 @@ export type Database = {
price: number price: number
price_periods: string | null price_periods: string | null
requires_payment: boolean requires_payment: boolean
sync_id: number sync_id: string | null
updated_at: string | null updated_at: string | null
} }
Insert: { Insert: {
@@ -1041,7 +1107,7 @@ export type Database = {
price: number price: number
price_periods?: string | null price_periods?: string | null
requires_payment: boolean requires_payment: boolean
sync_id: number sync_id?: string | null
updated_at?: string | null updated_at?: string | null
} }
Update: { Update: {
@@ -1060,7 +1126,7 @@ export type Database = {
price?: number price?: number
price_periods?: string | null price_periods?: string | null
requires_payment?: boolean requires_payment?: boolean
sync_id?: number sync_id?: string | null
updated_at?: string | null updated_at?: string | null
} }
Relationships: [ Relationships: [
@@ -1081,7 +1147,7 @@ export type Database = {
doctor_user_id: string | null doctor_user_id: string | null
id: number id: number
status: Database["medreport"]["Enums"]["analysis_feedback_status"] status: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at: string updated_at: string | null
updated_by: string | null updated_by: string | null
user_id: string user_id: string
value: string | null value: string | null
@@ -1093,7 +1159,7 @@ export type Database = {
doctor_user_id?: string | null doctor_user_id?: string | null
id?: number id?: number
status?: Database["medreport"]["Enums"]["analysis_feedback_status"] status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at?: string updated_at?: string | null
updated_by?: string | null updated_by?: string | null
user_id: string user_id: string
value?: string | null value?: string | null
@@ -1105,7 +1171,7 @@ export type Database = {
doctor_user_id?: string | null doctor_user_id?: string | null
id?: number id?: number
status?: Database["medreport"]["Enums"]["analysis_feedback_status"] status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
updated_at?: string updated_at?: string | null
updated_by?: string | null updated_by?: string | null
user_id?: string user_id?: string
value?: string | null value?: string | null
@@ -1784,9 +1850,7 @@ export type Database = {
Returns: Json Returns: Json
} }
create_team_account: { create_team_account: {
Args: Args: { account_name: string; new_personal_code: string }
| { account_name: string }
| { account_name: string; new_personal_code: string }
Returns: { Returns: {
application_role: Database["medreport"]["Enums"]["application_role"] application_role: Database["medreport"]["Enums"]["application_role"]
city: string | null city: string | null
@@ -1798,12 +1862,13 @@ export type Database = {
id: string id: string
is_personal_account: boolean is_personal_account: boolean
last_name: string | null last_name: string | null
medusa_account_id: string | null
name: string name: string
personal_code: string | null personal_code: string | null
phone: string | null phone: string | null
picture_url: string | null picture_url: string | null
preferred_locale: Database["medreport"]["Enums"]["locale"] | null
primary_owner_user_id: string primary_owner_user_id: string
public_data: Json
slug: string | null slug: string | null
updated_at: string | null updated_at: string | null
updated_by: string | null updated_by: string | null
@@ -1836,6 +1901,7 @@ export type Database = {
primary_owner_user_id: string primary_owner_user_id: string
name: string name: string
email: string email: string
personal_code: string
picture_url: string picture_url: string
created_at: string created_at: string
updated_at: string updated_at: string
@@ -1853,10 +1919,18 @@ export type Database = {
account_id: string account_id: string
}[] }[]
} }
get_medipost_dispatch_tries: {
Args: { p_medusa_order_id: string }
Returns: number
}
get_nonce_status: { get_nonce_status: {
Args: { p_id: string } Args: { p_id: string }
Returns: Json Returns: Json
} }
get_order_possible_actions: {
Args: { p_medusa_order_id: string }
Returns: Json
}
get_upper_system_role: { get_upper_system_role: {
Args: Record<PropertyKey, never> Args: Record<PropertyKey, never>
Returns: string Returns: string
@@ -1937,6 +2011,10 @@ export type Database = {
Args: { account_id: string; user_id: string } Args: { account_id: string; user_id: string }
Returns: boolean Returns: boolean
} }
medipost_retry_dispatch: {
Args: { order_id: string }
Returns: Json
}
revoke_nonce: { revoke_nonce: {
Args: { p_id: string; p_reason?: string } Args: { p_id: string; p_reason?: string }
Returns: boolean Returns: boolean
@@ -2057,21 +2135,6 @@ export type Database = {
} }
Returns: Json Returns: Json
} }
medipost_retry_dispatch: {
Args: {
order_id: string
}
Returns: {
success: boolean
error: string | null
}
}
get_medipost_dispatch_tries: {
Args: {
p_medusa_order_id: string
}
Returns: number
}
} }
Enums: { Enums: {
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED" analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"
@@ -2093,6 +2156,7 @@ export type Database = {
| "invites.manage" | "invites.manage"
application_role: "user" | "doctor" | "super_admin" application_role: "user" | "doctor" | "super_admin"
billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio" billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio"
locale: "en" | "et" | "ru"
notification_channel: "in_app" | "email" notification_channel: "in_app" | "email"
notification_type: "info" | "warning" | "error" notification_type: "info" | "warning" | "error"
payment_status: "pending" | "succeeded" | "failed" payment_status: "pending" | "succeeded" | "failed"
@@ -7959,6 +8023,7 @@ export type CompositeTypes<
export const Constants = { export const Constants = {
audit: { audit: {
Enums: { Enums: {
action_status: ["SUCCESS", "FAIL"],
doctor_page_view_action: [ doctor_page_view_action: [
"VIEW_ANALYSIS_RESULTS", "VIEW_ANALYSIS_RESULTS",
"VIEW_DASHBOARD", "VIEW_DASHBOARD",
@@ -7996,6 +8061,7 @@ export const Constants = {
], ],
application_role: ["user", "doctor", "super_admin"], application_role: ["user", "doctor", "super_admin"],
billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"], billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"],
locale: ["en", "et", "ru"],
notification_channel: ["in_app", "email"], notification_channel: ["in_app", "email"],
notification_type: ["info", "warning", "error"], notification_type: ["info", "warning", "error"],
payment_status: ["pending", "succeeded", "failed"], payment_status: ["pending", "succeeded", "failed"],

View File

@@ -1,16 +1,22 @@
'use client'; 'use client';
import { useCallback, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { useUpdateAccountData } from '@kit/accounts/hooks/use-update-account';
import { Database } from '@kit/supabase/database';
import { useUser } from '@kit/supabase/hooks/use-user';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '../shadcn/select'; } from '@kit/ui/select';
import { Trans } from '@kit/ui/trans';
export function LanguageSelector({ export function LanguageSelector({
onChange, onChange,
@@ -19,6 +25,9 @@ export function LanguageSelector({
}) { }) {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const { language: currentLanguage, options } = i18n; const { language: currentLanguage, options } = i18n;
const [value, setValue] = useState(i18n.language);
const { data: user } = useUser();
const locales = (options.supportedLngs as string[]).filter( const locales = (options.supportedLngs as string[]).filter(
(locale) => locale.toLowerCase() !== 'cimode', (locale) => locale.toLowerCase() !== 'cimode',
@@ -30,26 +39,37 @@ export function LanguageSelector({
}); });
}, [currentLanguage]); }, [currentLanguage]);
const [value, setValue] = useState(i18n.language); const userId = user?.id;
const updateAccountMutation = useUpdateAccountData(userId!);
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const languageChanged = useCallback( const updateLanguagePreference = async (
async (locale: string) => { locale: Database['medreport']['Enums']['locale'],
setValue(locale); ) => {
setValue(locale);
if (onChange) { if (onChange) {
onChange(locale); onChange(locale);
} }
await i18n.changeLanguage(locale); const promise = updateAccountMutation
.mutateAsync({
preferred_locale: locale,
})
.then(() => {
revalidateUserDataQuery(userId!);
});
await i18n.changeLanguage(locale);
// refresh cached translations return toast.promise(() => promise, {
window.location.reload(); success: <Trans i18nKey={'account:updatePreferredLocaleSuccess'} />,
}, error: <Trans i18nKey={'account:updatePreferredLocaleError'} />,
[i18n, onChange], loading: <Trans i18nKey={'account:updatePreferredLocaleLoading'} />,
); });
};
return ( return (
<Select value={value} onValueChange={languageChanged}> <Select value={value} onValueChange={updateLanguagePreference}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -125,5 +125,8 @@
}, },
"updateRoleSuccess": "Role updated", "updateRoleSuccess": "Role updated",
"updateRoleError": "Something went wrong, please try again", "updateRoleError": "Something went wrong, please try again",
"updateRoleLoading": "Updating role..." "updateRoleLoading": "Updating role...",
"updatePreferredLocaleSuccess": "Language preference updated",
"updatePreferredLocaleError": "Language preference update failed",
"updatePreferredLocaleLoading": "Updating language preference..."
} }

View File

@@ -9,8 +9,7 @@
}, },
"status": { "status": {
"QUEUED": "Waiting to send to lab", "QUEUED": "Waiting to send to lab",
"ON_HOLD": "Waiting for analysis results", "PROCESSING": "Waiting for results",
"PROCESSING": "In progress",
"PARTIAL_ANALYSIS_RESPONSE": "Partial analysis response", "PARTIAL_ANALYSIS_RESPONSE": "Partial analysis response",
"FULL_ANALYSIS_RESPONSE": "All analysis responses received, waiting for doctor response", "FULL_ANALYSIS_RESPONSE": "All analysis responses received, waiting for doctor response",
"COMPLETED": "Completed", "COMPLETED": "Completed",

View File

@@ -148,5 +148,8 @@
}, },
"updateRoleSuccess": "Roll uuendatud", "updateRoleSuccess": "Roll uuendatud",
"updateRoleError": "Midagi läks valesti. Palun proovi uuesti", "updateRoleError": "Midagi läks valesti. Palun proovi uuesti",
"updateRoleLoading": "Rolli uuendatakse..." "updateRoleLoading": "Rolli uuendatakse...",
"updatePreferredLocaleSuccess": "Eelistatud keel uuendatud",
"updatePreferredLocaleError": "Eelistatud keele uuendamine ei õnnestunud",
"updatePreferredLocaleLoading": "Eelistatud keelt uuendatakse..."
} }

View File

@@ -1,8 +1,9 @@
{ {
"title": "Vali teenus", "title": "Vali teenus",
"description": "Vali sobiv teenus või pakett vastavalt oma tervisemurele või -eesmärgile.", "description": "Vali sobiv teenus või pakett vastavalt oma tervisemurele või -eesmärgile.",
"analysisPackages": { "analysisPackages": {
"title": "Analüüside paketid", "title": "Analüüside paketid",
"description": "Tutvu personaalsete analüüsi pakettidega ja telli" "description": "Tutvu personaalsete analüüsi pakettidega ja telli"
} },
"noCategories": "Teenuste loetelu ei leitud, proovi hiljem uuesti"
} }

View File

@@ -80,7 +80,8 @@
"dashboard": "Ülevaade", "dashboard": "Ülevaade",
"settings": "Settings", "settings": "Settings",
"profile": "Profile", "profile": "Profile",
"application": "Application" "application": "Application",
"pickTime": "Vali aeg"
}, },
"roles": { "roles": {
"owner": { "owner": {

View File

@@ -9,7 +9,6 @@
}, },
"status": { "status": {
"QUEUED": "Esitatud", "QUEUED": "Esitatud",
"ON_HOLD": "Makstud",
"PROCESSING": "Synlabile edastatud", "PROCESSING": "Synlabile edastatud",
"PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused", "PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused",
"FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes, ootab arsti kokkuvõtet", "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes, ootab arsti kokkuvõtet",

View File

@@ -125,5 +125,8 @@
}, },
"updateRoleSuccess": "Role updated", "updateRoleSuccess": "Role updated",
"updateRoleError": "Something went wrong, please try again", "updateRoleError": "Something went wrong, please try again",
"updateRoleLoading": "Updating role..." "updateRoleLoading": "Updating role...",
"updatePreferredLocaleSuccess": "Language preference updated",
"updatePreferredLocaleError": "Language preference update failed",
"updatePreferredLocaleLoading": "Updating language preference..."
} }

View File

@@ -0,0 +1,7 @@
ALTER TABLE medreport.accounts
DROP COLUMN IF EXISTS public_data;
create type medreport.locale as enum ('en', 'et', 'ru');
ALTER TABLE medreport.accounts
ADD COLUMN preferred_locale medreport.locale

View File

@@ -0,0 +1,13 @@
create type "audit"."action_status" as enum ('SUCCESS', 'FAIL');
create table audit.notification_entries (
"id" bigint generated by default as identity not null,
"status" audit.action_status not null,
"action" text not null,
"comment" text,
"related_record_key" text,
"created_at" timestamp with time zone not null default now()
);
grant usage on schema audit to authenticated;
grant select, insert on table audit.notification_entries to authenticated;

View File

@@ -0,0 +1,2 @@
ALTER TABLE medreport.connected_online_services
ALTER COLUMN sync_id TYPE text USING sync_id::text;