60 Commits

Author SHA1 Message Date
ab92c3b4d0 test3 2025-09-08 00:47:04 +03:00
e324872c3c test2 2025-09-08 00:46:45 +03:00
a44f9c9207 test 2025-09-08 00:45:43 +03:00
0cf04b4f55 wip 2025-09-05 01:39:06 +03:00
84216c3ced test 2025-09-04 13:40:21 +03:00
0037241558 allow transferCart to fail on register 2025-09-04 13:36:46 +03:00
c7f89723e3 allow transferCart to fail on login/register 2025-09-04 13:19:09 +03:00
4a06059a25 medusa product can have either analysiselement or analysis originalId 2025-09-04 13:18:42 +03:00
a0abb44257 hide dashboard recommendations block 2025-09-04 12:57:18 +03:00
283b502963 fix tooltip should wrap long text 2025-09-04 12:41:18 +03:00
5c8f8b73d7 try to display price before adding to cart 2025-09-04 12:39:24 +03:00
5b52da0a62 fix adding to cart loading 2025-09-04 12:39:21 +03:00
Helena
21375cf55f MED-149: add more estonian translations (#72) 2025-09-04 12:38:46 +03:00
Helena
9122acc89f MED-151: add profile view and working smoking dashboard card (#71)
* MED-151: add profile view and working smoking dashboard card

* update zod

* move some components to shared

* move some components to shared

* remove console.logs

* remove unused password form components

* only check null for variant

* use pathsconfig
2025-09-04 12:17:54 +03:00
Danel Kungla
152ec5f36b fix query response cant be undefined 2025-09-04 10:59:34 +03:00
Danel Kungla
5176ecdddc hide search and wallet 2025-09-04 10:56:24 +03:00
40ffbf29a5 improve analyses sync error logging 2025-09-03 13:31:43 +03:00
b046b6ab16 use recipient from env as it's different in live 2025-09-03 13:31:41 +03:00
Danel Kungla
8cb9d7552c add Supabase configuration to staging environment and update Dockerfile to include .env.staging 2025-09-03 12:46:39 +03:00
Danel Kungla
cdb638f046 remove Supabase configuration from production environment variables 2025-09-03 10:04:56 +03:00
Danel Kungla
a587b222b9 updated supabase parameters for test 2025-09-02 17:52:47 +03:00
Danel Kungla
612673ddf9 add redirect for authenticated users to home page in middleware 2025-09-02 16:25:27 +03:00
Danel Kungla
3230dd7608 updated medusa publishable key env 2025-09-02 14:01:19 +03:00
Danel Kungla
e4d7c874cc updated medusa publishable key env 2025-09-02 13:13:21 +03:00
Helena
b7926f79a9 MED-89: add analysis view with doctor summary (#68)
* add analysis view with doctor summary

* remove console.log, also return null if analysis data missing

* replace orders table eye with button
2025-09-02 12:18:18 +03:00
Helena
9d62a2d86f MED-140: ui fixes (#69)
* MED-140: ui fixes

* make accountid optional in hook
2025-09-02 12:14:24 +03:00
Helena
3498406a0c MED-88: add doctor email notifications (#65)
* MED-88: add doctor email notifications

* add logging, send open jobs notification on partial analysis response

* update permissions

* fix import, permissions

* casing, let email be null

* unused import
2025-09-02 12:14:01 +03:00
Helena
56a832b96b MED-75: add russian translations (#70) 2025-09-02 11:53:30 +03:00
Danel Kungla
568104eaff updated test envs in aws 2025-09-01 23:25:24 +03:00
Danel Kungla
2eac3a6836 updated test envs in aws 2025-09-01 18:07:54 +03:00
2df366c14a feat(MED-121): fix variant age range check 2025-09-01 14:29:14 +03:00
Danel Kungla
6ce60eacc7 Add data.sql to .gitignore 2025-08-29 18:05:51 +03:00
danelkungla
6e76e75e85 Merge pull request #66 from MR-medreport/main
Main
2025-08-29 16:17:54 +03:00
71c3e2ef1e Merge pull request #64 from MR-medreport/MED-85
feat(MED-85-105-123): some testing feedback, other improvements
2025-08-29 11:52:55 +03:00
d83319a094 Merge branch 'main' into MED-85 2025-08-29 11:45:36 +03:00
Helena
505ef0d91b Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:28:15 +03:00
Helena
fce4355be8 Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:27:46 +03:00
Helena
da9658ad7a fix email button 2025-08-29 10:27:12 +03:00
danelkungla
e023d54a2a Merge pull request #63 from MR-medreport/main
update develop
2025-08-29 09:50:32 +03:00
danelkungla
bdaacbe78a MED-104: booking page
MED-104
2025-08-29 09:48:26 +03:00
Danel Kungla
5479f310d7 Merge branch 'main' into MED-104 2025-08-29 09:46:04 +03:00
815b877b5b Merge branch 'main' into MED-85 2025-08-28 16:12:46 +03:00
0c28f9681b feat(MED-86): fix status check for fake responses 2025-08-28 15:49:03 +03:00
70b85dc967 feat(MED-86): don't prettify results sync log since aws will split it up 2025-08-28 15:37:29 +03:00
71f5a25632 Merge branch 'main' into MED-85 2025-08-28 14:57:09 +03:00
d072226a5c feat(MED-105): organize env specific required migrations 2025-08-28 14:54:51 +03:00
da7f574234 feat(MED-86): user can see in Medusa BO if sending order to medipost succeeded 2025-08-28 14:42:07 +03:00
b3505c1627 feat(MED-100): show cart line item quantities total instead of items total count 2025-08-28 14:41:59 +03:00
Danel Kungla
ad28352fc8 MED-104: create booking view with categories 2025-08-28 14:11:54 +03:00
b931035c3b feat(MED-86): add db fn to show medipost dispatch error for order in Medusa 2025-08-28 13:36:07 +03:00
f723633646 feat(MED-123): don't redirect to cart on single analysis select 2025-08-28 13:31:29 +03:00
6e6ad13b52 feat(MED-123): show toast on adding analysis package to cart 2025-08-28 13:30:25 +03:00
Danel Kungla
31bc4b6cff initial commit 2025-08-28 13:15:39 +03:00
3ddc0a2716 feat(MED-123): show toast on adding analysis to cart 2025-08-28 12:44:07 +03:00
49eeaa1876 feat(MED-123): update translations 2025-08-28 12:40:33 +03:00
2ffad84100 feat(MED-105): log opening analysis results from orders view 2025-08-28 12:35:30 +03:00
b4985afdf0 feat(MED-85): add logging for medipost response error xml 2025-08-28 12:25:18 +03:00
a37c4cad9c feat(MED-85): add logging for medipost actions with xml and related order id 2025-08-28 11:56:07 +03:00
47ab39172e feat(MED-85): run sending fake medipost results in dev from Medusa BO 2025-08-28 10:30:43 +03:00
d760f86632 feat(MED-85): run force medipost results sync from Medusa BO 2025-08-28 09:54:32 +03:00
250 changed files with 60366 additions and 5383 deletions

2
.dockerignore Normal file
View File

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

5
.env
View File

@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a" NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# AUTH # AUTH
NEXT_PUBLIC_AUTH_PASSWORD=true NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY= NEXT_PUBLIC_CAPTCHA_SITE_KEY=
@@ -65,3 +65,6 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
# Configure Medusa password secret for Keycloak users
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==

View File

@@ -3,6 +3,7 @@
# SITE # SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_PASSWORD=true
# SUPABASE DEVELOPMENT # SUPABASE DEVELOPMENT
@@ -25,14 +26,65 @@ EMAIL_PORT=1025 # or 465 for SSL
EMAIL_TLS=false EMAIL_TLS=false
NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_TLS_REJECT_UNAUTHORIZED=0
# MEDIPOST
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
MEDIPOST_USER=trvurgtst
MEDIPOST_PASSWORD=SRB48HZMV
MEDIPOST_RECIPIENT=trvurgtst
MEDIPOST_MESSAGE_SENDER=trvurgtst
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
#MEDIPOST_USER=medreport
#MEDIPOST_PASSWORD=85MXFFDB7
#MEDIPOST_RECIPIENT=HTI
#MEDIPOST_MESSAGE_SENDER=medreport
# MEDUSA # MEDUSA
MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_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 # MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com 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
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197 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
### TEST.MEDREPORT.ee ###
DB_PASSWORD=T#u-$M7%RjbA@L@

15
.env.staging Normal file
View File

@@ -0,0 +1,15 @@
# PRODUCTION ENVIRONMENT VARIABLES
## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC OR NOT SENSITIVE
## THIS ENV IS USED FOR PRODUCTION AND IS COMMITED TO THE REPO
## AVOID PLACING SENSITIVE DATA IN THIS FILE.
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://kaldvociniytdbbcxvqk.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImthbGR2b2Npbml5dGRiYmN4dnFrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYzNjQ5OTYsImV4cCI6MjA3MTk0MDk5Nn0.eixihH2KGkJZolY9FiQDicJOo2kxvXrSe6gGUCrkLo0
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

1
.gitignore vendored
View File

@@ -43,3 +43,4 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
dump.sql dump.sql
data.sql

View File

@@ -11,6 +11,7 @@ COPY packages packages
COPY tooling tooling COPY tooling tooling
COPY .env .env COPY .env .env
COPY .env.production .env.production COPY .env.production .env.production
COPY .env.staging .env.staging
# Load env file and echo a specific variable # Load env file and echo a specific variable
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true # RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
@@ -20,13 +21,10 @@ COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug
RUN echo "📄 .env.production contents:" && cat .env.production \
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
RUN set -a \ RUN set -a \
&& . .env \ && . .env \
&& . .env.production \ && . .env.production \
&& . .env.staging \
&& set +a \ && set +a \
&& node check-env.js \ && node check-env.js \
&& pnpm build && pnpm build
@@ -34,18 +32,21 @@ RUN set -a \
# --- Stage 2: Runtime --- # --- Stage 2: Runtime ---
FROM node:20-alpine FROM node:20-alpine
ARG APP_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /app ./ COPY --from=builder /app ./
RUN cp ".env.${APP_ENV}" .env.local
RUN npm install -g pnpm@9 \ RUN npm install -g pnpm@9 \
&& pnpm install --prod --frozen-lockfile && pnpm install --prod --frozen-lockfile
ENV NODE_ENV=production ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug # 🔍 Optional: Log key envs for debug
RUN echo "📄 .env.production contents:" && cat .env.production \ RUN echo "📄 .env contents:" && cat .env.local \
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true && echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,15 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { SubmitButton } from '@kit/shared/components/ui/submit-button'; import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
import { CompanySubmitData } from '@/lib/types/company'; import { CompanySubmitData } from '@/lib/types/company';
import { companyOfferSchema } from '@/lib/validations/company-offer.schema'; import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { renderCompanyOfferEmail } from '@kit/email-templates';
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
import { FormItem } from '@kit/ui/form'; import { FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
@@ -39,7 +40,14 @@ const CompanyOfferForm = () => {
}); });
try { try {
sendCompanyOfferEmail(data, language) sendEmailFromTemplate(
renderCompanyOfferEmail,
{
companyData: data,
language,
},
process.env.CONTACT_EMAIL!,
)
.then(() => router.push('/company-offer/success')) .then(() => router.push('/company-offer/success'))
.catch((error) => { .catch((error) => {
setIsLoading(false); setIsLoading(false);

View File

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

View File

@@ -0,0 +1,23 @@
import { renderNewJobsAvailableEmail } from '@kit/email-templates';
import { getDoctorAccounts } from '~/lib/services/account.service';
import { getOpenJobAnalysisResponseIds } from '~/lib/services/doctor-jobs.service';
import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
export default async function sendOpenJobsEmails() {
const analysisResponseIds = await getOpenJobAnalysisResponseIds();
const doctorAccounts = await getDoctorAccounts();
const doctorEmails: string[] = doctorAccounts
.map(({ email }) => email)
.filter((email): email is string => !!email);
await sendEmailFromTemplate(
renderNewJobsAvailableEmail,
{
language: 'et',
analysisResponseIds,
},
doctorEmails,
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import {
NotificationAction,
createNotificationLog,
} from '~/lib/services/audit/notificationEntries.service';
import loadEnv from '../handler/load-env';
import sendOpenJobsEmails from '../handler/send-open-jobs-emails';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await sendOpenJobsEmails();
console.info(
'Successfully sent out open job notification emails to doctors.',
);
await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT,
status: 'SUCCESS',
});
return NextResponse.json(
{
message:
'Successfully sent out open job notification emails to doctors.',
},
{ status: 200 },
);
} catch (e: any) {
console.error(
'Error sending out open job notification emails to doctors.',
e,
);
await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT,
status: 'FAIL',
comment: e?.message,
});
return NextResponse.json(
{
message: 'Failed to send out open job notification emails to doctors.',
},
{ status: 500 },
);
}
};

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,62 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth'; import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config'; 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) { 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 service = createAuthCallbackService(getSupabaseServerClient());
const oauthResult = await service.exchangeCodeForSession(authCode);
if (!("isSuccess" in oauthResult)) {
return redirectOnError(oauthResult.searchParams);
}
const { nextPath } = await service.exchangeCodeForSession(request, { const api = createAccountsApi(getSupabaseServerClient());
joinTeamPath: pathsConfig.app.joinTeam,
redirectPath: pathsConfig.app.home,
});
return redirect(nextPath); const account = await api.getPersonalAccountByUserId(
oauthResult.user.id,
);
if (!account.email || !account.name || !account.last_name) {
return redirect(pathsConfig.auth.updateAccount);
}
return redirect(redirectPath);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,18 +11,39 @@ import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { UpdateAccountForm } from './_components/update-account-form'; 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() { async function UpdateAccount() {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const account = await loadCurrentUserAccount();
const { const {
data: { user }, data: { user },
} = await client.auth.getUser(); } = await client.auth.getUser();
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
if (!user) { if (!user) {
redirect(pathsConfig.auth.signIn); 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 ( return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border"> <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"> <div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
@@ -34,7 +55,7 @@ async function UpdateAccount() {
<p className="text-muted-foreground pt-1 text-sm"> <p className="text-muted-foreground pt-1 text-sm">
<Trans i18nKey={'account:updateAccount:description'} /> <Trans i18nKey={'account:updateAccount:description'} />
</p> </p>
<UpdateAccountForm user={user} /> <UpdateAccountForm defaultValues={defaultValues} />
</div> </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 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> </div>

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { capitalize } from 'lodash';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions'; import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
@@ -22,6 +23,9 @@ import {
doctorAnalysisFeedbackFormSchema, doctorAnalysisFeedbackFormSchema,
} from '@kit/doctor/schema/doctor-analysis.schema'; } from '@kit/doctor/schema/doctor-analysis.schema';
import ConfirmationModal from '@kit/shared/components/confirmation-modal'; import ConfirmationModal from '@kit/shared/components/confirmation-modal';
import {
useCurrentLocaleLanguageNames
} from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -57,6 +61,8 @@ export default function AnalysisView({
const { data: user } = useUser(); const { data: user } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const isInProgress = !!( const isInProgress = !!(
!!feedback?.status && !!feedback?.status &&
feedback?.doctor_user_id && feedback?.doctor_user_id &&
@@ -191,6 +197,12 @@ export default function AnalysisView({
<Trans i18nKey="doctor:email" /> <Trans i18nKey="doctor:email" />
</div> </div>
<div>{patient.email}</div> <div>{patient.email}</div>
<div className="font-bold">
<Trans i18nKey="common:language" />
</div>
<div>
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
</div>
</div> </div>
<div className="xs:hidden block"> <div className="xs:hidden block">
<DoctorJobSelect <DoctorJobSelect

View File

@@ -10,6 +10,7 @@ import { Eye } from 'lucide-react';
import { getResultSetName } from '@kit/doctor/lib/helpers'; import { getResultSetName } from '@kit/doctor/lib/helpers';
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema'; import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -23,7 +24,9 @@ import {
TableRow, TableRow,
} from '@kit/ui/table'; } from '@kit/ui/table';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import DoctorJobSelect from './doctor-job-select'; import DoctorJobSelect from './doctor-job-select';
import { capitalize } from 'lodash';
export default function ResultsTable({ export default function ResultsTable({
results = [], results = [],
@@ -58,6 +61,8 @@ export default function ResultsTable({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const fetchPage = async (page: number) => { const fetchPage = async (page: number) => {
startTransition(async () => { startTransition(async () => {
const result = await fetchAction({ const result = await fetchAction({
@@ -116,6 +121,9 @@ export default function ResultsTable({
<TableHead className="w-20"> <TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.resultsStatus" /> <Trans i18nKey="doctor:resultsTable.resultsStatus" />
</TableHead> </TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.language" />
</TableHead>
<TableHead className="w-20"> <TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.assignedTo" /> <Trans i18nKey="doctor:resultsTable.assignedTo" />
</TableHead> </TableHead>
@@ -179,6 +187,11 @@ export default function ResultsTable({
}} }}
/> />
</TableCell> </TableCell>
<TableCell>
{capitalize(
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
)}
</TableCell>
<TableCell> <TableCell>
<DoctorJobSelect <DoctorJobSelect
doctorUserId={result.doctor?.primary_owner_user_id} doctorUserId={result.doctor?.primary_owner_user_id}

View File

@@ -18,8 +18,8 @@ async function AnalysisPage({
id: string; id: string;
}>; }>;
}) { }) {
const { id: analysisResponseId } = await params; const { id: analysisOrderId } = await params;
const analysisResultDetails = await loadResult(Number(analysisResponseId)); const analysisResultDetails = await loadResult(Number(analysisOrderId));
if (!analysisResultDetails) { if (!analysisResultDetails) {
return null; return null;
@@ -28,7 +28,7 @@ async function AnalysisPage({
if (analysisResultDetails) { if (analysisResultDetails) {
await createDoctorPageViewLog({ await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS, action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
recordKey: analysisResponseId, recordKey: analysisOrderId,
dataOwnerUserId: analysisResultDetails.patient.userId, dataOwnerUserId: analysisResultDetails.patient.userId,
}); });
} }
@@ -50,3 +50,5 @@ async function AnalysisPage({
export default DoctorGuard(AnalysisPage); export default DoctorGuard(AnalysisPage);
const loadResult = cache(getAnalysisResultsForDoctor); const loadResult = cache(getAnalysisResultsForDoctor);

View File

@@ -0,0 +1,107 @@
import Link from 'next/link';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import Analysis from '../_components/analysis';
export default async function AnalysisResultsPage({
params,
}: {
params: Promise<{
id: string;
}>;
}) {
const account = await loadCurrentUserAccount();
const { id: analysisResponseId } = await params;
const analysisResponse = await loadUserAnalysis(Number(analysisResponseId));
if (!account?.id || !analysisResponse) {
return null;
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
});
return (
<>
<PageHeader />
<PageBody className="gap-4">
<div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
<div>
<h4>
<Trans i18nKey="analysis-results:pageTitle" />
</h4>
<p className="text-muted-foreground text-sm">
{analysisResponse?.elements &&
analysisResponse.elements?.length > 0 ? (
<Trans i18nKey="analysis-results:description" />
) : (
<Trans i18nKey="analysis-results:descriptionEmpty" />
)}
</p>
</div>
<Button asChild>
<Link href={pathsConfig.app.orderAnalysisPackage}>
<Trans i18nKey="analysis-results:orderNewAnalysis" />
</Link>
</Button>
</div>
<div className="flex flex-col gap-4">
<h4>
<Trans
i18nKey="analysis-results:orderTitle"
values={{ orderNumber: analysisResponse.order.medusa_order_id }}
/>
</h4>
<h5>
<Trans
i18nKey={`orders:status.${analysisResponse.order.status}`}
/>
<ButtonTooltip
content={`${analysisResponse.order.created_at ? new Date(analysisResponse?.order?.created_at).toLocaleString() : ''}`}
className="ml-6"
/>
</h5>
</div>
{analysisResponse?.summary?.value && (
<div>
<strong>
<Trans i18nKey="account:doctorAnalysisSummary" />
</strong>
<p>{analysisResponse.summary.value}</p>
</div>
)}
<div className="flex flex-col gap-2">
{analysisResponse.elements ? (
analysisResponse.elements.map((element, index) => (
<Analysis
key={index}
analysisElement={{ analysis_name_lab: element.analysis_name }}
results={element}
/>
))
) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" />
</div>
)}
</div>
</PageBody>
</>
);
}

View File

@@ -1,131 +0,0 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '@/lib/i18n/with-i18n';
import { Trans } from '@kit/ui/makerkit/trans';
import { PageBody } from '@kit/ui/page';
import { Button } from '@kit/ui/shadcn/button';
import { pathsConfig } from '@kit/shared/config';
import { getAnalysisElements } from '~/lib/services/analysis-element.service';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import { AnalysisOrder, getAnalysisOrders } from '~/lib/services/order.service';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { loadUserAnalysis } from '../../_lib/server/load-user-analysis';
import Analysis from './_components/analysis';
export const generateMetadata = async () => {
const i18n = await createI18nServerInstance();
const title = i18n.t('analysis-results:pageTitle');
return {
title,
};
};
async function AnalysisResultsPage() {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
const analysisResponses = await loadUserAnalysis();
const analysisResponseElements = analysisResponses?.flatMap(
({ elements }) => elements,
);
const analysisOrders = await getAnalysisOrders().catch(() => null);
if (!analysisOrders) {
redirect(pathsConfig.auth.signIn);
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
});
const getAnalysisElementIds = (analysisOrders: AnalysisOrder[]) => [
...new Set(analysisOrders?.flatMap((order) => order.analysis_element_ids).filter(Boolean) as number[]),
];
const analysisElementIds = getAnalysisElementIds(analysisOrders);
const analysisElements = await getAnalysisElements({ ids: analysisElementIds });
return (
<PageBody className="gap-4">
<div className="mt-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0">
<div>
<h4>
<Trans i18nKey="analysis-results:pageTitle" />
</h4>
<p className="text-muted-foreground text-sm">
{analysisResponses && analysisResponses.length > 0 ? (
<Trans i18nKey="analysis-results:description" />
) : (
<Trans i18nKey="analysis-results:descriptionEmpty" />
)}
</p>
</div>
<Button asChild>
<Link href={pathsConfig.app.orderAnalysisPackage}>
<Trans i18nKey="analysis-results:orderNewAnalysis" />
</Link>
</Button>
</div>
<div className="flex flex-col gap-8">
{analysisOrders.length > 0 && analysisElements.length > 0 ? analysisOrders.map((analysisOrder) => {
const analysisResponse = analysisResponses?.find((response) => response.analysis_order_id === analysisOrder.id);
const analysisElementIds = getAnalysisElementIds([analysisOrder]);
const analysisElementsForOrder = analysisElements.filter((element) => analysisElementIds.includes(element.id));
return (
<div key={analysisOrder.id} className="flex flex-col gap-4">
<h4>
<Trans i18nKey="analysis-results:orderTitle" values={{ orderNumber: analysisOrder.medusa_order_id }} />
</h4>
<h5>
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
<ButtonTooltip
content={`${new Date(analysisOrder.created_at).toLocaleString()}`}
className="ml-6"
/>
</h5>
<div className="flex flex-col gap-2">
{analysisElementsForOrder.length > 0 ? analysisElementsForOrder.map((analysisElement) => {
const results = analysisResponse?.elements.some((element) => element.analysis_element_original_id === analysisElement.analysis_id_original)
&& analysisResponseElements?.find((element) => element.analysis_element_original_id === analysisElement.analysis_id_original);
if (!results) {
return (
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} isCancelled={analysisOrder.status === 'CANCELLED'}/>
);
}
return (
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} results={results} />
);
}) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" />
</div>
)}
</div>
</div>
);
}) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisOrders" />
</div>
)}
</div>
</PageBody>
);
}
export default withI18n(AnalysisResultsPage);

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,11 @@
import { use } from 'react'; import { use } from 'react';
import { cookies } from 'next/headers';
import { retrieveCart } from '@lib/data/cart'; import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types'; import { StoreCart } from '@medusajs/types';
import { z } from 'zod';
import { UserWorkspaceContextProvider } from '@kit/accounts/components'; import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
import { import { pathsConfig } from '@kit/shared/config';
pathsConfig,
personalAccountNavigationConfig,
} from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
@@ -24,40 +18,11 @@ import { HomeSidebar } from '../_components/home-sidebar';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace'; import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
function UserHomeLayout({ children }: React.PropsWithChildren) { function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
if (state.style === 'sidebar') {
return <SidebarLayout>{children}</SidebarLayout>;
}
return <HeaderLayout>{children}</HeaderLayout>; return <HeaderLayout>{children}</HeaderLayout>;
} }
export default withI18n(UserHomeLayout); 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) { function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace()); const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart()); const cart = use(retrieveCart());
@@ -101,27 +66,3 @@ 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 client = getSupabaseServerClient();
const account = await loadCurrentUserAccount(); const account = await loadCurrentUserAccount();
const api = await createAccountsApi(client); const api = createAccountsApi(client);
const bmiThresholds = await api.fetchBmiThresholds(); const bmiThresholds = await api.fetchBmiThresholds();
if (!account) { if (!account) {

View File

@@ -17,6 +17,7 @@ import { formatCurrency } from "@/packages/shared/src/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service"; import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
import AnalysisLocation from "./analysis-location"; import AnalysisLocation from "./analysis-location";
import { composeOrderXML, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
const IS_DISCOUNT_SHOWN = false as boolean; const IS_DISCOUNT_SHOWN = false as boolean;
@@ -133,6 +134,26 @@ export default function Cart({
<Trans i18nKey="cart:checkout.goToCheckout" /> <Trans i18nKey="cart:checkout.goToCheckout" />
</Button> </Button>
</div> </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> </div>
); );
} }

View File

@@ -1,30 +1,28 @@
import { Trans } from '@kit/ui/trans';
import {
Card,
CardHeader,
CardDescription,
CardFooter,
} from '@kit/ui/card';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@kit/ui/button';
import { ChevronRight, HeartPulse } from 'lucide-react'; import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardDescription, CardFooter, CardHeader } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
export default function DashboardCards() { export default function DashboardCards() {
return ( return (
<div className='flex gap-4 lg:px-4'> <div className="flex gap-4 lg:px-4">
<Card <Card
variant="gradient-success" variant="gradient-success"
className="flex flex-col justify-between" className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between"
> >
<CardHeader className="flex-row"> <CardHeader className="flex-row">
<div <div
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'} className={
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
}
> >
<HeartPulse className="size-4 fill-green-500" /> <HeartPulse className="size-4 fill-green-500" />
</div> </div>
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'> <div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
<Link href='/home/order-analysis'> <Link href="/home/order-analysis">
<Button size="icon" variant="outline" className="px-2 text-black"> <Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" /> <ChevronRight className="size-4 stroke-2" />
</Button> </Button>
@@ -33,10 +31,10 @@ export default function DashboardCards() {
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-start gap-2"> <CardFooter className="flex flex-col items-start gap-2">
<h5> <h5>
<Trans i18nKey='dashboard:heroCard.orderAnalysis.title' /> <Trans i18nKey="dashboard:heroCard.orderAnalysis.title" />
</h5> </h5>
<CardDescription className="text-primary"> <CardDescription className="text-primary">
<Trans i18nKey='dashboard:heroCard.orderAnalysis.description' /> <Trans i18nKey="dashboard:heroCard.orderAnalysis.description" />
</CardDescription> </CardDescription>
</CardFooter> </CardFooter>
</Card> </Card>

View File

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

View File

@@ -7,33 +7,41 @@ import { Database } from '@/packages/supabase/src/database.types';
import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons'; import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons';
import { import {
Activity, Activity,
ChevronRight,
Clock9, Clock9,
Droplets,
Pill, Pill,
Scale, Scale,
TrendingUp, TrendingUp,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip'; import { pathsConfig } from '@kit/shared/config';
import { getPersonParameters } from '@kit/shared/utils'; import { getPersonParameters } from '@kit/shared/utils';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
Card, Card,
CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardProps,
} from '@kit/ui/card'; } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { isNil } from 'lodash';
import { BmiCategory } from '~/lib/types/bmi'; import { BmiCategory } from '~/lib/types/bmi';
import { import {
bmiFromMetric, bmiFromMetric,
getBmiBackgroundColor, getBmiBackgroundColor,
getBmiStatus, getBmiStatus,
} from '~/lib/utils'; } 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 = ({ const cards = ({
gender, gender,
@@ -41,111 +49,91 @@ const cards = ({
height, height,
weight, weight,
bmiStatus, bmiStatus,
smoking,
}: { }: {
gender?: string; gender?: string;
age?: number; age?: number;
height?: number | null; height?: number | null;
weight?: number | null; weight?: number | null;
bmiStatus: BmiCategory | null; bmiStatus: BmiCategory | null;
smoking?: boolean | null;
}) => [ }) => [
{ {
title: 'dashboard:gender', title: 'dashboard:gender',
description: gender ?? 'dashboard:male', description: gender ?? 'dashboard:male',
icon: <User />, icon: <User />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:age', title: 'dashboard:age',
description: age ? `${age}` : '-', description: age ? `${age}` : '-',
icon: <Clock9 />, icon: <Clock9 />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:height', title: 'dashboard:height',
description: height ? `${height}cm` : '-', description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />, icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:weight', title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-', description: weight ? `${weight}kg` : '-',
icon: <Scale />, icon: <Scale />,
iconBg: 'bg-success', iconBg: 'bg-success',
}, },
{ {
title: 'dashboard:bmi', title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(), description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
icon: <TrendingUp />, icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus), iconBg: getBmiBackgroundColor(bmiStatus),
}, },
{ {
title: 'dashboard:bloodPressure', title: 'dashboard:bloodPressure',
description: '-', description: '-',
icon: <Activity />, icon: <Activity />,
iconBg: 'bg-warning', iconBg: 'bg-warning',
}, },
{ {
title: 'dashboard:cholesterol', title: 'dashboard:cholesterol',
description: '-', description: '-',
icon: <BlendingModeIcon className="size-4" />, icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive', iconBg: 'bg-destructive',
}, },
{ {
title: 'dashboard:ldlCholesterol', title: 'dashboard:ldlCholesterol',
description: '-', description: '-',
icon: <Pill />, icon: <Pill />,
iconBg: 'bg-warning', iconBg: 'bg-warning',
}, },
// { // {
// title: 'Score 2', // title: 'Score 2',
// description: 'Normis', // description: 'Normis',
// icon: <LineChart />, // icon: <LineChart />,
// iconBg: 'bg-success', // iconBg: 'bg-success',
// }, // },
// { {
// title: 'dashboard:smoking', title: 'dashboard:smoking',
// description: 'dashboard:respondToQuestion', description:
// descriptionColor: 'text-primary', isNil(smoking)
// icon: ( ? 'dashboard:respondToQuestion'
// <Button size="icon" variant="outline" className="px-2 text-black"> : !!smoking
// <ChevronRight className="size-4 stroke-2" /> ? 'common:yes'
// </Button> : 'common:no',
// ), descriptionColor: 'text-primary',
// cardVariant: 'gradient-success' as CardProps['variant'], 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),
},
];
const dummyRecommendations = [ const IS_SHOWN_RECOMMENDATIONS = false as boolean;
{
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({ export default function Dashboard({
account, account,
@@ -160,31 +148,32 @@ export default function Dashboard({
const params = getPersonParameters(account.personal_code!); const params = getPersonParameters(account.personal_code!);
const bmiStatus = getBmiStatus(bmiThresholds, { const bmiStatus = getBmiStatus(bmiThresholds, {
age: params?.age || 0, age: params?.age || 0,
height: account.account_params?.[0]?.height || 0, height: account.accountParams?.height || 0,
weight: account.account_params?.[0]?.weight || 0, weight: account.accountParams?.weight || 0,
}); });
return ( return (
<> <>
<div className="grid auto-rows-fr grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5"> <div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
{cards({ {cards({
gender: params?.gender, gender: params?.gender,
age: params?.age, age: params?.age,
height: account.account_params?.[0]?.height, height: account.accountParams?.height,
weight: account.account_params?.[0]?.weight, weight: account.accountParams?.weight,
bmiStatus, bmiStatus,
smoking: account.accountParams?.isSmoker,
}).map( }).map(
({ ({
title, title,
description, description,
icon, icon,
iconBg, iconBg,
// cardVariant, cardVariant,
// descriptionColor, // descriptionColor,
}) => ( }) => (
<Card <Card
key={title} key={title}
// variant={cardVariant} variant={cardVariant}
className="flex flex-col justify-between" className="flex flex-col justify-between"
> >
<CardHeader className="items-end-safe"> <CardHeader className="items-end-safe">
@@ -211,68 +200,7 @@ export default function Dashboard({
), ),
)} )}
</div> </div>
<Card> {IS_SHOWN_RECOMMENDATIONS && <DashboardRecommendations />}
<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 justify-between" key={index}>
<div className="mr-4 flex 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>
<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">
<p className="text-sm font-medium"> {price}</p>
{href ? (
<Link href={href}>
<Button size="sm" variant="secondary">
{buttonText}
</Button>
</Link>
) : (
<Button size="sm" variant="secondary">
{buttonText}
</Button>
)}
</div>
</div>
);
},
)}
</CardContent>
</Card>
</> </>
); );
} }

View File

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

View File

@@ -1,12 +1,18 @@
'use client'; 'use client';
import { useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { StoreCart } from '@medusajs/types'; import { StoreCart } from '@medusajs/types';
import { LogOut, Menu, ShoppingCart } from 'lucide-react'; 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 DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { import {
featureFlagsConfig, pathsConfig,
personalAccountNavigationConfig, personalAccountNavigationConfig,
} from '@kit/shared/config'; } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
@@ -15,7 +21,6 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
@@ -23,14 +28,16 @@ import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
// home imports // home imports
import { HomeAccountSelector } from '../_components/home-account-selector';
import type { UserWorkspace } from '../_lib/server/load-user-workspace'; import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { export function HomeMobileNavigation(props: {
workspace: UserWorkspace; workspace: UserWorkspace;
cart: StoreCart | null; cart: StoreCart | null;
}) { }) {
const user = props.workspace.user;
const signOut = useSignOut(); const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id);
const Links = personalAccountNavigationConfig.routes.map((item, index) => { const Links = personalAccountNavigationConfig.routes.map((item, index) => {
if ('children' in item) { if ('children' in item) {
@@ -51,8 +58,30 @@ export function HomeMobileNavigation(props: {
} }
}); });
const cartItemsCount = props.cart?.items?.length ?? 0; const hasTotpFactor = useMemo(() => {
const hasCartItems = cartItemsCount > 0; 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 ( return (
<DropdownMenu> <DropdownMenu>
@@ -61,29 +90,13 @@ export function HomeMobileNavigation(props: {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}> <DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={featureFlagsConfig.enableTeamAccounts}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common:yourAccounts'} />
</DropdownMenuLabel>
<HomeAccountSelector
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
collisionPadding={0}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<If condition={props.cart && hasCartItems}> <If condition={props.cart && hasCartItems}>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownLink <DropdownLink
path="/home/cart" path={pathsConfig.app.cart}
label="common:shoppingCartCount" label="common:shoppingCartCount"
Icon={<ShoppingCart className="stroke-[1.5px]" />} Icon={<ShoppingCart className="stroke-[1.5px]" />}
labelOptions={{ count: cartItemsCount }} labelOptions={{ count: cartQuantityTotal }}
/> />
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -91,6 +104,41 @@ export function HomeMobileNavigation(props: {
<DropdownMenuGroup>{Links}</DropdownMenuGroup> <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 /> <DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} /> <SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
@@ -99,49 +147,4 @@ 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

@@ -1,6 +1,7 @@
"use client"; "use client";
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react'; import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -12,15 +13,17 @@ import {
import { StoreProduct } from '@medusajs/types'; import { StoreProduct } from '@medusajs/types';
import { useState } from 'react'; import { useState } from 'react';
import { handleAddToCart } from '~/lib/services/medusaCart.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { useRouter } from 'next/navigation';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip'; import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { toast } from '@kit/ui/sonner';
import { formatCurrency } from '@/packages/shared/src/utils';
export type OrderAnalysisCard = Pick< export type OrderAnalysisCard = Pick<
StoreProduct, 'title' | 'description' | 'subtitle' StoreProduct, 'title' | 'description' | 'subtitle'
> & { > & {
isAvailable: boolean; isAvailable: boolean;
variant: { id: string }; variant: { id: string };
price: number | null;
}; };
export default function OrderAnalysesCards({ export default function OrderAnalysesCards({
@@ -30,37 +33,47 @@ export default function OrderAnalysesCards({
analyses: OrderAnalysisCard[]; analyses: OrderAnalysisCard[];
countryCode: string; countryCode: string;
}) { }) {
const router = useRouter();
const [isAddingToCart, setIsAddingToCart] = useState(false); const { i18n: { language } } = useTranslation()
const [variantAddingToCart, setVariantAddingToCart] = useState<string | null>(null);
const handleSelect = async (variantId: string) => { const handleSelect = async (variantId: string) => {
if (isAddingToCart) { if (variantAddingToCart) {
return null; return null;
} }
setIsAddingToCart(true); setVariantAddingToCart(variantId);
try { try {
await handleAddToCart({ await handleAddToCart({
selectedVariant: { id: variantId }, selectedVariant: { id: variantId },
countryCode, countryCode,
}); });
setIsAddingToCart(false); toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
router.push('/home/cart'); setVariantAddingToCart(null);
} catch (e) { } catch (e) {
setIsAddingToCart(false); toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
setVariantAddingToCart(null);
console.error(e); console.error(e);
} }
} }
return ( return (
<div className="grid grid-cols-3 gap-6 mt-4"> <div className="grid 2xs:grid-cols-3 gap-6 mt-4">
{analyses.map(({ {analyses.map(({
title, title,
variant, variant,
description, description,
subtitle, subtitle,
isAvailable, isAvailable,
price,
}) => { }) => {
const formattedPrice = typeof price === 'number'
? formatCurrency({
currencyCode: 'eur',
locale: language,
value: price,
})
: null;
return ( return (
<Card <Card
key={title} key={title}
@@ -81,7 +94,7 @@ export default function OrderAnalysesCards({
className="px-2 text-black" className="px-2 text-black"
onClick={() => handleSelect(variant.id)} onClick={() => handleSelect(variant.id)}
> >
{isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />} {variantAddingToCart === variant.id ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
</Button> </Button>
</div> </div>
)} )}
@@ -92,7 +105,14 @@ export default function OrderAnalysesCards({
{description && ( {description && (
<> <>
{' '} {' '}
<InfoTooltip content={`${description}`} /> <InfoTooltip
content={
<div className='flex flex-col gap-2'>
<span>{formattedPrice}</span>
<span>{description}</span>
</div>
}
/>
</> </>
)} )}
</h5> </h5>

View File

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

View File

@@ -0,0 +1,18 @@
'use server';
import { createPageViewLog, PageViewAction } from "~/lib/services/audit/pageView.service";
import { loadCurrentUserAccount } from "../../_lib/server/load-user-account";
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_ANALYSIS_RESULTS_FROM_ORDER,
extraData: {
analysisOrderId,
},
});
}

View File

@@ -1,29 +1,49 @@
import { Trans } from '@kit/ui/trans'; 'use client';
import { useRouter } from 'next/navigation';
import { StoreOrderLineItem } from '@medusajs/types';
import { formatDate } from 'date-fns';
import { Eye } from 'lucide-react';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { import {
Table, Table,
TableBody, TableBody,
TableHead,
TableRow,
TableHeader,
TableCell, TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table'; } from '@kit/ui/table';
import { StoreOrderLineItem } from "@medusajs/types"; import { Trans } from '@kit/ui/trans';
import { AnalysisOrder } from '~/lib/services/order.service';
import { formatDate } from 'date-fns';
import Link from 'next/link';
import { Eye } from 'lucide-react';
export default function OrderItemsTable({ items, title, analysisOrder }: { import { AnalysisOrder } from '~/lib/services/order.service';
import { logAnalysisResultsNavigateAction } from './actions';
export default function OrderItemsTable({
items,
title,
analysisOrder,
}: {
items: StoreOrderLineItem[]; items: StoreOrderLineItem[];
title: string; title: string;
analysisOrder: AnalysisOrder; analysisOrder: AnalysisOrder;
}) { }) {
const router = useRouter();
if (!items || items.length === 0) { if (!items || items.length === 0) {
return null; return null;
} }
const openAnalysisResults = async () => {
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`);
};
return ( return (
<Table className="rounded-lg border border-separate"> <Table className="border-separate rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus"> <TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow> <TableRow>
<TableHead className="px-6"> <TableHead className="px-6">
@@ -35,13 +55,14 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
<TableHead className="px-6"> <TableHead className="px-6">
<Trans i18nKey="orders:table.status" /> <Trans i18nKey="orders:table.status" />
</TableHead> </TableHead>
<TableHead className="px-6"> <TableHead className="px-6"></TableHead>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items {items
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1) .sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((orderItem) => ( .map((orderItem) => (
<TableRow className="w-full" key={orderItem.id}> <TableRow className="w-full" key={orderItem.id}>
<TableCell className="text-left w-[100%] px-6"> <TableCell className="text-left w-[100%] px-6">
@@ -54,24 +75,18 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')} {formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell> </TableCell>
<TableCell className="px-6 min-w-[180px]"> <TableCell className="min-w-[180px] px-6">
<Trans i18nKey={`orders:status.${analysisOrder.status}`} /> <Trans i18nKey={`orders:status.${analysisOrder.status}`} />
</TableCell> </TableCell>
<TableCell className="text-right px-6"> <TableCell className="px-6 text-right">
<span className="flex gap-x-1 justify-end w-[30px]"> <Button size="sm" onClick={openAnalysisResults}>
<Link href={`/home/analysis-results`} className="flex items-center justify-between text-small-regular"> <Trans i18nKey="analysis-results:view" />
<button </Button>
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
>
<Eye />
</button>
</Link>
</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
) );
} }

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { cache } from 'react'; import { cache } from 'react';
import { listProductTypes } from "@lib/data/products";
import { listRegions } from '@lib/data/regions';
import { getProductCategories } from '@lib/data/categories'; import { getProductCategories } from '@lib/data/categories';
import { listProducts, listProductTypes } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards'; import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
async function countryCodesLoader() { async function countryCodesLoader() {
@@ -14,7 +15,9 @@ async function countryCodesLoader() {
export const loadCountryCodes = cache(countryCodesLoader); export const loadCountryCodes = cache(countryCodesLoader);
async function productCategoriesLoader() { async function productCategoriesLoader() {
const productCategories = await getProductCategories({ fields: "*products, *products.variants" }); const productCategories = await getProductCategories({
fields: '*products, *products.variants, is_active',
});
return productCategories.product_categories ?? []; return productCategories.product_categories ?? [];
} }
export const loadProductCategories = cache(productCategoriesLoader); export const loadProductCategories = cache(productCategoriesLoader);
@@ -29,25 +32,42 @@ async function analysesLoader() {
const [countryCodes, productCategories] = await Promise.all([ const [countryCodes, productCategories] = await Promise.all([
loadCountryCodes(), loadCountryCodes(),
loadProductCategories(), loadProductCategories(),
]); ]);
const countryCode = countryCodes[0]!; const countryCode = countryCodes[0]!;
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis'); const category = productCategories.find(
({ metadata }) => metadata?.page === 'order-analysis',
);
const categoryProducts = category
? await listProducts({
countryCode,
queryParams: { limit: 100, category_id: category.id },
})
: null;
const serviceCategories = productCategories.filter(
({ parent_category }) => parent_category?.handle === 'tto-categories',
);
return { return {
analyses: category?.products?.map<OrderAnalysisCard>(({ title, description, subtitle, variants, status, metadata }) => { analyses:
const variant = variants![0]!; categoryProducts?.response.products.map<OrderAnalysisCard>(
return { ({ title, description, subtitle, variants, status, metadata }) => {
title, const variant = variants![0]!;
description, return {
subtitle, title,
variant: { description,
id: variant.id, subtitle,
variant: {
id: variant.id,
},
isAvailable:
status === 'published' && !!metadata?.analysisIdOriginal,
price: variant.calculated_price?.calculated_amount ?? null,
};
}, },
isAvailable: status === 'published' && !!metadata?.analysisIdOriginal, ) ?? [],
};
}) ?? [],
countryCode, countryCode,
} };
} }
export const loadAnalyses = cache(analysesLoader); export const loadAnalyses = cache(analysesLoader);

View File

@@ -6,8 +6,8 @@ import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import type { StoreProduct } from '@medusajs/types'; import type { StoreProduct } from '@medusajs/types';
import { loadCurrentUserAccount } from './load-user-account'; import { loadCurrentUserAccount } from './load-user-account';
import { AnalysisPackageWithVariant } from '~/components/select-analysis-package';
import { AccountWithParams } from '@/packages/features/accounts/src/server/api'; import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>
@@ -38,8 +38,11 @@ function userSpecificVariantLoader({
if (age >= 18 && age <= 29) { if (age >= 18 && age <= 29) {
return '18-29'; return '18-29';
} }
if (age >= 30 && age <= 49) { if (age >= 30 && age <= 39) {
return '30-49'; return '30-39';
}
if (age >= 40 && age <= 49) {
return '40-49';
} }
if (age >= 50 && age <= 59) { if (age >= 50 && age <= 59) {
return '50-59'; return '50-59';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api';
import { UserAnalysis } from '@kit/accounts/types/accounts';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalyses>>;
/**
* @name loadUserAnalyses
* @description
* Load the user's analyses. It's a cached per-request function that fetches the user workspace data.
* It can be used across the server components to load the user workspace data.
*/
export const loadUserAnalyses = cache(analysesLoader);
async function analysesLoader(): Promise<UserAnalysis | null> {
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
return api.getUserAnalyses();
}

View File

@@ -1,7 +1,7 @@
import { cache } from 'react'; import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api'; import { createAccountsApi } from '@kit/accounts/api';
import { UserAnalysis } from '@kit/accounts/types/accounts'; import { AnalysisResultDetails } from '@kit/accounts/types/accounts';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>; export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
@@ -9,14 +9,15 @@ export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
/** /**
* @name loadUserAnalysis * @name loadUserAnalysis
* @description * @description
* Load the user's analyses. It's a cached per-request function that fetches the user workspace data. * Load the user's analysis based on id. It's a cached per-request function that fetches the user's analysis data.
* It can be used across the server components to load the user workspace data.
*/ */
export const loadUserAnalysis = cache(analysisLoader); export const loadUserAnalysis = cache(analysisLoader);
async function analysisLoader(): Promise<UserAnalysis | null> { async function analysisLoader(
analysisOrderId: number,
): Promise<AnalysisResultDetails | null> {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const api = createAccountsApi(client); const api = createAccountsApi(client);
return api.getUserAnalysis(); return api.getUserAnalysis(analysisOrderId);
} }

View File

@@ -0,0 +1,111 @@
'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

@@ -0,0 +1,259 @@
'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

@@ -0,0 +1,152 @@
'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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,66 @@
'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,23 +1,69 @@
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { use } from 'react';
import { Trans } from '@kit/ui/trans';
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 { withI18n } from '~/lib/i18n/with-i18n'; 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';
// local imports function UserSettingsLayout({ children }: React.PropsWithChildren) {
import { HomeLayoutPageHeader } from '../_components/home-page-header'; return <HeaderLayout>{children}</HeaderLayout>;
function UserSettingsLayout(props: React.PropsWithChildren) {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'account:routes.settings'} />}
description={<AppBreadcrumbs />}
/>
{props.children}
</>
);
} }
export default withI18n(UserSettingsLayout); 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;
}) {
return (
<>
<AppLogo href={pathsConfig.app.home} />
<SettingsMobileNavigation workspace={workspace} cart={cart} />
</>
);
}

View File

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

View File

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,17 @@
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,9 +1,10 @@
'use client'; 'use client';
import Link from 'next/link'; import DropdownLink from '@kit/shared/components/ui/dropdown-link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Home, LogOut, Menu } from 'lucide-react'; import SignOutDropdownItem from '@kit/shared/components/sign-out-dropdown-item';
import { Home, Menu } from 'lucide-react';
import { AccountSelector } from '@kit/accounts/account-selector'; import { AccountSelector } from '@kit/accounts/account-selector';
import { import {
@@ -92,47 +93,7 @@ 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: { function TeamAccountsModal(props: {
accounts: Accounts; accounts: Accounts;

8362
current-test-data.sql Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
import type { Tables } from '@/packages/supabase/src/database.types'; import type { Tables } from '@/packages/supabase/src/database.types';
import { AccountWithParams } from '@kit/accounts/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-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 Account = Tables<{ schema: 'medreport' }, 'accounts'>;
type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>; type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>;
@@ -41,3 +47,100 @@ export async function getAccountAdmin({
return data as unknown as AccountWithMemberships; return data as unknown as AccountWithMemberships;
} }
export async function getDoctorAccounts() {
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('id, email, name, last_name, preferred_locale')
.eq('is_personal_account', true)
.eq('application_role', 'doctor')
.throwOnError();
return data?.map(({ id, email, name, last_name, preferred_locale }) => ({
id,
email,
name,
lastName: last_name,
preferredLocale: preferred_locale,
}));
}
export async function getAssignedDoctorAccount(analysisOrderId: number) {
const supabase = getSupabaseServerAdminClient();
const { data: doctorUser } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('doctor_user_id')
.eq('analysis_order_id', analysisOrderId)
.throwOnError();
const doctorData = doctorUser[0];
if (!doctorData || !doctorData.doctor_user_id) {
return null;
}
const { data } = await supabase
.schema('medreport')
.from('accounts')
.select('email')
.eq('primary_owner_user_id', doctorData.doctor_user_id);
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 { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { IUuringElement } from "./medipost.types"; import type { IUuringElement } from "./medipost.types";
type AnalysesWithGroupsAndElements = ({ export type AnalysesWithGroupsAndElements = ({
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & { analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>; analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
}; };
@@ -105,12 +105,18 @@ export const createMedusaSyncSuccessEntry = async () => {
}); });
} }
export async function getAnalyses({ ids }: { ids: number[] }): Promise<AnalysesWithGroupsAndElements> { export async function getAnalyses({ ids, originalIds }: { ids?: number[], originalIds?: string[] }): Promise<AnalysesWithGroupsAndElements> {
const { data } = await getSupabaseServerAdminClient() const query = getSupabaseServerAdminClient()
.schema('medreport') .schema('medreport')
.from('analyses') .from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`) .select(`*, analysis_elements(*, analysis_groups(*))`);
.in('id', ids); if (Array.isArray(ids)) {
query.in('id', ids);
}
if (Array.isArray(originalIds)) {
query.in('analysis_id_original', originalIds);
}
const { data } = await query.throwOnError();
return data as unknown as AnalysesWithGroupsAndElements; return data as unknown as AnalysesWithGroupsAndElements;
} }

View File

@@ -1,8 +1,10 @@
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export enum NotificationAction { export enum NotificationAction {
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED', DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
} }
export const createNotificationLog = async ({ export const createNotificationLog = async ({
@@ -17,7 +19,7 @@ export const createNotificationLog = async ({
relatedRecordId?: string | number; relatedRecordId?: string | number;
}) => { }) => {
try { try {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerAdminClient();
await supabase await supabase
.schema('audit') .schema('audit')
@@ -30,6 +32,6 @@ export const createNotificationLog = async ({
}) })
.throwOnError(); .throwOnError();
} catch (error) { } catch (error) {
console.error('Failed to insert doctor page view log', error); console.error('Failed to insert doctor notification log', error);
} }
}; };

View File

@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
export enum PageViewAction { export enum PageViewAction {
VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS', VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS',
VIEW_ANALYSIS_RESULTS_FROM_ORDER = 'VIEW_ANALYSIS_RESULTS_FROM_ORDER',
REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS', REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS',
VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS', VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS',
VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD', VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD',
@@ -10,9 +11,11 @@ export enum PageViewAction {
export const createPageViewLog = async ({ export const createPageViewLog = async ({
accountId, accountId,
action, action,
extraData,
}: { }: {
accountId: string; accountId: string;
action: PageViewAction; action: PageViewAction;
extraData?: Record<string, any>;
}) => { }) => {
try { try {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
@@ -34,6 +37,7 @@ export const createPageViewLog = async ({
account_id: accountId, account_id: accountId,
action, action,
changed_by: user.id, changed_by: user.id,
extra_data: extraData,
}) })
.throwOnError(); .throwOnError();
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,32 @@
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
async function getAssignedOrderIds() {
const supabase = getSupabaseServerAdminClient();
const { data: assignedOrderIds } = await supabase
.schema('medreport')
.from('doctor_analysis_feedback')
.select('analysis_order_id')
.not('doctor_user_id', 'is', null)
.throwOnError();
return assignedOrderIds?.map((f) => f.analysis_order_id) || [];
}
export async function getOpenJobAnalysisResponseIds() {
const supabase = getSupabaseServerAdminClient();
const assignedIds = await getAssignedOrderIds();
let query = supabase
.schema('medreport')
.from('analysis_responses')
.select('id, analysis_order_id')
.order('created_at', { ascending: false });
if (assignedIds.length > 0) {
query = query.not('analysis_order_id', 'in', `(${assignedIds.join(',')})`);
}
const { data: analysisResponses } = await query.throwOnError();
return analysisResponses?.map(({ id }) => id) || [];
}

View File

@@ -1,50 +1,41 @@
'use server'; 'use server';
import { CompanySubmitData } from '@/lib/types/company'; import { toArray } from '@/lib/utils';
import { emailSchema } from '@/lib/validations/email.schema';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getMailer } from '@kit/mailers'; import { getMailer } from '@kit/mailers';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
export const sendDoctorSummaryCompletedEmail = async ( import { emailSchema } from '~/lib/validations/email.schema';
language: string,
recipientName: string,
recipientEmail: string,
orderNr: string,
orderId: number,
) => {
const { html, subject } = await renderDoctorSummaryReceivedEmail({
language,
recipientName,
recipientEmail,
orderNr,
orderId,
});
await sendEmail({ type EmailTemplate = {
subject, html: string;
html, subject: string;
to: recipientEmail,
});
}; };
export const sendCompanyOfferEmail = async ( type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
data: CompanySubmitData,
language: string,
) => {
const { renderCompanyOfferEmail } = await import('@kit/email-templates');
const { html, subject } = await renderCompanyOfferEmail({
language,
companyData: data,
});
await sendEmail({ export const sendEmailFromTemplate = async <T>(
subject, renderer: EmailRenderer<T>,
html, templateParams: T,
to: process.env.CONTACT_EMAIL || '', recipients: string | string[],
}); ) => {
const { html, subject } = await renderer(templateParams);
const recipientsArray = toArray(recipients);
if (!recipientsArray.length) {
throw new Error('No valid email recipients provided');
}
const emailPromises = recipientsArray.map((email) =>
sendEmail({
subject,
html,
to: email,
}),
);
await Promise.all(emailPromises);
}; };
export const sendEmail = enhanceAction( export const sendEmail = enhanceAction(
@@ -53,7 +44,7 @@ export const sendEmail = enhanceAction(
const log = await getLogger(); const log = await getLogger();
if (!process.env.EMAIL_USER) { if (!process.env.EMAIL_USER) {
log.error('Sending email failed, as no sender found in env.') log.error('Sending email failed, as no sender was found in env.');
throw new Error('No email user configured'); throw new Error('No email user configured');
} }

View File

@@ -37,10 +37,10 @@ import { Tables } from '@kit/supabase/database';
import { createAnalysisGroup } from './analysis-group.service'; import { createAnalysisGroup } from './analysis-group.service';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { getOrder, updateOrderStatus } from './order.service'; import { getOrder, updateOrderStatus } from './order.service';
import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service'; import { AnalysisElement, getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { getAnalyses } from './analyses.service'; import { AnalysesWithGroupsAndElements, getAnalyses } from './analyses.service';
import { getAccountAdmin } from './account.service'; import { getAccountAdmin } from './account.service';
import { StoreOrder } from '@medusajs/types'; import { StoreOrder, StoreOrderLineItem } from '@medusajs/types';
import { listProducts } from '@lib/data/products'; import { listProducts } from '@lib/data/products';
import { listRegions } from '@lib/data/regions'; import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
@@ -97,7 +97,7 @@ export async function getLatestPublicMessageListItem() {
Action: MedipostAction.GetPublicMessageList, Action: MedipostAction.GetPublicMessageList,
User: USER, User: USER,
Password: PASSWORD, Password: PASSWORD,
Sender: 'syndev', Sender: RECIPIENT,
// LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created // LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created
// MessageType check only for messages of certain type // MessageType check only for messages of certain type
}, },
@@ -180,7 +180,10 @@ export async function getPrivateMessage(messageId: string) {
await validateMedipostResponse(data, { canHaveEmptyCode: true }); await validateMedipostResponse(data, { canHaveEmptyCode: true });
return parseXML(data) as MedipostOrderResponse; return {
message: parseXML(data) as MedipostOrderResponse,
xml: data as string,
};
} }
export async function deletePrivateMessage(messageId: string) { export async function deletePrivateMessage(messageId: string) {
@@ -211,7 +214,9 @@ export async function readPrivateMessageResponse({
try { try {
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds }); const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
if (!privateMessage) { messageId = privateMessage?.messageId ?? null;
if (!privateMessage || !messageId) {
return { return {
messageId: null, messageId: null,
hasAnalysisResponse: false, hasAnalysisResponse: false,
@@ -221,40 +226,28 @@ export async function readPrivateMessageResponse({
}; };
} }
messageId = privateMessage.messageId; const { message: privateMessageContent, xml: privateMessageXml } = await getPrivateMessage(
if (!messageId) {
return {
messageId: null,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
};
}
const privateMessageContent = await getPrivateMessage(
privateMessage.messageId, privateMessage.messageId,
); );
const messageResponse = privateMessageContent?.Saadetis?.Vastus; const messageResponse = privateMessageContent?.Saadetis?.Vastus;
medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId; medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
if (!medusaOrderId || !medusaOrderId.toString().startsWith('order_')) { const hasInvalidOrderId = !medusaOrderId || !medusaOrderId.toString().startsWith('order_');
return {
messageId,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
};
}
if (!messageResponse) { if (hasInvalidOrderId || !messageResponse) {
await createMedipostActionLog({
action: 'sync_analysis_results_from_medipost',
xml: privateMessageXml,
hasAnalysisResults: false,
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
});
return { return {
messageId, messageId,
hasAnalysisResponse: false, hasAnalysisResponse: false,
hasPartialAnalysisResponse: false, hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false, hasFullAnalysisResponse: false,
medusaOrderId, medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
}; };
} }
@@ -487,72 +480,60 @@ export async function composeOrderXML({
throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`); 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'>[] = const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
uniqBy( uniqBy(uniques, 'id');
( console.log('analysisGroups', { analysisGroups, uniques });
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
[]
).concat(
analyses?.flatMap(
({ analysis_elements }) => analysis_elements.analysis_groups,
) ?? [],
),
'id',
);
const specimenSection = []; const specimenSection = [];
const analysisSection = []; const analysisSection = [];
let order = 1; let order = 1;
for (const currentGroup of analysisGroups) { for (const currentGroup of analysisGroups) {
let relatedAnalysisElement = analysisElements?.find( const relatedAnalysisElements = await getRelatedAnalysisElements({
(element) => element.analysis_groups.id === currentGroup.id, analysisElements,
); analyses,
const relatedAnalyses = analyses?.filter((analysis) => { currentGroup,
return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
}); });
if (!relatedAnalysisElement) { for (const relatedAnalysisElement of relatedAnalysisElements) {
relatedAnalysisElement = relatedAnalyses?.find( if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
(relatedAnalysis) => throw new Error(
relatedAnalysis.analysis_elements.analysis_groups.id === `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
currentGroup.id, );
)?.analysis_elements; }
}
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) { for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
throw new Error( const materials = toArray(group.Materjal);
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`, const specimenXml = materials.flatMap(
({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => {
return toArray(Konteiner).map((container) =>
getSpecimen(
MaterjaliTyypOID,
MaterjaliTyyp,
MaterjaliNimi,
order,
container.ProovinouKoodOID,
container.ProovinouKood,
),
);
},
);
specimenSection.push(...specimenXml);
}
const groupXml = getAnalysisGroup(
currentGroup.original_id,
currentGroup.name,
order,
relatedAnalysisElement,
); );
order++;
analysisSection.push(groupXml);
} }
for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
const materials = toArray(group.Materjal);
const specimenXml = materials.flatMap(
({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => {
return toArray(Konteiner).map((container) =>
getSpecimen(
MaterjaliTyypOID,
MaterjaliTyyp,
MaterjaliNimi,
order,
container.ProovinouKoodOID,
container.ProovinouKood,
),
);
},
);
specimenSection.push(...specimenXml);
}
const groupXml = getAnalysisGroup(
currentGroup.original_id,
currentGroup.name,
order,
relatedAnalysisElement,
);
order++;
analysisSection.push(groupXml);
} }
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
@@ -673,7 +654,7 @@ async function syncPrivateMessage({
unit: element.Mootyhik ?? null, unit: element.Mootyhik ?? null,
original_response_element: element, original_response_element: element,
analysis_name: element.UuringNimi || element.KNimetus, analysis_name: element.UuringNimi || element.KNimetus,
comment: element.UuringuKommentaar comment: element.UuringuKommentaar ?? '',
})), })),
); );
} }
@@ -702,7 +683,7 @@ async function syncPrivateMessage({
); );
} }
const { data: allOrderResponseElements} = await supabase const { data: allOrderResponseElements } = await supabase
.schema('medreport') .schema('medreport')
.from('analysis_response_elements') .from('analysis_response_elements')
.select('*') .select('*')
@@ -722,7 +703,7 @@ export async function sendOrderToMedipost({
orderedAnalysisElements, orderedAnalysisElements,
}: { }: {
medusaOrderId: string; medusaOrderId: string;
orderedAnalysisElements: { analysisElementId: number }[]; orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
}) { }) {
const medreportOrder = await getOrder({ medusaOrderId }); const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
@@ -734,8 +715,8 @@ export async function sendOrderToMedipost({
lastName: account.last_name ?? '', lastName: account.last_name ?? '',
phone: account.phone ?? '', phone: account.phone ?? '',
}, },
orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId), orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: [], orderedAnalysesIds: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId, orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at), orderCreatedAt: new Date(medreportOrder.created_at),
comment: '', comment: '',
@@ -745,12 +726,36 @@ export async function sendOrderToMedipost({
await sendPrivateMessage(orderXml); await sendPrivateMessage(orderXml);
} catch (e) { } catch (e) {
const isMedipostError = e instanceof MedipostValidationError; const isMedipostError = e instanceof MedipostValidationError;
await logMedipostDispatch({ if (isMedipostError) {
medusaOrderId, await logMedipostDispatch({
isSuccess: false, medusaOrderId,
isMedipostError, isSuccess: false,
errorMessage: isMedipostError ? e.response : undefined, isMedipostError,
}); errorMessage: e.response,
});
await createMedipostActionLog({
action: 'send_order_to_medipost',
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
responseXml: e.response,
hasError: true,
});
} else {
await logMedipostDispatch({
medusaOrderId,
isSuccess: false,
isMedipostError,
});
await createMedipostActionLog({
action: 'send_order_to_medipost',
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
hasError: true,
});
}
throw e; throw e;
} }
await logMedipostDispatch({ await logMedipostDispatch({
@@ -758,20 +763,30 @@ export async function sendOrderToMedipost({
isSuccess: true, isSuccess: true,
isMedipostError: false, isMedipostError: false,
}); });
await createMedipostActionLog({
action: 'send_order_to_medipost',
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
});
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
} }
export async function getOrderedAnalysisElementsIds({ type OrderItems = {
items: Pick<StoreOrderLineItem, 'product'>[];
}
export async function getOrderedAnalysisIds({
medusaOrder, medusaOrder,
}: { }: {
medusaOrder: StoreOrder; medusaOrder: OrderItems;
}): Promise<{ }): Promise<{
analysisElementId: number; analysisElementId?: number;
analysisId?: number;
}[]> { }[]> {
const countryCodes = await listRegions(); const countryCodes = await listRegions();
const countryCode = countryCodes[0]!.countries![0]!.iso_2!; const countryCode = countryCodes[0]!.countries![0]!.iso_2!;
async function getOrderedAnalysisElements(medusaOrder: StoreOrder) { async function getOrderedAnalysisElements(medusaOrder: OrderItems) {
const originalIds = (medusaOrder?.items ?? []) const originalIds = (medusaOrder?.items ?? [])
.map((a) => a.product?.metadata?.analysisIdOriginal) .map((a) => a.product?.metadata?.analysisIdOriginal)
.filter((a) => typeof a === 'string') as string[]; .filter((a) => typeof a === 'string') as string[];
@@ -779,7 +794,15 @@ export async function getOrderedAnalysisElementsIds({
return analysisElements.map(({ id }) => ({ analysisElementId: id })); return analysisElements.map(({ id }) => ({ analysisElementId: id }));
} }
async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) { async function getOrderedAnalyses(medusaOrder: OrderItems) {
const originalIds = (medusaOrder?.items ?? [])
.map((a) => a.product?.metadata?.analysisIdOriginal)
.filter((a) => typeof a === 'string') as string[];
const analyses = await getAnalyses({ originalIds });
return analyses.map(({ id }) => ({ analysisId: id }));
}
async function getOrderedAnalysisPackages(medusaOrder: OrderItems) {
const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX));
const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[]; const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[];
if (orderedPackageIds.length === 0) { if (orderedPackageIds.length === 0) {
@@ -818,10 +841,81 @@ export async function getOrderedAnalysisElementsIds({
return analysisElements.map(({ id }) => ({ analysisElementId: id })); return analysisElements.map(({ id }) => ({ analysisElementId: id }));
} }
const [analysisPackageElements, orderedAnalysisElements] = await Promise.all([ const [analysisPackageElements, orderedAnalysisElements, orderedAnalyses] = await Promise.all([
getOrderedAnalysisPackages(medusaOrder), getOrderedAnalysisPackages(medusaOrder),
getOrderedAnalysisElements(medusaOrder), getOrderedAnalysisElements(medusaOrder),
getOrderedAnalyses(medusaOrder),
]); ]);
return [...analysisPackageElements, ...orderedAnalysisElements]; return [...analysisPackageElements, ...orderedAnalysisElements, ...orderedAnalyses];
}
export async function createMedipostActionLog({
action,
xml,
hasAnalysisResults = false,
medusaOrderId,
responseXml,
hasError = false,
}: {
action:
| '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;
responseXml?: string | null;
hasError?: boolean;
}) {
await getSupabaseServerAdminClient()
.schema('medreport')
.from('medipost_actions')
.insert({
action,
xml,
has_analysis_results: hasAnalysisResults,
medusa_order_id: medusaOrderId,
response_xml: responseXml,
has_error: hasError,
})
.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({ .object({
medusaBackendPublicUrl: z medusaBackendPublicUrl: z
.string({ .string({
required_error: 'MEDUSA_BACKEND_PUBLIC_URL is required', error: 'MEDUSA_BACKEND_PUBLIC_URL is required',
}) })
.min(1), .min(1),
siteUrl: z siteUrl: z
.string({ .string({
required_error: 'NEXT_PUBLIC_SITE_URL is required', error: 'NEXT_PUBLIC_SITE_URL is required',
}) })
.min(1), .min(1),
}) })
@@ -110,7 +110,7 @@ export async function handleNavigateToPayment({
const paymentLink = const paymentLink =
await new MontonioOrderHandlerService().getMontonioPaymentLink({ await new MontonioOrderHandlerService().getMontonioPaymentLink({
notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`, notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`,
returnUrl: `${env().siteUrl}/home/cart/montonio-callback`, returnUrl: `${"https://webhook.site"}/home/cart/montonio-callback`,
amount: cart.total, amount: cart.total,
currency: cart.currency_code.toUpperCase(), currency: cart.currency_code.toUpperCase(),
description: `Order from Medreport`, description: `Order from Medreport`,

View File

@@ -10,7 +10,7 @@ export async function createOrder({
orderedAnalysisElements, orderedAnalysisElements,
}: { }: {
medusaOrder: StoreOrder; medusaOrder: StoreOrder;
orderedAnalysisElements: { analysisElementId: number }[]; orderedAnalysisElements: { analysisElementId?: number; analysisId?: number }[];
}) { }) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
@@ -21,8 +21,8 @@ export async function createOrder({
const orderResult = await supabase.schema('medreport') const orderResult = await supabase.schema('medreport')
.from('analysis_orders') .from('analysis_orders')
.insert({ .insert({
analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId), analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
analysis_ids: [], analysis_ids: orderedAnalysisElements.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
status: 'QUEUED', status: 'QUEUED',
user_id: user.id, user_id: user.id,
medusa_order_id: medusaOrder.id, medusa_order_id: medusaOrder.id,
@@ -134,8 +134,10 @@ export async function getAnalysisOrders({
export async function getAnalysisOrdersAdmin({ export async function getAnalysisOrdersAdmin({
orderStatus, orderStatus,
medusaOrderId,
}: { }: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status']; orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
medusaOrderId?: string | null;
} = {}) { } = {}) {
const query = getSupabaseServerAdminClient() const query = getSupabaseServerAdminClient()
.schema('medreport') .schema('medreport')
@@ -144,6 +146,9 @@ export async function getAnalysisOrdersAdmin({
if (orderStatus) { if (orderStatus) {
query.eq('status', orderStatus); query.eq('status', orderStatus);
} }
if (medusaOrderId) {
query.eq('medusa_order_id', medusaOrderId);
}
const orders = await query.order('created_at', { ascending: false }).throwOnError(); const orders = await query.order('created_at', { ascending: false }).throwOnError();
return orders.data; return orders.data;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -165,6 +165,20 @@ async function doctorMiddleware(request: NextRequest, response: NextResponse) {
*/ */
function getPatterns() { function getPatterns() {
return [ return [
{
pattern: new URLPattern({ pathname: '/' }),
handler: async (req: NextRequest, res: NextResponse) => {
const {
data: { user },
} = await getUser(req, res);
if (user) {
return NextResponse.redirect(
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
);
}
},
},
{ {
pattern: new URLPattern({ pathname: '/admin/*?' }), pattern: new URLPattern({ pathname: '/admin/*?' }),
handler: adminMiddleware, handler: adminMiddleware,

View File

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

View File

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

View File

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

View File

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

View File

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

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