4 Commits

Author SHA1 Message Date
bc61db07b2 test2 2025-09-04 01:21:42 +03:00
3d58e5aa84 try to display price before adding to cart 2025-09-03 23:04:09 +03:00
8ecca096f2 fix adding to cart loading 2025-09-03 23:03:00 +03:00
84c8dcc792 env test 2025-09-03 14:24:56 +03:00
144 changed files with 4392 additions and 57668 deletions

View File

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

3
.env
View File

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

View File

@@ -3,7 +3,6 @@
# SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_PASSWORD=true
# SUPABASE DEVELOPMENT
@@ -26,6 +25,18 @@ EMAIL_PORT=1025 # or 465 for SSL
EMAIL_TLS=false
NODE_TLS_REJECT_UNAUTHORIZED=0
# MEDUSA
MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
# JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
# MEDIPOST
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
@@ -34,57 +45,48 @@ MEDIPOST_PASSWORD=SRB48HZMV
MEDIPOST_RECIPIENT=trvurgtst
MEDIPOST_MESSAGE_SENDER=trvurgtst
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
#MEDIPOST_USER=medreport
#MEDIPOST_PASSWORD=85MXFFDB7
#MEDIPOST_RECIPIENT=HTI
#MEDIPOST_MESSAGE_SENDER=medreport
# MEDUSA
MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
#MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
#MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_827a2ab863021cb67993f1d81078f81bfce4b4e0da642d8c0f5398ded9d8fd32
#MEDUSA_BACKEND_URL=https://backoffice.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice.medreport.ee
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_068d930c33fea53608a410d84a51935f6ce2ccec5bef8e0ecf75eaee602ac486
#MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
#MEDUSA_BACKEND_PUBLIC_URL=http://5.181.51.38:9000
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
#NEXT_PUBLIC_MONTONIO_ACCESS_KEY=13e3686a-e7ad-41f6-998b-3f7d7de17654
#MONTONIO_SECRET_KEY=wTd4BZ01h80KZLMPL4mjt0RCFxKaYRSu9mMB1PQZCxnw
#MONTONIO_API_URL=https://stargate.montonio.com
# JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1Njk4NDYyOCwiZXhwIjoyMDcyNTYwNjI4fQ.1UZR7AqSD9bOy1gtZRGhOCNoESsw2W-DoFDDsNNMwoE
#NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
#NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
#SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
#NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
#NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
#SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
MEDIPOST_USER=medreport
MEDIPOST_PASSWORD=85MXFFDB7
MEDIPOST_RECIPIENT=HTI
MEDIPOST_MESSAGE_SENDER=medreport
### TEST.MEDREPORT.ee ###
DB_PASSWORD=T#u-$M7%RjbA@L@
#### MEDUSA
MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_827a2ab863021cb67993f1d81078f81bfce4b4e0da642d8c0f5398ded9d8fd32
#### SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
#######
### LOCAL ###
#### MEDUSA
MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
#MEDUSA_BACKEND_PUBLIC_URL=http://5.181.51.38:9000
MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
#### SUPABASE
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
#######
SUPABASE_AUTH_CLIENT_ID=supabase
SUPABASE_AUTH_KEYCLOAK_SECRET=Gl394GjizClhQl06KFeoFyZ7ZbPamG5I
SUPABASE_AUTH_KEYCLOAK_URL=http://localhost:8585/realms/medreport-sandbox
SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL=http://localhost:3000/auth/callback

View File

@@ -19,4 +19,6 @@ EMAIL_PASSWORD=password
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
CONTACT_EMAIL=test@makerkit.dev
CONTACT_EMAIL=test@makerkit.dev
SUPABASE_AUTH_KEYCLOAK_URL=https://keycloak.medreport.ee/realms/medreport-prod

View File

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

View File

@@ -9,16 +9,18 @@ import { ContactEmailSchema } from '../contact-email.schema';
const contactEmail = z
.string({
error:
description: `The email where you want to receive the contact form submissions.`,
required_error:
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
}).describe(`The email where you want to receive the contact form submissions.`)
})
.parse(process.env.CONTACT_EMAIL);
const emailFrom = z
.string({
error:
description: `The email sending address.`,
required_error:
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
}).describe(`The email sending address.`)
})
.parse(process.env.EMAIL_SENDER);
export const sendContactEmail = enhanceAction(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
import Link from 'next/link';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
export default function PasswordOption({
inviteToken,
returnPath,
}: {
inviteToken?: string;
returnPath?: string;
}) {
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
const paths = {
callback: pathsConfig.auth.callback,
returnPath: returnPath ?? pathsConfig.app.home,
joinTeam: pathsConfig.app.joinTeam,
updateAccount: pathsConfig.auth.updateAccount,
};
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth:signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth:signInSubheading'} />
</p>
</div>
<SignInMethodsContainer
inviteToken={inviteToken}
paths={paths}
providers={authConfig.providers}
/>
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signUpPath} prefetch={true}>
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link>
</Button>
</div>
</>
);
}

View File

@@ -1,37 +0,0 @@
'use client';
import Loading from '@/app/home/loading';
import { useEffect } from 'react';
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
import { useRouter } from 'next/navigation';
export function SignInPageClientRedirect() {
const router = useRouter();
useEffect(() => {
async function signIn() {
const { data, error } = await getSupabaseBrowserClient()
.auth
.signInWithOAuth({
provider: 'keycloak',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
prompt: 'login',
},
}
});
if (error) {
console.error('OAuth error', error);
router.push('/');
} else if (data.url) {
router.push(data.url);
}
}
signIn();
}, [router]);
return <Loading />;
}

View File

@@ -1,9 +1,14 @@
import { pathsConfig, authConfig } from '@kit/shared/config';
import Link from 'next/link';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
import PasswordOption from './components/PasswordOption';
interface SignInPageProps {
searchParams: Promise<{
@@ -21,14 +26,47 @@ export const generateMetadata = async () => {
};
async function SignInPage({ searchParams }: SignInPageProps) {
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
const { invite_token: inviteToken, next = pathsConfig.app.home } =
await searchParams;
if (authConfig.providers.password) {
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
}
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
return <SignInPageClientRedirect />;
const paths = {
callback: pathsConfig.auth.callback,
returnPath: next ?? pathsConfig.app.home,
joinTeam: pathsConfig.app.joinTeam,
updateAccount: pathsConfig.auth.updateAccount,
};
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth:signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth:signInSubheading'} />
</p>
</div>
<SignInMethodsContainer
inviteToken={inviteToken}
paths={paths}
providers={authConfig.providers}
/>
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signUpPath} prefetch={true}>
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link>
</Button>
</div>
</>
);
}
export default withI18n(SignInPage);

View File

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

View File

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

View File

@@ -1,20 +1,19 @@
import { z } from 'zod';
import parsePhoneNumber from 'libphonenumber-js/min';
export const UpdateAccountSchema = z.object({
firstName: z
.string({
error: 'First name is required',
required_error: 'First name is required',
})
.nonempty(),
lastName: z
.string({
error: 'Last name is required',
required_error: 'Last name is required',
})
.nonempty(),
personalCode: z
.string({
error: 'Personal code is required',
required_error: 'Personal code is required',
})
.nonempty(),
email: z.string().email({
@@ -22,38 +21,21 @@ export const UpdateAccountSchema = z.object({
}),
phone: z
.string({
error: 'Phone number is required',
required_error: 'Phone number is required',
})
.nonempty()
.refine(
(phone) => {
try {
const phoneNumber = parsePhoneNumber(phone);
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPhoneNumber',
}
),
.nonempty(),
city: z.string().optional(),
weight: z
.number({
error: (issue) =>
issue.input === undefined
? 'Weight is required'
: 'Weight must be a number',
required_error: 'Weight is required',
invalid_type_error: 'Weight must be a number',
})
.gt(0, { message: 'Weight must be greater than 0' }),
height: z
.number({
error: (issue) =>
issue.input === undefined
? 'Height is required'
: 'Height must be a number',
required_error: 'Height is required',
invalid_type_error: 'Height must be a number',
})
.gt(0, { message: 'Height must be greater than 0' }),
userConsent: z.boolean().refine((val) => val === true, {

View File

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

View File

@@ -11,39 +11,18 @@ import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { UpdateAccountForm } from './_components/update-account-form';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { toTitleCase } from '~/lib/utils';
async function UpdateAccount() {
const client = getSupabaseServerClient();
const account = await loadCurrentUserAccount();
const {
data: { user },
} = await client.auth.getUser();
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
if (!user) {
redirect(pathsConfig.auth.signIn);
}
const defaultValues = {
firstName: account?.name ? toTitleCase(account.name) : '',
lastName: account?.last_name ? toTitleCase(account.last_name) : '',
personalCode: account?.personal_code ?? '',
email: (() => {
if (isKeycloakUser) {
return account?.email ?? '';
}
return account?.email ?? user?.email ?? '';
})(),
phone: account?.phone ?? '',
city: account?.city ?? '',
weight: account?.accountParams?.weight ?? 0,
height: account?.accountParams?.height ?? 0,
userConsent: account?.has_consent_personal_data ?? false,
};
return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
@@ -55,7 +34,7 @@ async function UpdateAccount() {
<p className="text-muted-foreground pt-1 text-sm">
<Trans i18nKey={'account:updateAccount:description'} />
</p>
<UpdateAccountForm defaultValues={defaultValues} />
<UpdateAccountForm user={user} />
</div>
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
</div>

View File

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

View File

@@ -1,11 +1,17 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types';
import { z } from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo';
import { pathsConfig } from '@kit/shared/config';
import {
pathsConfig,
personalAccountNavigationConfig,
} from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
@@ -18,11 +24,40 @@ import { HomeSidebar } from '../_components/home-sidebar';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
if (state.style === 'sidebar') {
return <SidebarLayout>{children}</SidebarLayout>;
}
return <HeaderLayout>{children}</HeaderLayout>;
}
export default withI18n(UserHomeLayout);
function SidebarLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const state = use(getLayoutState());
return (
<UserWorkspaceContextProvider value={workspace}>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<HomeSidebar />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} cart={null} />
</PageMobileNavigation>
{children}
</Page>
</SidebarProvider>
</UserWorkspaceContextProvider>
);
}
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart());
@@ -66,3 +101,27 @@ function MobileNavigation({
</>
);
}
async function getLayoutState() {
const cookieStore = await cookies();
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
const layoutStyleCookie = cookieStore.get('layout-style');
const sidebarOpenCookie = cookieStore.get('sidebar:state');
const sidebarOpen = sidebarOpenCookie
? sidebarOpenCookie.value === 'false'
: !personalAccountNavigationConfig.sidebarCollapsed;
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
const style = parsedStyle.success
? parsedStyle.data
: personalAccountNavigationConfig.style;
return {
open: sidebarOpen,
style,
};
}

View File

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

View File

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

View File

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

View File

@@ -7,41 +7,33 @@ import { Database } from '@/packages/supabase/src/database.types';
import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons';
import {
Activity,
ChevronRight,
Clock9,
Droplets,
Pill,
Scale,
TrendingUp,
User,
} from 'lucide-react';
import { pathsConfig } from '@kit/shared/config';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { getPersonParameters } from '@kit/shared/utils';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardProps,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { isNil } from 'lodash';
import { BmiCategory } from '~/lib/types/bmi';
import {
bmiFromMetric,
getBmiBackgroundColor,
getBmiStatus,
} from '~/lib/utils';
import DashboardRecommendations from './dashboard-recommendations';
const getCardVariant = (isSuccess: boolean | null): CardProps['variant'] => {
if (isSuccess === null) return 'default';
if (isSuccess) return 'gradient-success';
return 'gradient-destructive';
};
const cards = ({
gender,
@@ -49,91 +41,111 @@ const cards = ({
height,
weight,
bmiStatus,
smoking,
}: {
gender?: string;
age?: number;
height?: number | null;
weight?: number | null;
bmiStatus: BmiCategory | null;
smoking?: boolean | null;
}) => [
{
title: 'dashboard:gender',
description: gender ?? 'dashboard:male',
icon: <User />,
iconBg: 'bg-success',
},
{
title: 'dashboard:age',
description: age ? `${age}` : '-',
icon: <Clock9 />,
iconBg: 'bg-success',
},
{
title: 'dashboard:height',
description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success',
},
{
title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-',
icon: <Scale />,
iconBg: 'bg-success',
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},
{
title: 'dashboard:bloodPressure',
description: '-',
icon: <Activity />,
iconBg: 'bg-warning',
},
{
title: 'dashboard:cholesterol',
description: '-',
icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive',
},
{
title: 'dashboard:ldlCholesterol',
description: '-',
icon: <Pill />,
iconBg: 'bg-warning',
},
// {
// title: 'Score 2',
// description: 'Normis',
// icon: <LineChart />,
// iconBg: 'bg-success',
// },
{
title: 'dashboard:smoking',
description:
isNil(smoking)
? 'dashboard:respondToQuestion'
: !!smoking
? 'common:yes'
: 'common:no',
descriptionColor: 'text-primary',
icon:
isNil(smoking) ? (
<Link href={pathsConfig.app.personalAccountSettings}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
) : null,
cardVariant: getCardVariant(isNil(smoking) ? null : !smoking),
},
];
{
title: 'dashboard:gender',
description: gender ?? 'dashboard:male',
icon: <User />,
iconBg: 'bg-success',
},
{
title: 'dashboard:age',
description: age ? `${age}` : '-',
icon: <Clock9 />,
iconBg: 'bg-success',
},
{
title: 'dashboard:height',
description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success',
},
{
title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-',
icon: <Scale />,
iconBg: 'bg-success',
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(),
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},
{
title: 'dashboard:bloodPressure',
description: '-',
icon: <Activity />,
iconBg: 'bg-warning',
},
{
title: 'dashboard:cholesterol',
description: '-',
icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive',
},
{
title: 'dashboard:ldlCholesterol',
description: '-',
icon: <Pill />,
iconBg: 'bg-warning',
},
// {
// title: 'Score 2',
// description: 'Normis',
// icon: <LineChart />,
// iconBg: 'bg-success',
// },
// {
// title: 'dashboard:smoking',
// description: 'dashboard:respondToQuestion',
// descriptionColor: 'text-primary',
// icon: (
// <Button size="icon" variant="outline" className="px-2 text-black">
// <ChevronRight className="size-4 stroke-2" />
// </Button>
// ),
// cardVariant: 'gradient-success' as CardProps['variant'],
// },
];
const IS_SHOWN_RECOMMENDATIONS = false as boolean;
const dummyRecommendations = [
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-cyan/10 text-cyan',
title: 'Kolesterooli kontroll',
description: 'HDL-kolestrool',
tooltipContent: 'Selgitus',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
{
icon: <BlendingModeIcon className="size-4" />,
color: 'bg-primary/10 text-primary',
title: 'Kolesterooli kontroll',
tooltipContent: 'Selgitus',
description: 'LDL-Kolesterool',
buttonText: 'Broneeri',
href: '/home/booking',
},
{
icon: <Droplets />,
color: 'bg-destructive/10 text-destructive',
title: 'Vererõhu kontroll',
tooltipContent: 'Selgitus',
description: 'Score-Risk 2',
price: '20,00 €',
buttonText: 'Telli',
href: '/home/booking',
},
];
export default function Dashboard({
account,
@@ -148,8 +160,8 @@ export default function Dashboard({
const params = getPersonParameters(account.personal_code!);
const bmiStatus = getBmiStatus(bmiThresholds, {
age: params?.age || 0,
height: account.accountParams?.height || 0,
weight: account.accountParams?.weight || 0,
height: account.account_params?.[0]?.height || 0,
weight: account.account_params?.[0]?.weight || 0,
});
return (
@@ -158,22 +170,21 @@ export default function Dashboard({
{cards({
gender: params?.gender,
age: params?.age,
height: account.accountParams?.height,
weight: account.accountParams?.weight,
height: account.account_params?.[0]?.height,
weight: account.account_params?.[0]?.weight,
bmiStatus,
smoking: account.accountParams?.isSmoker,
}).map(
({
title,
description,
icon,
iconBg,
cardVariant,
// cardVariant,
// descriptionColor,
}) => (
<Card
key={title}
variant={cardVariant}
// variant={cardVariant}
className="flex flex-col justify-between"
>
<CardHeader className="items-end-safe">
@@ -200,7 +211,79 @@ export default function Dashboard({
),
)}
</div>
{IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
<Card>
<CardHeader className="items-start">
<h4>
<Trans i18nKey="dashboard:recommendedForYou" />
</h4>
</CardHeader>
<CardContent className="space-y-6">
{dummyRecommendations.map(
(
{
icon,
color,
title,
description,
tooltipContent,
price,
buttonText,
href,
},
index,
) => {
return (
<div
className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div
className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
color,
)}
>
{icon}
</div>
<div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title}
<InfoTooltip content={tooltipContent} />
</div>
<p className="text-muted-foreground text-sm">
{description}
</p>
</div>
</div>
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4 min-w-fit">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
</Link>
) : (
<Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
</>
);
}

View File

@@ -31,8 +31,7 @@ export async function HomeMenuNavigation(props: {
})
: 0;
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0;
return (
@@ -40,18 +39,15 @@ export async function HomeMenuNavigation(props: {
<div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
<AppLogo href={pathsConfig.app.home} />
</div>
{/* TODO: add search functionality */}
{/* <Search
<Search
className="flex grow"
startElement={<Trans i18nKey="common:search" values={{ end: '...' }} />}
/> */}
/>
<div className="flex items-center justify-end gap-3">
{/* TODO: add wallet functionality
<Card className="px-6 py-2">
<span> {Number(0).toFixed(2).replace('.', ',')}</span>
</Card>
*/}
{hasCartItems && (
<Button
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
@@ -60,7 +56,7 @@ export async function HomeMenuNavigation(props: {
<span className="flex items-center text-nowrap">{totalValue}</span>
</Button>
)}
<Link href={pathsConfig.app.cart}>
<Link href="/home/cart">
<Button
variant="ghost"
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"

View File

@@ -4,13 +4,11 @@ import { useMemo } from 'react';
import Link from 'next/link';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { StoreCart } from '@medusajs/types';
import { Cross, Menu, Shield, ShoppingCart } from 'lucide-react';
import { Cross, LogOut, Menu, Shield, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import {
pathsConfig,
personalAccountNavigationConfig,
@@ -93,7 +91,7 @@ export function HomeMobileNavigation(props: {
<If condition={props.cart && hasCartItems}>
<DropdownMenuGroup>
<DropdownLink
path={pathsConfig.app.cart}
path="/home/cart"
label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartQuantityTotal }}
@@ -147,4 +145,49 @@ export function HomeMobileNavigation(props: {
);
}
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
labelOptions?: Record<string, any>;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild key={props.path}>
<Link
href={props.path}
className={'flex h-12 w-full items-center space-x-4'}
>
{props.Icon}
<span>
<Trans
i18nKey={props.label}
defaults={props.label}
values={props.labelOptions}
/>
</span>
</Link>
</DropdownMenuItem>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-4'}
onClick={props.onSignOut}
>
<LogOut className={'h-6'} />
<span>
<Trans i18nKey={'common:signOut'} defaults={'Sign out'} />
</span>
</DropdownMenuItem>
);
}

View File

@@ -94,7 +94,7 @@ export default function OrderAnalysesCards({
className="px-2 text-black"
onClick={() => handleSelect(variant.id)}
>
{variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
{variantAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { cache } from 'react';
import { requireUserInServerComponent } from '@/lib/server/require-user-in-server-component';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';

View File

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

View File

@@ -1,111 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Trans } from 'react-i18next';
import { AccountWithParams } from '@kit/accounts/api';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { Button } from '@kit/ui/button';
import { Card, CardDescription, CardTitle } from '@kit/ui/card';
import { Form } from '@kit/ui/form';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import {
AccountPreferences,
accountPreferencesSchema,
} from '../_lib/account-preferences.schema';
import { updatePersonalAccountPreferencesAction } from '../_lib/server/actions';
import { LanguageSelector } from '@kit/ui/language-selector';
export default function AccountPreferencesForm({
account,
}: {
account: AccountWithParams | null;
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const form = useForm({
resolver: zodResolver(accountPreferencesSchema),
defaultValues: {
preferredLanguage: account?.preferred_locale,
isConsentToAnonymizedCompanyStatistics:
!!account?.has_consent_anonymized_company_statistics,
},
});
const { register, handleSubmit, watch, setValue } = form;
const onSubmit = async (data: AccountPreferences) => {
if (!account?.id) {
return toast.error(<Trans i18nKey="account:updateAccountError" />);
}
const result = await updatePersonalAccountPreferencesAction({
accountId: account.id,
data,
});
if (result.success) {
revalidateUserDataQuery(account.primary_owner_user_id);
return toast.success(
<Trans i18nKey="account:updateAccountPreferencesSuccess" />,
);
}
return toast.error(
<Trans i18nKey="account:updateAccountPreferencesError" />,
);
};
const watchedConsent = watch('isConsentToAnonymizedCompanyStatistics');
if (!account) return null;
return (
<>
<div className="space-y-2">
<CardTitle className="text-base">
<Trans i18nKey={'account:language'} />
</CardTitle>
<LanguageSelector />
</div>
<Form {...form}>
<form
className="flex flex-col gap-6 text-left"
onSubmit={handleSubmit(onSubmit)}
>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
<Trans i18nKey="account:consents" />
</h2>
<Card>
<div className="flex items-center justify-between p-3">
<div>
<CardTitle className="text-base">
<Trans i18nKey="account:consentToAnonymizedCompanyData.label" />
</CardTitle>
<CardDescription>
<Trans i18nKey="account:consentToAnonymizedCompanyData.description" />
</CardDescription>
</div>
<Switch
checked={!!watchedConsent}
onCheckedChange={(checked) =>
setValue('isConsentToAnonymizedCompanyStatistics', checked)
}
{...register('isConsentToAnonymizedCompanyStatistics')}
/>
</div>
</Card>
</div>
<Button type="submit" className="w-36">
<Trans i18nKey="account:updateProfileSubmitLabel" />
</Button>
</form>
</Form>
</>
);
}

View File

@@ -1,259 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Trans } from 'react-i18next';
import { AccountWithParams } from '@kit/accounts/api';
import { useRevalidatePersonalAccountDataQuery } from '@kit/accounts/hooks/use-personal-account-data';
import { Button } from '@kit/ui/button';
import { Card, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import {
AccountSettings,
accountSettingsSchema,
} from '../_lib/account-settings.schema';
import { updatePersonalAccountAction } from '../_lib/server/actions';
export default function AccountSettingsForm({
account,
}: {
account: AccountWithParams | null;
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const form = useForm({
resolver: zodResolver(accountSettingsSchema),
defaultValues: {
firstName: account?.name,
lastName: account?.last_name ?? '',
email: account?.email,
phone: account?.phone ?? '',
accountParams: {
height: account?.accountParams?.height,
weight: account?.accountParams?.weight,
isSmoker: account?.accountParams?.isSmoker,
},
},
});
const { handleSubmit } = form;
const onSubmit = async (data: AccountSettings) => {
if (!account?.id) {
return toast.error(<Trans i18nKey="account:updateAccountError" />);
}
const result = await updatePersonalAccountAction({
accountId: account.id,
data,
});
if (result.success) {
revalidateUserDataQuery(account.primary_owner_user_id);
return toast.success(<Trans i18nKey="account:updateAccountSuccess" />);
}
return toast.error(<Trans i18nKey="account:updateAccountError" />);
};
if (!account) return null;
return (
<Form {...form}>
<form
className="flex flex-col gap-6 text-left"
onSubmit={handleSubmit(onSubmit)}
>
<FormField
name={'firstName'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.firstName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'lastName'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.lastName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
name={'accountParams.height'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.height'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'accountParams.weight'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.weight'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<FormField
name={'phone'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.phone'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'email'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.email'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
<Trans i18nKey="account:myHabits" />
</h2>
<FormField
name="accountParams.isSmoker"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField.smoking'} />
</FormLabel>
<FormControl>
<Select
value={
field.value === true
? 'yes'
: field.value === false
? 'no'
: 'preferNotToAnswer'
}
onValueChange={(value) => {
if (value === 'yes') {
field.onChange(true);
} else if (value === 'no') {
field.onChange(false);
} else {
field.onChange(null);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">
<Trans i18nKey="common:yes" />
</SelectItem>
<SelectItem value="no">
<Trans i18nKey="common:no" />
</SelectItem>
<SelectItem value="preferNotToAnswer">
<Trans i18nKey="common:preferNotToAnswer" />
</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-36">
<Trans i18nKey="account:updateProfileSubmitLabel" />
</Button>
</form>
</Form>
);
}

View File

@@ -1,152 +0,0 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { StoreCart } from '@medusajs/types';
import { Cross, Menu, Shield, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import { pathsConfig } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { UserWorkspace } from '../../_lib/server/load-user-workspace';
import { routes } from './settings-sidebar';
export function SettingsMobileNavigation(props: {
workspace: UserWorkspace;
cart: StoreCart | null;
}) {
const user = props.workspace.user;
const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id);
const Links = [
{
children: [{ path: pathsConfig.app.home, label: 'common:routes.home' }],
},
]
.concat(routes)
.map((item, index) => {
if ('children' in item) {
return item.children.map((child) => {
return (
<DropdownLink
key={child.path}
path={child.path}
label={child.label}
/>
);
});
}
if ('divider' in item) {
return <DropdownMenuSeparator key={index} />;
}
});
const hasTotpFactor = useMemo(() => {
const factors = user?.factors ?? [];
return factors.some(
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
);
}, [user?.factors]);
const isSuperAdmin = useMemo(() => {
const hasAdminRole =
personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
return hasAdminRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const isDoctor = useMemo(() => {
const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
const hasCartItems = cartQuantityTotal > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={props.cart && hasCartItems}>
<DropdownMenuGroup>
<DropdownLink
path={pathsConfig.app.cart}
label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartQuantityTotal }}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.admin}
>
<Shield className={'h-5'} />
<span>Super Admin</span>
</Link>
</DropdownMenuItem>
</If>
<If condition={isDoctor}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
'flex h-full cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.doctor}
>
<Cross className={'h-5'} />
<span>
<Trans i18nKey="common:doctor" />
</span>
</Link>
</DropdownMenuItem>
</If>
<DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,22 +0,0 @@
import { Separator } from "@kit/ui/separator";
import { Trans } from "@kit/ui/trans";
export default function SettingsSectionHeader({
titleKey,
descriptionKey,
}: {
titleKey: string;
descriptionKey: string;
}) {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-gray-900">
<Trans i18nKey={titleKey} />
</h1>
<p className="text-gray-600">
<Trans i18nKey={descriptionKey} />
</p>
<Separator />
</div>
);
}

View File

@@ -1,55 +0,0 @@
import z from 'zod';
import { pathsConfig } from '@kit/shared/config';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import { PageHeader } from '@kit/ui/page';
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarNavigation,
} from '@kit/ui/shadcn-sidebar';
import { Trans } from '@kit/ui/trans';
export const routes = [
{
children: [
{
label: 'common:routes.profile',
path: pathsConfig.app.personalAccountSettings,
end: true,
},
{
label: 'common:routes.preferences',
path: pathsConfig.app.personalAccountPreferences,
end: true,
},
{
label: 'common:routes.security',
path: pathsConfig.app.personalAccountSecurity,
end: true,
},
],
},
] satisfies z.infer<typeof NavigationConfigSchema>['routes'];
export function SettingsSidebar() {
return (
<Sidebar>
<SidebarHeader className="mt-16 h-24 w-[95vw] max-w-screen justify-center border-b bg-white pt-2">
<PageHeader
title={<Trans i18nKey="account:accountTabLabel" />}
description={<Trans i18nKey={'account:accountTabDescription'} />}
/>
</SidebarHeader>
<SidebarContent className="w-auto">
<SidebarNavigation
config={{ style: 'custom', sidebarCollapsedStyle: 'none', routes }}
/>
</SidebarContent>
</Sidebar>
);
}

View File

@@ -1,8 +0,0 @@
import { z } from 'zod';
export const accountPreferencesSchema = z.object({
preferredLanguage: z.enum(['et', 'en', 'ru']).optional().nullable(),
isConsentToAnonymizedCompanyStatistics: z.boolean().optional(),
});
export type AccountPreferences = z.infer<typeof accountPreferencesSchema>;

View File

@@ -1,21 +0,0 @@
import { z } from 'zod';
export const accountSettingsSchema = z.object({
firstName: z
.string()
.min(1, { error: 'error:tooShort' })
.max(200, { error: 'error:tooLong' }),
lastName: z
.string()
.min(1, { error: 'error:tooShort' })
.max(200, { error: 'error:tooLong' }),
email: z.email({ error: 'error:invalidEmail' }).nullable(),
phone: z.e164({ error: 'error:invalidPhone' }),
accountParams: z.object({
height: z.coerce.number({ error: 'error:invalidNumber' }),
weight: z.coerce.number({ error: 'error:invalidNumber' }),
isSmoker: z.boolean().optional().nullable(),
}),
});
export type AccountSettings = z.infer<typeof accountSettingsSchema>;

View File

@@ -1,66 +0,0 @@
'use server';
import z from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import {
updatePersonalAccount,
updatePersonalAccountPreferences,
} from '~/lib/services/account.service';
import {
AccountPreferences,
accountPreferencesSchema,
} from '../account-preferences.schema';
import {
AccountSettings,
accountSettingsSchema,
} from '../account-settings.schema';
export const updatePersonalAccountAction = enhanceAction(
async ({ accountId, data }: { accountId: string; data: AccountSettings }) => {
const logger = await getLogger();
try {
logger.info({ accountId }, 'Updating account');
await updatePersonalAccount(accountId, data);
logger.info({ accountId }, 'Successfully updated account');
return { success: true };
} catch (e) {
logger.error('Failed to update account', JSON.stringify(e));
return { success: false };
}
},
{
schema: z.object({ accountId: z.uuid(), data: accountSettingsSchema }),
},
);
export const updatePersonalAccountPreferencesAction = enhanceAction(
async ({
accountId,
data,
}: {
accountId: string;
data: AccountPreferences;
}) => {
const logger = await getLogger();
try {
logger.info({ accountId }, 'Updating account preferences');
await updatePersonalAccountPreferences(accountId, data);
logger.info({ accountId }, 'Successfully updated account preferences');
return { success: true };
} catch (e) {
logger.error('Failed to update account preferences', JSON.stringify(e));
return { success: false };
}
},
{
schema: z.object({ accountId: z.uuid(), data: accountPreferencesSchema }),
},
);

View File

@@ -1,69 +1,23 @@
import { use } from 'react';
import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo';
import { pathsConfig } from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { SettingsSidebar } from './_components/settings-sidebar';
// home imports
import { HomeMenuNavigation } from '../_components/home-menu-navigation';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { SettingsMobileNavigation } from './_components/settings-navigation';
function UserSettingsLayout({ children }: React.PropsWithChildren) {
return <HeaderLayout>{children}</HeaderLayout>;
}
// local imports
import { HomeLayoutPageHeader } from '../_components/home-page-header';
export default withI18n(UserSettingsLayout);
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart());
return (
<UserWorkspaceContextProvider value={workspace}>
<Page style={'header'}>
<PageNavigation>
<HomeMenuNavigation workspace={workspace} cart={cart} />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} cart={cart} />
</PageMobileNavigation>
<SidebarProvider defaultOpen>
<Page style={'sidebar'}>
<PageNavigation>
<SettingsSidebar />
</PageNavigation>
<div className="md:mt-28 min-w-full min-h-full">{children}</div>
</Page>
</SidebarProvider>
</Page>
</UserWorkspaceContextProvider>
);
}
function MobileNavigation({
workspace,
cart,
}: {
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
cart: StoreCart | null;
}) {
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<>
<AppLogo href={pathsConfig.app.home} />
<HomeLayoutPageHeader
title={<Trans i18nKey={'account:routes.settings'} />}
description={<AppBreadcrumbs />}
/>
<SettingsMobileNavigation workspace={workspace} cart={cart} />
{props.children}
</>
);
}
export default withI18n(UserSettingsLayout);

View File

@@ -1,11 +1,28 @@
import { use } from 'react';
import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings';
import {
authConfig,
featureFlagsConfig,
pathsConfig,
} from '@kit/shared/config';
import { PageBody } from '@kit/ui/page';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component';
import { withI18n } from '~/lib/i18n/with-i18n';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
import AccountSettingsForm from './_components/account-settings-form';
import SettingsSectionHeader from './_components/settings-section-header';
const features = {
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enablePasswordUpdate: authConfig.providers.password,
};
const callbackPath = pathsConfig.auth.callback;
const accountHomePath = pathsConfig.app.accountHome;
const paths = {
callback: callbackPath + `?next=${accountHomePath}`,
};
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
@@ -16,18 +33,17 @@ export const generateMetadata = async () => {
};
};
async function PersonalAccountSettingsPage() {
const account = await loadCurrentUserAccount();
function PersonalAccountSettingsPage() {
const user = use(requireUserInServerComponent());
return (
<PageBody>
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:accountTabLabel"
descriptionKey="account:accountTabDescription"
/>
<AccountSettingsForm account={account} />
</div>
<div className={'flex w-full flex-1 flex-col lg:max-w-2xl'}>
<PersonalAccountSettingsContainer
userId={user.id}
features={features}
paths={paths}
/>
</div>
</PageBody>
);

View File

@@ -1,24 +0,0 @@
import { CardTitle } from '@kit/ui/card';
import { LanguageSelector } from '@kit/ui/language-selector';
import { Trans } from '@kit/ui/trans';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
import AccountPreferencesForm from '../_components/account-preferences-form';
import SettingsSectionHeader from '../_components/settings-section-header';
export default async function PreferencesPage() {
const account = await loadCurrentUserAccount();
return (
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:preferencesTabLabel"
descriptionKey="account:preferencesTabDescription"
/>
<AccountPreferencesForm account={account} />
</div>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { MultiFactorAuthFactorsList } from '@kit/accounts/components';
import SettingsSectionHeader from '../_components/settings-section-header';
export default function SecuritySettingsPage() {
return (
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
<SettingsSectionHeader
titleKey="account:securityTabLabel"
descriptionKey="account:securityTabDescription"
/>
<MultiFactorAuthFactorsList />
</div>
</div>
);
}

View File

@@ -1,10 +1,9 @@
'use client';
import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { Home, Menu } from 'lucide-react';
import { Home, LogOut, Menu } from 'lucide-react';
import { AccountSelector } from '@kit/accounts/account-selector';
import {
@@ -93,7 +92,47 @@ export const TeamAccountLayoutMobileNavigation = (
);
};
function DropdownLink(
props: React.PropsWithChildren<{
path: string;
label: string;
Icon: React.ReactNode;
}>,
) {
return (
<DropdownMenuItem asChild>
<Link
href={props.path}
className={'flex h-12 w-full items-center gap-x-3 px-3'}
>
{props.Icon}
<span>
<Trans i18nKey={props.label} defaults={props.label} />
</span>
</Link>
</DropdownMenuItem>
);
}
function SignOutDropdownItem(
props: React.PropsWithChildren<{
onSignOut: () => unknown;
}>,
) {
return (
<DropdownMenuItem
className={'flex h-12 w-full items-center space-x-2'}
onClick={props.onSignOut}
>
<LogOut className={'h-4'} />
<span>
<Trans i18nKey={'common:signOut'} />
</span>
</DropdownMenuItem>
);
}
function TeamAccountsModal(props: {
accounts: Accounts;

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -41,7 +41,6 @@ export const defaultI18nNamespaces = [
'orders',
'analysis-results',
'doctor',
'error',
];
/**

View File

@@ -1,14 +1,8 @@
import type { Tables } from '@/packages/supabase/src/database.types';
import { AccountWithParams } from '@kit/accounts/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountSettings } from '~/home/(user)/settings/_lib/account-settings.schema';
import { AccountPreferences } from '../../app/home/(user)/settings/_lib/account-preferences.schema';
import { updateCustomer } from '../../packages/features/medusa-storefront/src/lib/data';
type Account = Tables<{ schema: 'medreport' }, 'accounts'>;
type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>;
@@ -67,9 +61,7 @@ export async function getDoctorAccounts() {
}
export async function getAssignedDoctorAccount(analysisOrderId: number) {
const supabase = getSupabaseServerAdminClient();
const { data: doctorUser } = await supabase
const { data: doctorUser } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('doctor_analysis_feedback')
.select('doctor_user_id')
@@ -81,7 +73,7 @@ export async function getAssignedDoctorAccount(analysisOrderId: number) {
return null;
}
const { data } = await supabase
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('email')
@@ -89,58 +81,3 @@ export async function getAssignedDoctorAccount(analysisOrderId: number) {
return { email: data?.[0]?.email };
}
export async function updatePersonalAccount(
accountId: string,
account: AccountSettings,
) {
const supabase = getSupabaseServerClient();
return Promise.all([
supabase
.schema('medreport')
.from('accounts')
.update({
name: account.firstName,
last_name: account.lastName,
email: account.email,
phone: account.phone,
})
.eq('id', accountId)
.throwOnError(),
supabase
.schema('medreport')
.from('account_params')
.upsert(
{
height: account.accountParams.height,
weight: account.accountParams.weight,
is_smoker: account.accountParams.isSmoker,
},
{ onConflict: 'account_id' },
)
.throwOnError(),
updateCustomer({
first_name: account.firstName,
last_name: account.lastName,
phone: account.phone,
}),
]);
}
export async function updatePersonalAccountPreferences(
accountId: string,
preferences: AccountPreferences,
) {
const supabase = getSupabaseServerClient();
return supabase
.schema('medreport')
.from('accounts')
.update({
preferred_locale: preferences.preferredLanguage,
has_consent_anonymized_company_statistics:
preferences.isConsentToAnonymizedCompanyStatistics,
})
.eq('id', accountId)
.throwOnError();
}

View File

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

View File

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

View File

@@ -16,12 +16,12 @@ const env = () =>
.object({
medusaBackendPublicUrl: z
.string({
error: 'MEDUSA_BACKEND_PUBLIC_URL is required',
required_error: 'MEDUSA_BACKEND_PUBLIC_URL is required',
})
.min(1),
siteUrl: z
.string({
error: 'NEXT_PUBLIC_SITE_URL is required',
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
})
@@ -110,7 +110,7 @@ export async function handleNavigateToPayment({
const paymentLink =
await new MontonioOrderHandlerService().getMontonioPaymentLink({
notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`,
returnUrl: `${"https://webhook.site"}/home/cart/montonio-callback`,
returnUrl: `${env().siteUrl}/home/cart/montonio-callback`,
amount: cart.total,
currency: cart.currency_code.toUpperCase(),
description: `Order from Medreport`,

View File

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

View File

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

View File

@@ -2,14 +2,15 @@ import { z } from 'zod';
export const companyOfferSchema = z.object({
companyName: z.string({
error: 'Company name is required',
required_error: 'Company name is required',
}),
contactPerson: z.string({
error: 'Contact person is required',
required_error: 'Contact person is required',
}),
email: z
.email({
error: 'Invalid email',
}),
.string({
required_error: 'Email is required',
})
.email('Invalid email'),
phone: z.string().optional(),
});

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -33,13 +33,13 @@
"@hookform/resolvers": "^5.1.1",
"@kit/accounts": "workspace:*",
"@kit/admin": "workspace:*",
"@kit/doctor": "workspace:*",
"@kit/analytics": "workspace:*",
"@kit/auth": "workspace:*",
"@kit/billing": "workspace:*",
"@kit/billing-gateway": "workspace:*",
"@kit/cms": "workspace:*",
"@kit/database-webhooks": "workspace:*",
"@kit/doctor": "workspace:*",
"@kit/email-templates": "workspace:*",
"@kit/i18n": "workspace:*",
"@kit/mailers": "workspace:*",
@@ -69,7 +69,6 @@
"fast-xml-parser": "^5.2.5",
"isikukood": "3.1.7",
"jsonwebtoken": "9.0.2",
"libphonenumber-js": "^1.12.15",
"lodash": "^4.17.21",
"lucide-react": "^0.510.0",
"next": "15.3.2",
@@ -83,7 +82,7 @@
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"ts-node": "^10.9.2",
"zod": "^4.1.5"
"zod": "^3.25.67"
},
"devDependencies": {
"@hookform/resolvers": "^5.0.1",

View File

@@ -21,25 +21,39 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
export const LineItemSchema = z
.object({
id: z
.string()
.describe('Unique identifier for the line item. Defined by the Provider.')
.string({
description:
'Unique identifier for the line item. Defined by the Provider.',
})
.min(1),
name: z
.string().describe('Name of the line item. Displayed to the user.')
.string({
description: 'Name of the line item. Displayed to the user.',
})
.min(1),
description: z
.string().describe('Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
' from the line item. This is useful if you want to provide a more detailed description to the user.')
.string({
description:
'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
' from the line item. This is useful if you want to provide a more detailed description to the user.',
})
.optional(),
cost: z
.number().describe('Cost of the line item. Displayed to the user.')
.number({
description: 'Cost of the line item. Displayed to the user.',
})
.min(0),
type: LineItemTypeSchema,
unit: z
.string().describe('Unit of the line item. Displayed to the user. Example "seat" or "GB"')
.string({
description:
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
})
.optional(),
setupFee: z
.number().describe(`Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`)
.number({
description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`,
})
.positive()
.optional(),
tiers: z
@@ -78,10 +92,14 @@ export const LineItemSchema = z
export const PlanSchema = z
.object({
id: z
.string().describe('Unique identifier for the plan. Defined by yourself.')
.string({
description: 'Unique identifier for the plan. Defined by yourself.',
})
.min(1),
name: z
.string().describe('Name of the plan. Displayed to the user.')
.string({
description: 'Name of the plan. Displayed to the user.',
})
.min(1),
interval: BillingIntervalSchema.optional(),
custom: z.boolean().default(false).optional(),
@@ -106,7 +124,10 @@ export const PlanSchema = z
},
),
trialDays: z
.number().describe('Number of days for the trial period. Leave empty for no trial.')
.number({
description:
'Number of days for the trial period. Leave empty for no trial.',
})
.positive()
.optional(),
paymentType: PaymentTypeSchema,
@@ -188,34 +209,54 @@ export const PlanSchema = z
const ProductSchema = z
.object({
id: z
.string().describe('Unique identifier for the product. Defined by th Provider.')
.string({
description:
'Unique identifier for the product. Defined by th Provider.',
})
.min(1),
name: z
.string().describe('Name of the product. Displayed to the user.')
.string({
description: 'Name of the product. Displayed to the user.',
})
.min(1),
description: z
.string().describe('Description of the product. Displayed to the user.')
.string({
description: 'Description of the product. Displayed to the user.',
})
.min(1),
currency: z
.string().describe('Currency code for the product. Displayed to the user.')
.string({
description: 'Currency code for the product. Displayed to the user.',
})
.min(3)
.max(3),
badge: z
.string().describe('Badge for the product. Displayed to the user. Example: "Popular"')
.string({
description:
'Badge for the product. Displayed to the user. Example: "Popular"',
})
.optional(),
features: z
.array(
z.string(),
).describe('Features of the product. Displayed to the user.')
z.string({
description: 'Features of the product. Displayed to the user.',
}),
)
.nonempty(),
enableDiscountField: z
.boolean().describe('Enable discount field for the product in the checkout.')
.boolean({
description: 'Enable discount field for the product in the checkout.',
})
.optional(),
highlighted: z
.boolean().describe('Highlight this product. Displayed to the user.')
.boolean({
description: 'Highlight this product. Displayed to the user.',
})
.optional(),
hidden: z
.boolean().describe('Hide this product from being displayed to users.')
.boolean({
description: 'Hide this product from being displayed to users.',
})
.optional(),
plans: z.array(PlanSchema),
})

View File

@@ -1,10 +1,14 @@
import { z } from 'zod';
export const ReportBillingUsageSchema = z.object({
id: z.string().describe('The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.'),
id: z.string({
description:
'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.',
}),
eventName: z
.string()
.describe('The name of the event that triggered the usage')
.string({
description: 'The name of the event that triggered the usage',
})
.optional(),
usage: z.object({
quantity: z.number(),

View File

@@ -3,8 +3,8 @@ import { z } from 'zod';
export const UpdateHealthBenefitSchema = z.object({
occurance: z
.string({
error: 'Occurance is required',
required_error: 'Occurance is required',
})
.nonempty(),
amount: z.number({ error: 'Amount is required' }),
amount: z.number({ required_error: 'Amount is required' }),
});

View File

@@ -4,12 +4,12 @@ export const MontonioServerEnvSchema = z
.object({
secretKey: z
.string({
error: `Please provide the variable MONTONIO_SECRET_KEY`,
required_error: `Please provide the variable MONTONIO_SECRET_KEY`,
})
.min(1),
apiUrl: z
.string({
error: `Please provide the variable MONTONIO_API_URL`,
required_error: `Please provide the variable MONTONIO_API_URL`,
})
.min(1),
});

View File

@@ -4,12 +4,12 @@ export const StripeServerEnvSchema = z
.object({
secretKey: z
.string({
error: `Please provide the variable STRIPE_SECRET_KEY`,
required_error: `Please provide the variable STRIPE_SECRET_KEY`,
})
.min(1),
webhooksSecret: z
.string({
error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
})
.min(1),
})

View File

@@ -4,9 +4,9 @@ import { DatabaseWebhookVerifierService } from './database-webhook-verifier.serv
const webhooksSecret = z
.string({
error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
description: `The secret used to verify the webhook signature`,
required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
})
.describe(`The secret used to verify the webhook signature`,)
.min(1)
.parse(process.env.SUPABASE_DB_WEBHOOK_SECRET);

View File

@@ -1,3 +1 @@
export * from './user-workspace-context';
export * from './personal-account-settings/mfa/multi-factor-auth-list'
export * from './personal-account-settings/mfa/multi-factor-auth-setup-dialog'

View File

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

View File

@@ -0,0 +1,208 @@
'use client';
import { useTranslation } from 'react-i18next';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { LanguageSelector } from '@kit/ui/language-selector';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { usePersonalAccountData } from '../../hooks/use-personal-account-data';
import { AccountDangerZone } from './account-danger-zone';
import ConsentToggle from './consent/consent-toggle';
import { UpdateEmailFormContainer } from './email/update-email-form-container';
import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list';
import { UpdatePasswordFormContainer } from './password/update-password-container';
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
import { UpdateAccountImageContainer } from './update-account-image-container';
export function PersonalAccountSettingsContainer(
props: React.PropsWithChildren<{
userId: string;
features: {
enableAccountDeletion: boolean;
enablePasswordUpdate: boolean;
};
paths: {
callback: string;
};
}>,
) {
const supportsLanguageSelection = useSupportMultiLanguage();
const user = usePersonalAccountData(props.userId);
if (!user.data || user.isPending) {
return <LoadingOverlay fullPage />;
}
return (
<div className={'flex w-full flex-col space-y-4 pb-32'}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:accountImage'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:accountImageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateAccountImageContainer
user={{
pictureUrl: user.data.picture_url,
id: user.data.id,
}}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:name'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:nameDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateAccountDetailsFormContainer user={user.data} />
</CardContent>
</Card>
<If condition={supportsLanguageSelection}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:language'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:languageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<LanguageSelector />
</CardContent>
</Card>
</If>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updateEmailCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updateEmailCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateEmailFormContainer callbackPath={props.paths.callback} />
</CardContent>
</Card>
<If condition={props.features.enablePasswordUpdate}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updatePasswordCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updatePasswordCardDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdatePasswordFormContainer callbackPath={props.paths.callback} />
</CardContent>
</Card>
</If>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:multiFactorAuth'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<MultiFactorAuthFactorsList userId={props.userId} />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex justify-between">
<div>
<p className="font-medium">
<Trans
i18nKey={'account:consentToAnonymizedCompanyData.label'}
/>
</p>
<CardDescription>
<Trans
i18nKey={'account:consentToAnonymizedCompanyData.description'}
/>
</CardDescription>
</div>
<ConsentToggle
userId={props.userId}
initialState={
!!user.data.has_consent_anonymized_company_statistics
}
/>
</div>
</CardHeader>
</Card>
<If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:dangerZoneDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<AccountDangerZone />
</CardContent>
</Card>
</If>
</div>
);
}
function useSupportMultiLanguage() {
const { i18n } = useTranslation();
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
return supportedLangs.length > 1;
}

View File

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { Switch } from '@kit/ui/switch';
import { Trans } from '@kit/ui/trans';
import { useRevalidatePersonalAccountDataQuery } from '../../../hooks/use-personal-account-data';
import { useUpdateAccountData } from '../../../hooks/use-update-account';
// This is temporary. When the profile views are ready, all account values included in the form will be updated together on form submit.
export default function ConsentToggle({
userId,
initialState,
}: {
userId: string;
initialState: boolean;
}) {
const [isConsent, setIsConsent] = useState(initialState);
const updateAccountMutation = useUpdateAccountData(userId);
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const updateConsent = (consent: boolean) => {
const promise = updateAccountMutation
.mutateAsync({
has_consent_anonymized_company_statistics: consent,
})
.then(() => {
revalidateUserDataQuery(userId);
});
return toast.promise(() => promise, {
success: <Trans i18nKey={'account:updateConsentSuccess'} />,
error: <Trans i18nKey={'account:updateConsentError'} />,
loading: <Trans i18nKey={'account:updateConsentLoading'} />,
});
};
return (
<Switch
checked={isConsent}
onCheckedChange={setIsConsent}
onClick={() => updateConsent(!isConsent)}
disabled={updateAccountMutation.isPending}
/>
);
}

View File

@@ -12,7 +12,6 @@ import { toast } from 'sonner';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useUser } from '@kit/supabase/hooks/use-user';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -47,18 +46,13 @@ import { Trans } from '@kit/ui/trans';
import { MultiFactorAuthSetupDialog } from './multi-factor-auth-setup-dialog';
export function MultiFactorAuthFactorsList() {
const { data: user } = useUser();
if (!user?.id) {
return null;
}
export function MultiFactorAuthFactorsList(props: { userId: string }) {
return (
<div className={'flex flex-col space-y-4'}>
<FactorsTableContainer userId={user?.id} />
<FactorsTableContainer userId={props.userId} />
<div>
<MultiFactorAuthSetupDialog userId={user?.id} />
<MultiFactorAuthSetupDialog userId={props.userId} />
</div>
</div>
);

View File

@@ -0,0 +1,42 @@
'use client';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert } from '@kit/ui/alert';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { UpdatePasswordForm } from './update-password-form';
export function UpdatePasswordFormContainer(
props: React.PropsWithChildren<{
callbackPath: string;
}>,
) {
const { data: user, isPending } = useUser();
if (isPending) {
return <LoadingOverlay fullPage={false} />;
}
if (!user) {
return null;
}
const canUpdatePassword = user.identities?.some(
(item) => item.provider === `email`,
);
if (!canUpdatePassword) {
return <WarnCannotUpdatePasswordAlert />;
}
return <UpdatePasswordForm callbackPath={props.callbackPath} user={user} />;
}
function WarnCannotUpdatePasswordAlert() {
return (
<Alert variant={'warning'}>
<Trans i18nKey={'account:cannotUpdatePassword'} />
</Alert>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Trans } from '@kit/ui/trans';
import { PasswordUpdateSchema } from '../../../schema/update-password.schema';
export const UpdatePasswordForm = ({
user,
callbackPath,
}: {
user: User;
callbackPath: string;
}) => {
const { t } = useTranslation('account');
const updateUserMutation = useUpdateUser();
const [needsReauthentication, setNeedsReauthentication] = useState(false);
const updatePasswordFromCredential = (password: string) => {
const redirectTo = [window.location.origin, callbackPath].join('');
const promise = updateUserMutation
.mutateAsync({ password, redirectTo })
.catch((error) => {
if (
typeof error === 'string' &&
error?.includes('Password update requires reauthentication')
) {
setNeedsReauthentication(true);
} else {
throw error;
}
});
toast.promise(() => promise, {
success: t(`updatePasswordSuccess`),
error: t(`updatePasswordError`),
loading: t(`updatePasswordLoading`),
});
};
const updatePasswordCallback = async ({
newPassword,
}: {
newPassword: string;
}) => {
const email = user.email;
// if the user does not have an email assigned, it's possible they
// don't have an email/password factor linked, and the UI is out of sync
if (!email) {
return Promise.reject(t(`cannotUpdatePassword`));
}
updatePasswordFromCredential(newPassword);
};
const form = useForm({
resolver: zodResolver(
PasswordUpdateSchema.withTranslation(t('passwordNotMatching')),
),
defaultValues: {
newPassword: '',
repeatPassword: '',
},
});
return (
<Form {...form}>
<form
data-test={'account-password-form'}
onSubmit={form.handleSubmit(updatePasswordCallback)}
>
<div className={'flex flex-col space-y-4'}>
<If condition={updateUserMutation.data}>
<SuccessAlert />
</If>
<If condition={needsReauthentication}>
<NeedsReauthenticationAlert />
</If>
<FormField
name={'newPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:newPassword'} />
</Label>
</FormLabel>
<FormControl>
<Input
data-test={'account-password-form-password-input'}
autoComplete={'new-password'}
required
type={'password'}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
name={'repeatPassword'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Label>
<Trans i18nKey={'account:repeatPassword'} />
</Label>
</FormLabel>
<FormControl>
<Input
data-test={'account-password-form-repeat-password-input'}
required
type={'password'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<div>
<Button disabled={updateUserMutation.isPending}>
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
</Button>
</div>
</div>
</form>
</Form>
);
};
function SuccessAlert() {
return (
<Alert variant={'success'}>
<Check className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
);
}
function NeedsReauthenticationAlert() {
return (
<Alert variant={'warning'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
import { UpdateAccountDetailsForm } from './update-account-details-form';
export function UpdateAccountDetailsFormContainer({
user,
}: {
user: {
name: string | null;
id: string;
};
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
return (
<UpdateAccountDetailsForm
displayName={user.name ?? ''}
userId={user.id}
onUpdate={() => revalidateUserDataQuery(user.id)}
/>
);
}

View File

@@ -0,0 +1,98 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { useUpdateAccountData } from '../../hooks/use-update-account';
import { AccountDetailsSchema } from '../../schema/account-details.schema';
type UpdateUserDataParams =
Database['medreport']['Tables']['accounts']['Update'];
export function UpdateAccountDetailsForm({
displayName,
onUpdate,
userId,
}: {
displayName: string;
userId: string;
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
}) {
const updateAccountMutation = useUpdateAccountData(userId);
const { t } = useTranslation('account');
const form = useForm({
resolver: zodResolver(AccountDetailsSchema),
defaultValues: {
displayName,
},
});
const onSubmit = ({ displayName }: { displayName: string }) => {
const data = { name: displayName };
const promise = updateAccountMutation.mutateAsync(data).then(() => {
onUpdate(data);
});
return toast.promise(() => promise, {
success: t(`updateProfileSuccess`),
error: t(`updateProfileError`),
loading: t(`updateProfileLoading`),
});
};
return (
<div className={'flex flex-col space-y-8'}>
<Form {...form}>
<form
data-test={'update-account-name-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name={'displayName'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:name'} />
</FormLabel>
<FormControl>
<Input
data-test={'account-display-name'}
minLength={2}
placeholder={''}
maxLength={100}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Database } from '@kit/supabase/database';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader';
import { Trans } from '@kit/ui/trans';
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';
const AVATARS_BUCKET = 'account_image';
export function UpdateAccountImageContainer({
user,
}: {
user: {
pictureUrl: string | null;
id: string;
};
}) {
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
return (
<UploadProfileAvatarForm
pictureUrl={user.pictureUrl ?? null}
userId={user.id}
onAvatarUpdated={() => revalidateUserDataQuery(user.id)}
/>
);
}
function UploadProfileAvatarForm(props: {
pictureUrl: string | null;
userId: string;
onAvatarUpdated: () => void;
}) {
const client = useSupabase();
const { t } = useTranslation('account');
const createToaster = useCallback(
(promise: () => Promise<unknown>) => {
return toast.promise(promise, {
success: t(`updateProfileSuccess`),
error: t(`updateProfileError`),
loading: t(`updateProfileLoading`),
});
},
[t],
);
const onValueChange = useCallback(
(file: File | null) => {
const removeExistingStorageFile = () => {
if (props.pictureUrl) {
return (
deleteProfilePhoto(client, props.pictureUrl) ?? Promise.resolve()
);
}
return Promise.resolve();
};
if (file) {
const promise = () =>
removeExistingStorageFile().then(() =>
uploadUserProfilePhoto(client, file, props.userId)
.then((pictureUrl) => {
return client
.schema('medreport')
.from('accounts')
.update({
picture_url: pictureUrl,
})
.eq('id', props.userId)
.throwOnError();
})
.then(() => {
props.onAvatarUpdated();
}),
);
createToaster(promise);
} else {
const promise = () =>
removeExistingStorageFile()
.then(() => {
return client
.schema('medreport')
.from('accounts')
.update({
picture_url: null,
})
.eq('id', props.userId)
.throwOnError();
})
.then(() => {
props.onAvatarUpdated();
});
createToaster(promise);
}
},
[client, createToaster, props],
);
return (
<ImageUploader value={props.pictureUrl} onValueChange={onValueChange}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm'}>
<Trans i18nKey={'account:profilePictureHeading'} />
</span>
<span className={'text-xs'}>
<Trans i18nKey={'account:profilePictureSubheading'} />
</span>
</div>
</ImageUploader>
);
}
function deleteProfilePhoto(client: SupabaseClient<Database>, url: string) {
const bucket = client.storage.from(AVATARS_BUCKET);
const fileName = url.split('/').pop()?.split('?')[0];
if (!fileName) {
return;
}
return bucket.remove([fileName]);
}
async function uploadUserProfilePhoto(
client: SupabaseClient<Database>,
photoFile: File,
userId: string,
) {
const bytes = await photoFile.arrayBuffer();
const bucket = client.storage.from(AVATARS_BUCKET);
const extension = photoFile.name.split('.').pop();
const fileName = await getAvatarFileName(userId, extension);
const result = await bucket.upload(fileName, bytes);
if (!result.error) {
return bucket.getPublicUrl(fileName).data.publicUrl;
}
throw result.error;
}
async function getAvatarFileName(
userId: string,
extension: string | undefined,
) {
const { nanoid } = await import('nanoid');
// we add a version to the URL to ensure
// the browser always fetches the latest image
const uniqueId = nanoid(16);
return `${userId}.${extension}?v=${uniqueId}`;
}

View File

@@ -2,19 +2,18 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { AnalysisResultDetails, UserAnalysis } from '../types/accounts';
import {
AnalysisResultDetails,
UserAnalysis,
} from '../types/accounts';
export type AccountWithParams =
Database['medreport']['Tables']['accounts']['Row'] & {
accountParams:
| (Pick<
account_params:
| Pick<
Database['medreport']['Tables']['account_params']['Row'],
'weight' | 'height'
> & {
isSmoker:
| Database['medreport']['Tables']['account_params']['Row']['is_smoker']
| null;
})
>[]
| null;
};
@@ -35,9 +34,7 @@ class AccountsApi {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select(
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
)
.select('*, account_params: account_params (weight, height)')
.eq('id', id)
.single();
@@ -48,41 +45,6 @@ class AccountsApi {
return data;
}
/**
* @name getPersonalAccountByUserId
* @description Get the personal account data for the given user ID.
* @param userId
*/
async getPersonalAccountByUserId(userId: string): Promise<AccountWithParams> {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select(
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
)
.eq('primary_owner_user_id', userId)
.eq('is_personal_account', true)
.single();
if (error) {
throw error;
}
const { personal_code, ...rest } = data;
return {
...rest,
personal_code: (() => {
if (!personal_code) {
return null;
}
if (personal_code.toLowerCase().startsWith('ee')) {
return personal_code.substring(2);
}
return personal_code;
})(),
};
}
/**
* @name getAccountWorkspace
* @description Get the account workspace data.

View File

@@ -32,8 +32,9 @@ const SPECIAL_CHARACTERS_REGEX = /[!@#$%^&*()+=[\]{};':"\\|,.<>/?]/;
* @name CompanyNameSchema
*/
export const CompanyNameSchema = z
.string()
.describe('The name of the company account')
.string({
description: 'The name of the company account',
})
.min(2)
.max(50)
.refine(

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,12 +88,10 @@ export const OauthProviders: React.FC<{
queryParams.set('invite_token', props.inviteToken);
}
// 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 redirectPath = [
props.paths.callback,
queryParams.toString(),
].join('?');
const redirectTo = [origin, redirectPath].join('');
const scopes = OAUTH_SCOPES[provider] ?? undefined;
@@ -104,7 +102,7 @@ export const OauthProviders: React.FC<{
redirectTo,
queryParams: props.queryParams,
scopes,
// skipBrowserRedirect: false,
skipBrowserRedirect: false,
},
} satisfies SignInWithOAuthCredentials;

View File

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

View File

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

View File

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

View File

@@ -410,7 +410,7 @@ export async function getAnalysisResultsForDoctor(
.from('accounts')
.select(
`primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale,
accountParams:account_params(height,weight)`,
account_params(height,weight)`,
)
.eq('is_personal_account', true)
.eq('primary_owner_user_id', userId)
@@ -472,7 +472,7 @@ export async function getAnalysisResultsForDoctor(
last_name,
personal_code,
phone,
accountParams,
account_params,
preferred_locale,
} = accountWithParams[0];
@@ -513,8 +513,8 @@ export async function getAnalysisResultsForDoctor(
personalCode: personal_code,
phone,
email,
height: accountParams?.height,
weight: accountParams?.weight,
height: account_params?.[0]?.height,
weight: account_params?.[0]?.weight,
},
};
}

View File

@@ -4,6 +4,7 @@ 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,
@@ -126,7 +127,7 @@ export async function login(_currentState: unknown, formData: FormData) {
}
}
export async function medusaLogout(countryCode = 'ee') {
export async function signout(countryCode: string) {
await sdk.auth.logout()
await removeAuthToken()
@@ -138,6 +139,8 @@ export async function medusaLogout(countryCode = 'ee') {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
redirect(`/${countryCode}/account`)
}
export async function transferCart() {
@@ -257,110 +260,62 @@ export const updateCustomerAddress = async (
})
}
async function medusaLogin(email: string, password: string) {
const token = await sdk.auth.login("customer", "emailpass", { email, password });
await setAuthToken(token as string);
try {
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()),
});
}
export async function medusaLoginOrRegister(credentials: {
email: string
supabaseUserId?: string
name?: string,
lastName?: string,
} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) {
const { email, supabaseUserId, name, lastName } = credentials;
const password = await (async () => {
if (credentials.isDevPasswordLogin) {
return credentials.password;
}
return generateDeterministicPassword(email, supabaseUserId);
})();
password?: string
}) {
const { email, password } = credentials;
try {
return await medusaLogin(email, password);
} catch (loginError) {
console.error("Failed to login customer, attempting to register", loginError);
const token = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(token as string);
await transferCart();
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
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 {
await medusaRegister({ email, password, name, lastName });
return await medusaLogin(email, password);
const registerToken = await sdk.auth.register("customer", "emailpass", {
email: email,
password: password,
})
await setAuthToken(registerToken as string);
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.customer.create({ email }, {}, headers);
const loginToken = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(loginToken as string);
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
await transferCart();
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
}
return customer.id;
} 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

@@ -68,7 +68,7 @@ export const listProducts = async ({
},
headers,
next,
//cache: "force-cache",
cache: "force-cache",
}
)
.then(({ products, count }) => {

View File

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

View File

@@ -17,22 +17,22 @@ const env = z
.object({
invitePath: z
.string({
error: 'The property invitePath is required',
required_error: 'The property invitePath is required',
})
.min(1),
siteURL: z
.string({
error: 'NEXT_PUBLIC_SITE_URL is required',
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
productName: z
.string({
error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
emailSender: z
.string({
error: 'EMAIL_SENDER is required',
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})

View File

@@ -78,7 +78,7 @@ class AccountWebhooksService {
productName: z.string(),
fromEmail: z
.string({
error: 'EMAIL_SENDER is required',
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})

View File

@@ -8,9 +8,9 @@ type Config = z.infer<typeof MailerSchema>;
const RESEND_API_KEY = z
.string({
error: 'Please provide the API key for the Resend API',
description: 'The API key for the Resend API',
required_error: 'Please provide the API key for the Resend API',
})
.describe('The API key for the Resend API')
.parse(process.env.RESEND_API_KEY);
export function createResendMailer() {

View File

@@ -4,19 +4,25 @@ import { z } from 'zod';
export const SmtpConfigSchema = z.object({
user: z.string({
error: `Please provide the variable EMAIL_USER`,
})
.describe('This is the email account to send emails from. This is specific to the email provider.'),
description:
'This is the email account to send emails from. This is specific to the email provider.',
required_error: `Please provide the variable EMAIL_USER`,
}),
pass: z.string({
error: `Please provide the variable EMAIL_PASSWORD`,
}).describe('This is the password for the email account'),
description: 'This is the password for the email account',
required_error: `Please provide the variable EMAIL_PASSWORD`,
}),
host: z.string({
error: `Please provide the variable EMAIL_HOST`,
}).describe('This is the SMTP host for the email provider'),
description: 'This is the SMTP host for the email provider',
required_error: `Please provide the variable EMAIL_HOST`,
}),
port: z.number({
error: `Please provide the variable EMAIL_PORT`,
}).describe('This is the port for the email provider. Normally 587 or 465.'),
description:
'This is the port for the email provider. Normally 587 or 465.',
required_error: `Please provide the variable EMAIL_PORT`,
}),
secure: z.boolean({
error: `Please provide the variable EMAIL_TLS`,
}).describe('This is whether the connection is secure or not'),
description: 'This is whether the connection is secure or not',
required_error: `Please provide the variable EMAIL_TLS`,
}),
});

View File

@@ -4,9 +4,9 @@ import { MonitoringService } from '@kit/monitoring-core';
const apiKey = z
.string({
error: 'NEXT_PUBLIC_BASELIME_KEY is required',
required_error: 'NEXT_PUBLIC_BASELIME_KEY is required',
description: 'The Baseline API key',
})
.describe('The Baseline API key')
.parse(process.env.NEXT_PUBLIC_BASELIME_KEY);
export class BaselimeServerMonitoringService implements MonitoringService {

Some files were not shown because too many files have changed in this diff Show More