Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into MED-103

This commit is contained in:
Helena
2025-09-11 10:09:37 +03:00
164 changed files with 3059 additions and 1158 deletions

View File

@@ -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);
}
}
}

View File

@@ -49,33 +49,28 @@ export async function renderAccountDeleteEmail(props: Props) {
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`, {
displayName: props.userDisplayName,
})}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph1`, {
productName: props.productName,
})}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph2`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph3`, {
productName: props.productName,
})}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph4`, {
productName: props.productName,

View File

@@ -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({
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>
@@ -62,7 +61,6 @@ export async function renderAllResultsReceivedEmail({
>
{t(`${namespace}:linkText`)}
</EmailButton>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}

View File

@@ -55,23 +55,19 @@ export async function renderCompanyOfferEmail({
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:companyName`)} {companyData.companyName}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:contactPerson`)} {companyData.contactPerson}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:email`)} {companyData.email}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:phone`)} {companyData.phone || 'N/A'}
</Text>

View File

@@ -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(
<Html>
@@ -54,29 +48,26 @@ export async function renderDoctorSummaryReceivedEmail({
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<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 })}
{t(`common:helloName`, { name: recipientName })}
</Text>
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
>
{t(`${namespace}:linkText`, { orderNr })}
</EmailButton>
<Text>
{t(`${namespace}:ifButtonDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
{t(`${namespace}:p1`)}{' '}
<Link
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
>
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
</Link>
</Text>
<Text>{t(`${namespace}:p2`)}</Text>
<Text>{t(`${namespace}:p3`)}</Text>
<Text>{t(`${namespace}:p4`)}</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>

View File

@@ -46,11 +46,10 @@ export async function renderFirstResultsReceivedEmail({
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>

View File

@@ -74,20 +74,17 @@ export async function renderInviteEmail(props: Props) {
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{hello}
</Text>
<Text
className="text-[16px] leading-[24px] text-[#242424]"
dangerouslySetInnerHTML={{ __html: mainText }}
/>
{props.teamLogo && (
<Section>
<Row>
@@ -102,20 +99,16 @@ export async function renderInviteEmail(props: Props) {
</Row>
</Section>
)}
<Section className="mb-[32px] mt-[32px] text-center">
<Section className="mt-[32px] mb-[32px] text-center">
<CtaButton href={props.link}>{joinTeam}</CtaButton>
</Section>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:copyPasteLink`)}{' '}
<Link href={props.link} className="text-blue-600 no-underline">
{props.link}
</Link>
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-[12px] leading-[24px] text-[#666666]">
{t(`${namespace}:invitationIntendedFor`, {
invitedUserEmail: props.invitedUserEmail,

View File

@@ -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({
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>

View File

@@ -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(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`common:helloName`, { name: recipientName })}
</Text>
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
{t(`${namespace}:heading`)}
</Text>
<Text>{t(`${namespace}:p1`, { partnerLocation })}</Text>
<Text dangerouslySetInnerHTML={{ __html: p2 }}></Text>
<Text>{t(`${namespace}:p3`)}</Text>
<Text dangerouslySetInnerHTML={{ __html: p4 }}></Text>
{isUrine && (
<>
<Text dangerouslySetInnerHTML={{ __html: p1Urine }}></Text>
<Text>{t(`${namespace}:p2Urine`)}</Text>
</>
)}
<Text>{t(`${namespace}:p5`)}</Text>
<Text>{t(`${namespace}:p6`)}</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -60,18 +60,17 @@ export async function renderOtpEmail(props: Props) {
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] text-[#242424]">{mainText}</Text>
<Text className="text-[16px] text-[#242424]">{otpText}</Text>
<Section className="mb-[16px] mt-[16px] text-center">
<Section className="mt-[16px] mb-[16px] text-center">
<Button className={'w-full rounded bg-neutral-950 text-center'}>
<Text className="text-[16px] font-semibold leading-[16px] text-white">
<Text className="text-[16px] leading-[16px] font-semibold text-white">
{props.otp}
</Text>
</Button>

View File

@@ -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(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`common:helloName`, { name: recipientName })}
</Text>
<Text>
{t(`${namespace}:p1`)}{' '}
<Link
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
>
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
</Link>
</Text>
<Text>{t(`${namespace}:p2`)}</Text>
<Text>{t(`${namespace}:p3`)}</Text>
<Text>{t(`${namespace}:p4`)}</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -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(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`common:helloName`, { name: recipientName })}
</Text>
<Text>
{t(`${namespace}:p1`)}{' '}
<Link
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
>
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
</Link>
</Text>
<Text>{t(`${namespace}:p2`)}</Text>
<Text>{t(`${namespace}:p3`)}</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -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) {
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<EmailContent>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{hello}
</Text>
{lines.map((line, index) => (
<Text
key={index}
@@ -86,7 +84,6 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
dangerouslySetInnerHTML={{ __html: line }}
/>
))}
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>

View File

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

View File

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

View File

@@ -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 - <a href='https://medreport.ee/et/verevotupunktid'>see locations and opening hours</a>.",
"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 <strong>referrals</strong> select <strong>specialist referral</strong>.",
"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 <strong>urine test</strong>. 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 points restroom)."
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -4,5 +4,7 @@
"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>"
}
},
"helloName": "Tere, {{name}}",
"hello": "Tere"
}

View File

@@ -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"
}

View File

@@ -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 - <a href='https://medreport.ee/et/verevotupunktid'>vaata asukohti ja lahtiolekuaegasid</a>.",
"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: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>",
"p5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
"p6": "SYNLAB klienditoe telefon: 17123",
"p1Urine": "Analüüsides on ette nähtud <strong>uriinianalüüs</strong>. 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)."
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -1,8 +1,8 @@
{
"subject": "Получено заключение врача по заказу {{orderNr}}",
"previewText": "Врач отправил заключение по вашим результатам анализа.",
"hello": "Здравствуйте, {{displayName}}",
"summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.",
"linkText": осмотреть заключение",
"ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:"
"subject": "Заключение врача готово",
"previewText": "Врач подготовил заключение по результатам анализов.",
"p1": "Заключение врача готово:",
"p2": "Рекомендуется проходить комплексное обследование регулярно, но как минимум один раз в год, если вы хотите сохранить активный и полноценный образ жизни.",
"p3": "MedReport позволяет легко, удобно и быстро просматривать медицинские данные в одном месте и заказывать обследования.",
"p4": "Телефон службы поддержки SYNLAB: 17123"
}

View File

@@ -0,0 +1,13 @@
{
"subject": "Направление отправлено в лабораторию. Пожалуйста, сдайте анализы.",
"heading": "Спасибо за заказ!",
"previewText": "Направление на обследование отправлено в лабораторию.",
"p1": "Направление на обследование было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: {{partnerLocation}}.",
"p2": "Если у вас нет возможности прийти в выбранный пункт сдачи анализов, вы можете обратиться в любой удобный для вас пункт <a href='https://medreport.ee/et/verevotupunktid'>посмотреть адреса и часы работы</a>.",
"p3": "Рекомендуется сдавать анализы утром (до 12:00) натощак, без еды и напитков (разрешается пить воду).",
"p4": "В пункте сдачи анализов выберите в системе очереди: в разделе <strong>направления</strong> → <strong>направление от специалиста</strong>.",
"p5": "Если у вас возникли дополнительные вопросы, пожалуйста, свяжитесь с нами.",
"p6": "Телефон службы поддержки SYNLAB: 17123",
"p1Urine": "В обследование входит <strong>анализ мочи</strong>. Для анализа необходимо собрать первую утреннюю мочу.",
"p2Urine": "Контейнер можно приобрести в аптеке и принести образец с собой (процедура проводится дома) или взять контейнер в пункте сдачи (процедура проводится в туалете пункта)."
}

View File

@@ -0,0 +1,8 @@
{
"subject": "Поступили первые результаты заказанных исследований",
"previewText": "Первые результаты исследований поступили.",
"p1": "Первые результаты исследований поступили:",
"p2": "Мы отправим следующее уведомление, когда все результаты исследований будут получены в системе.",
"p3": "Если у вас возникнут дополнительные вопросы, пожалуйста, свяжитесь с нами.",
"p4": "Телефон службы поддержки SYNLAB: 17123"
}

View File

@@ -0,0 +1,7 @@
{
"subject": "Все заказанные результаты исследований поступили. Ожидается заключение врача.",
"previewText": "Все результаты исследований поступили.",
"p1": "Все результаты исследований поступили:",
"p2": "Мы отправим следующее уведомление, когда заключение врача будет подготовлено.",
"p3": "Телефон службы поддержки SYNLAB: 17123"
}

View File

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

View File

@@ -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 (
<div
className={
'dark:bg-secondary flex flex-col space-y-4 rounded-lg border p-4'
'dark:bg-secondary flex flex-col space-y-2 rounded-lg border p-4'
}
>
<p>
@@ -343,6 +346,10 @@ function FactorQrCode({
<div className={'flex justify-center'}>
<QrImage src={form.getValues('qrCode')} />
</div>
<p className='text-center text-sm'>
{form.getValues('totpSecret')}
</p>
</div>
);
}

View File

@@ -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<AccountWithParams> {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select(
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
)
.eq('primary_owner_user_id', userId)
.eq('is_personal_account', true)
.single();
if (error) {
throw error;
}
const { personal_code, ...rest } = data;
return {
...rest,
personal_code: PersonalCode.getPersonalCode(personal_code),
};
}
/**
* @name getAccountWorkspace
* @description Get the account workspace data.

View File

@@ -10,7 +10,7 @@ const personalCodeSchema = z.string().refine(
}
},
{
message: 'Invalid personal code',
message: 'common:formFieldError.invalidPersonalCode',
},
);

View File

@@ -7,9 +7,8 @@ export function AuthLayoutShell({
return (
<div
className={
'sm:py-auto flex flex-col items-center justify-center py-6' +
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' +
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
'sm:py-auto flex flex-col items-center justify-center py-6 h-screen' +
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8'
}
>
{Logo ? <Logo /> : null}

View File

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

View File

@@ -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 (
<>
<If condition={showVerifyEmailAlert}>
<SuccessAlert />
{authConfig.isMailerAutoconfirmEnabled ? (
<div className="flex justify-center">
<Spinner />
</div>
) : <SuccessAlert />
}
</If>
<If condition={!showVerifyEmailAlert}>
@@ -53,7 +67,7 @@ export function EmailPasswordSignUpContainer({
onSubmit={onSignupRequested}
loading={loading}
defaultValues={defaultValues}
displayTermsCheckbox={displayTermsCheckbox}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
/>
</If>
</>

View File

@@ -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',
}}
/>
</If>
</>

View File

@@ -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: {
<InviteAlert />
</If>
<If condition={props.providers.password}>
<If condition={props.authConfig.providers.password}>
<EmailPasswordSignUpContainer
emailRedirectTo={props.paths.callback}
defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox}
onSignUp={() => redirect(redirectUrl)}
authConfig={props.authConfig}
onSignUp={() => {
if (!props.authConfig.isMailerAutoconfirmEnabled) {
return;
}
setTimeout(() => {
router.replace(props.paths.updateAccount)
}, 2_500);
}}
/>
</If>
<If condition={props.providers.magicLink}>
<If condition={props.authConfig.providers.magicLink}>
<MagicLinkAuthContainer
inviteToken={props.inviteToken}
redirectUrl={redirectUrl}
shouldCreateUser={true}
defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox}
displayTermsCheckbox={props.authConfig.displayTermsCheckbox}
/>
</If>
<If condition={props.providers.oAuth.length}>
<If condition={props.authConfig.providers.oAuth.length}>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
@@ -72,13 +83,16 @@ export function SignUpMethodsContainer(props: {
</div>
<OauthProviders
enabledProviders={props.providers.oAuth}
enabledProviders={props.authConfig.providers.oAuth}
inviteToken={props.inviteToken}
shouldCreateUser={true}
paths={{
callback: props.paths.callback,
returnPath: props.paths.appHome,
}}
queryParams={{
prompt: 'login',
}}
/>
</If>
</>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<typeof doctorJobSelectSchema>;

View File

@@ -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<string> => {
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(),
})
}

View File

@@ -4,7 +4,6 @@ import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
@@ -127,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<string> {
// Use the user ID or email as the base for deterministic generation
const baseString = userId || email;
const secret = process.env.MEDUSA_PASSWORD_SECRET!;
// Create a deterministic password using HMAC
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(baseString);
// Import key for HMAC
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// Generate HMAC
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// Convert to base64 and make it a valid password
const hashArray = Array.from(new Uint8Array(signature));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// Take first 24 characters and add some complexity
const basePassword = hashHex.substring(0, 24);
// Add some required complexity for Medusa (uppercase, lowercase, numbers, symbols)
return `Mk${basePassword}9!`;
}

View File

@@ -54,7 +54,6 @@ export const listOrders = async (
},
headers,
next,
cache: "force-cache",
})
.then(({ orders }) => orders)
.catch((err) => medusaError(err))

View File

@@ -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 }) => {

View File

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

View File

@@ -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:*",

View File

@@ -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,
});
}
}

View File

@@ -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<StoreProduct, 'title' | 'description' | 'subtitle' | 'metadata'> & {
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<AdminProductVariant, 'metadata'>;
};
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(<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />);
toast.success(
<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />,
);
router.push(pathsConfig.app.cart);
} catch (e) {
toast.error(<Trans i18nKey={'order-analysis-package:analysisPackageAddToCartError'} />);
toast.error(
<Trans
i18nKey={'order-analysis-package:analysisPackageAddToCartError'}
/>,
);
setIsAddingToCart(false);
console.error(e);
}
@@ -86,7 +106,7 @@ export default function SelectAnalysisPackage({
<CardContent className="space-y-1 text-center">
<PackageHeader
title={title}
tagColor='bg-cyan'
tagColor="bg-cyan"
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
language={language}
price={price}
@@ -94,8 +114,15 @@ export default function SelectAnalysisPackage({
<CardDescription>{subtitle}</CardDescription>
</CardContent>
<CardFooter>
<Button className="w-full text-[10px] sm:text-sm" onClick={handleSelect} isLoading={isAddingToCart}>
{!isAddingToCart && <Trans i18nKey='order-analysis-package:selectThisPackage' />}
<Button
className="w-full text-[10px] sm:text-sm"
onClick={handleSelect}
>
{isAddingToCart ? (
<Spinner />
) : (
<Trans i18nKey="order-analysis-package:selectThisPackage" />
)}
</Button>
</CardFooter>
</Card>

View File

@@ -23,7 +23,7 @@ export function InfoTooltip({
<TooltipTrigger>
{icon || <Info className="size-4 cursor-pointer" />}
</TooltipTrigger>
<TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent>
<TooltipContent className='max-w-[90vw] sm:max-w-[400px]'>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);

View File

@@ -0,0 +1,144 @@
import type { Provider } from '@supabase/supabase-js';
import authConfig from './auth.config';
type SupabaseExternalProvider = Provider | 'email';
interface SupabaseAuthSettings {
external: Record<SupabaseExternalProvider, boolean>;
disable_signup: boolean;
mailer_autoconfirm: boolean;
}
export class AuthProvidersService {
private supabaseUrl: string;
private cache: Map<string, { data: SupabaseAuthSettings; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
constructor(supabaseUrl: string) {
this.supabaseUrl = supabaseUrl;
}
async fetchAuthSettings(): Promise<SupabaseAuthSettings | null> {
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);
}

View File

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

View File

@@ -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<Provider> = 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<boolean> {
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',
]);
}

View File

@@ -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,
};

View File

@@ -1,2 +1,3 @@
export * from './use-csrf-token';
export * from './use-current-locale-language-names';
export * from './use-auth-config';

View File

@@ -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<void>;
}
export function useAuthConfig(): UseAuthConfigResult {
const [config, setConfig] = useState<AuthConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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,
};
}

View File

@@ -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.

View File

@@ -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"]

View File

@@ -4,6 +4,7 @@ import {
AuthError,
type EmailOtpType,
SupabaseClient,
User,
} from '@supabase/supabase-js';
/**
@@ -20,7 +21,7 @@ export function createAuthCallbackService(client: SupabaseClient) {
* @description Service for handling auth callbacks in Supabase
*/
class AuthCallbackService {
constructor(private readonly client: SupabaseClient) {}
constructor(private readonly client: SupabaseClient) { }
/**
* @name verifyTokenHash
@@ -128,89 +129,117 @@ class AuthCallbackService {
/**
* @name exchangeCodeForSession
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
* @param request
* @param params
* @param authCode
*/
async exchangeCodeForSession(
request: Request,
params: {
joinTeamPath: string;
redirectPath: string;
errorPath?: string;
},
): Promise<{
nextPath: string;
}> {
const requestUrl = new URL(request.url);
const searchParams = requestUrl.searchParams;
async exchangeCodeForSession(authCode: string): Promise<{
isSuccess: boolean;
user: User;
} | ErrorURLParameters> {
let user: User;
try {
const { data, error } =
await this.client.auth.exchangeCodeForSession(authCode);
const authCode = searchParams.get('code');
const error = searchParams.get('error');
const nextUrlPathFromParams = searchParams.get('next');
const inviteToken = searchParams.get('invite_token');
const errorPath = params.errorPath ?? '/auth/callback/error';
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
// if we have an invite token, we redirect to the join team page
// instead of the default next url. This is because the user is trying
// to join a team and we want to make sure they are redirected to the
// correct page.
if (inviteToken) {
const emailParam = searchParams.get('email');
const urlParams = new URLSearchParams({
invite_token: inviteToken,
email: emailParam ?? '',
});
nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
}
if (authCode) {
try {
const { error } =
await this.client.auth.exchangeCodeForSession(authCode);
// if we have an error, we redirect to the error page
if (error) {
return onError({
code: error.code,
error: error.message,
path: errorPath,
});
}
} catch (error) {
console.error(
{
error,
name: `auth.callback`,
},
`An error occurred while exchanging code for session`,
);
const message = error instanceof Error ? error.message : error;
return onError({
code: (error as AuthError)?.code,
error: message as string,
path: errorPath,
// if we have an error, we redirect to the error page
if (error) {
return getErrorURLParameters({
code: error.code,
error: error.message,
});
}
}
if (error) {
return onError({
error,
path: errorPath,
// Handle Keycloak users - set up Medusa integration
if (data?.user && this.isKeycloakUser(data.user)) {
await this.setupMedusaUserForKeycloak(data.user);
}
user = data.user;
} catch (error) {
console.error(
{
error,
name: `auth.callback`,
},
`An error occurred while exchanging code for session`,
);
const message = error instanceof Error ? error.message : error;
return getErrorURLParameters({
code: (error as AuthError)?.code,
error: message as string,
});
}
return {
nextPath: nextUrl,
isSuccess: true,
user,
};
}
/**
* Check if user is from Keycloak provider
*/
private isKeycloakUser(user: any): boolean {
return user?.app_metadata?.provider === 'keycloak' ||
user?.app_metadata?.providers?.includes('keycloak');
}
private async setupMedusaUserForKeycloak(user: any): Promise<void> {
if (!user.email) {
console.warn('Keycloak user has no email, skipping Medusa setup');
return;
}
try {
// Check if user already has medusa_account_id
const { data: accountData, error: fetchError } = await this.client
.schema('medreport')
.from('accounts')
.select('medusa_account_id, name, last_name')
.eq('primary_owner_user_id', user.id)
.eq('is_personal_account', true)
.single();
if (fetchError && fetchError.code !== 'PGRST116') {
console.error('Error fetching account data for Keycloak user:', fetchError);
return;
}
// If user already has Medusa account, we're done
if (accountData?.medusa_account_id) {
console.log('Keycloak user already has Medusa account:', accountData.medusa_account_id);
return;
}
const { medusaLoginOrRegister } = await import('../../features/medusa-storefront/src/lib/data/customer');
const medusaAccountId = await medusaLoginOrRegister({
email: user.email,
supabaseUserId: user.id,
name: accountData?.name ?? '-',
lastName: accountData?.last_name ?? '-',
});
// Update the account with the Medusa account ID
const { error: updateError } = await this.client
.schema('medreport')
.from('accounts')
.update({ medusa_account_id: medusaAccountId })
.eq('primary_owner_user_id', user.id)
.eq('is_personal_account', true);
if (updateError) {
console.error('Error updating account with Medusa ID:', updateError);
return;
}
console.log('Successfully set up Medusa account for Keycloak user:', medusaAccountId);
} catch (error) {
console.error('Error setting up Medusa account for Keycloak user:', error);
}
}
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
url.host = host as string;
@@ -231,15 +260,19 @@ class AuthCallbackService {
}
}
function onError({
interface ErrorURLParameters {
error: string;
code?: string;
searchParams: string;
}
export function getErrorURLParameters({
error,
path,
code,
}: {
error: string;
path: string;
code?: string;
}) {
}): ErrorURLParameters {
const errorMessage = getAuthErrorMessage({ error, code });
console.error(
@@ -255,10 +288,10 @@ function onError({
code: code ?? '',
});
const nextPath = `${path}?${searchParams.toString()}`;
return {
nextPath,
error: errorMessage,
code: code ?? '',
searchParams: searchParams.toString(),
};
}

View File

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

View File

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

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

@@ -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;
}
},
});
}

View File

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

View File

@@ -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,
});
}

View File

@@ -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 (
<Fragment key={index}>
<BreadcrumbItem className={'capitalize lg:text-xs'}>
<BreadcrumbItem className={clsx('lg:text-xs', {
'font-bold text-black': isLast,
})}>
<If
condition={index < visiblePaths.length - 1}
fallback={label}
@@ -64,6 +69,7 @@ export function AppBreadcrumbs(props: {
'/' +
splitPath.slice(0, splitPath.indexOf(path) + 1).join('/')
}
className='text-muted-foreground'
>
{label}
</BreadcrumbLink>
@@ -77,7 +83,7 @@ export function AppBreadcrumbs(props: {
</>
)}
<If condition={index !== visiblePaths.length - 1}>
<If condition={!isLast}>
<BreadcrumbSeparator />
</If>
</Fragment>

View File

@@ -53,6 +53,7 @@ export function LanguageSelector({
}
if (!userId) {
localStorage.setItem('lang', locale);
return i18n.changeLanguage(locale);
}

View File

@@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) {
>
{MobileNavigation}
<div className={'bg-background flex flex-1 flex-col px-4 pb-8 lg:px-0'}>
<div className={'bg-background flex flex-1 flex-col px-2 pb-8 lg:px-0'}>
{Children}
</div>
</div>
@@ -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 (
<div className={'flex h-6 items-center'}>
<div className={'text-muted-foreground text-sm leading-none font-normal'}>
<div className={'flex items-center'}>
<div className={'text-muted-foreground text-sm font-normal leading-6'}>
{props.children}
</div>
</div>
@@ -158,7 +158,7 @@ export function PageHeader({
return (
<div
className={cn(
'flex items-center justify-between py-5 lg:px-4',
'flex items-center justify-between py-5',
className,
)}
>
@@ -168,7 +168,7 @@ export function PageHeader({
</If>
<If condition={displaySidebarTrigger || description}>
<div className="flex items-center gap-x-2.5">
<div className="flex items-center gap-3">
{displaySidebarTrigger ? (
<SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground hidden h-4.5 w-4.5 cursor-pointer lg:inline-flex" />
) : null}

View File

@@ -34,7 +34,7 @@ const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
<div className={cn('flex flex-col space-y-1.5 p-4 sm:p-6', className)} {...props} />
);
CardHeader.displayName = 'CardHeader';
@@ -60,14 +60,14 @@ CardDescription.displayName = 'CardDescription';
const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn('p-6 pt-0', className)} {...props} />;
}) => <div className={cn('p-4 sm:p-6 pt-0', className)} {...props} />;
CardContent.displayName = 'CardContent';
const CardFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div className={cn('flex items-center p-6 pt-0', className)} {...props} />
<div className={cn('flex items-center p-4 sm:p-6 pt-0', className)} {...props} />
);
CardFooter.displayName = 'CardFooter';