Compare commits
30 Commits
231b9d8dc4
...
keycloak2
| Author | SHA1 | Date | |
|---|---|---|---|
| ab92c3b4d0 | |||
| e324872c3c | |||
| a44f9c9207 | |||
| 0cf04b4f55 | |||
| 84216c3ced | |||
| 0037241558 | |||
| c7f89723e3 | |||
| 4a06059a25 | |||
| a0abb44257 | |||
| 283b502963 | |||
| 5c8f8b73d7 | |||
| 5b52da0a62 | |||
|
|
21375cf55f | ||
|
|
9122acc89f | ||
|
|
152ec5f36b | ||
|
|
5176ecdddc | ||
| 40ffbf29a5 | |||
| b046b6ab16 | |||
|
|
8cb9d7552c | ||
|
|
cdb638f046 | ||
|
|
a587b222b9 | ||
|
|
612673ddf9 | ||
|
|
3230dd7608 | ||
|
|
e4d7c874cc | ||
|
|
b7926f79a9 | ||
|
|
9d62a2d86f | ||
|
|
3498406a0c | ||
|
|
56a832b96b | ||
|
|
568104eaff | ||
|
|
2eac3a6836 |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
5
.env
5
.env
@@ -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==
|
||||||
|
|||||||
@@ -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
15
.env.staging
Normal 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
|
||||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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'} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
21
app/api/email-template-test/route.ts
Normal file
21
app/api/email-template-test/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
23
app/api/job/handler/send-open-jobs-emails.ts
Normal file
23
app/api/job/handler/send-open-jobs-emails.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
53
app/api/job/send-open-jobs-emails/route.ts
Normal file
53
app/api/job/send-open-jobs-emails/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,8 +35,8 @@ 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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { createMedipostActionLog, 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,8 +27,8 @@ 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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
54
app/auth/sign-in/components/PasswordOption.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
107
app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx
Normal file
107
app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
128
app/home/(user)/_components/dashboard-recommendations.tsx
Normal file
128
app/home/(user)/_components/dashboard-recommendations.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export async function HomeMenuNavigation(props: {
|
|||||||
})
|
})
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
const cartQuantityTotal =
|
||||||
|
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
||||||
const hasCartItems = cartQuantityTotal > 0;
|
const hasCartItems = cartQuantityTotal > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,15 +40,18 @@ export async function HomeMenuNavigation(props: {
|
|||||||
<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"
|
||||||
|
|||||||
@@ -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,7 +58,29 @@ export function HomeMobileNavigation(props: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
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;
|
const hasCartItems = cartQuantityTotal > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,26 +90,10 @@ 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: cartQuantityTotal }}
|
labelOptions={{ count: cartQuantityTotal }}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -15,12 +16,14 @@ import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
|||||||
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 { 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,36 +33,47 @@ export default function OrderAnalysesCards({
|
|||||||
analyses: OrderAnalysisCard[];
|
analyses: OrderAnalysisCard[];
|
||||||
countryCode: string;
|
countryCode: string;
|
||||||
}) {
|
}) {
|
||||||
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,
|
||||||
});
|
});
|
||||||
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
|
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
|
||||||
setIsAddingToCart(false);
|
setVariantAddingToCart(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
|
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
|
||||||
setIsAddingToCart(false);
|
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}
|
||||||
@@ -80,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>
|
||||||
)}
|
)}
|
||||||
@@ -91,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>
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
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 { AnalysisOrder } from '~/lib/services/order.service';
|
||||||
import { formatDate } from 'date-fns';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { logAnalysisResultsNavigateAction } from './actions';
|
import { logAnalysisResultsNavigateAction } from './actions';
|
||||||
|
|
||||||
export default function OrderItemsTable({ items, title, analysisOrder }: {
|
export default function OrderItemsTable({
|
||||||
|
items,
|
||||||
|
title,
|
||||||
|
analysisOrder,
|
||||||
|
}: {
|
||||||
items: StoreOrderLineItem[];
|
items: StoreOrderLineItem[];
|
||||||
title: string;
|
title: string;
|
||||||
analysisOrder: AnalysisOrder;
|
analysisOrder: AnalysisOrder;
|
||||||
@@ -29,11 +39,11 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
|
|||||||
|
|
||||||
const openAnalysisResults = async () => {
|
const openAnalysisResults = async () => {
|
||||||
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
|
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
|
||||||
router.push(`/home/analysis-results`);
|
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">
|
||||||
@@ -45,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">
|
||||||
@@ -64,23 +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}>
|
||||||
<button
|
<Trans i18nKey="analysis-results:view" />
|
||||||
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer "
|
</Button>
|
||||||
onClick={openAnalysisResults}
|
|
||||||
>
|
|
||||||
<Eye />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
|
|
||||||
import { getProductCategories } from '@lib/data/categories';
|
import { getProductCategories } from '@lib/data/categories';
|
||||||
import { listProductTypes } from '@lib/data/products';
|
import { listProducts, listProductTypes } from '@lib/data/products';
|
||||||
import { listRegions } from '@lib/data/regions';
|
import { listRegions } from '@lib/data/regions';
|
||||||
|
|
||||||
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
|
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
|
||||||
import { ServiceCategory } from '../../_components/service-categories';
|
|
||||||
|
|
||||||
async function countryCodesLoader() {
|
async function countryCodesLoader() {
|
||||||
const countryCodes = await listRegions().then((regions) =>
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
@@ -39,13 +38,20 @@ async function analysesLoader() {
|
|||||||
const category = productCategories.find(
|
const category = productCategories.find(
|
||||||
({ metadata }) => metadata?.page === 'order-analysis',
|
({ metadata }) => metadata?.page === 'order-analysis',
|
||||||
);
|
);
|
||||||
|
const categoryProducts = category
|
||||||
|
? await listProducts({
|
||||||
|
countryCode,
|
||||||
|
queryParams: { limit: 100, category_id: category.id },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const serviceCategories = productCategories.filter(
|
const serviceCategories = productCategories.filter(
|
||||||
({ parent_category }) => parent_category?.handle === 'tto-categories',
|
({ parent_category }) => parent_category?.handle === 'tto-categories',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
analyses:
|
analyses:
|
||||||
category?.products?.map<OrderAnalysisCard>(
|
categoryProducts?.response.products.map<OrderAnalysisCard>(
|
||||||
({ title, description, subtitle, variants, status, metadata }) => {
|
({ title, description, subtitle, variants, status, metadata }) => {
|
||||||
const variant = variants![0]!;
|
const variant = variants![0]!;
|
||||||
return {
|
return {
|
||||||
@@ -57,6 +63,7 @@ async function analysesLoader() {
|
|||||||
},
|
},
|
||||||
isAvailable:
|
isAvailable:
|
||||||
status === 'published' && !!metadata?.analysisIdOriginal,
|
status === 'published' && !!metadata?.analysisIdOriginal,
|
||||||
|
price: variant.calculated_price?.calculated_amount ?? null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
) ?? [],
|
) ?? [],
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/home/(user)/_lib/server/load-user-analyses.ts
Normal file
22
app/home/(user)/_lib/server/load-user-analyses.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
app/home/(user)/settings/_components/account-settings-form.tsx
Normal file
259
app/home/(user)/settings/_components/account-settings-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
app/home/(user)/settings/_components/settings-navigation.tsx
Normal file
152
app/home/(user)/settings/_components/settings-navigation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/home/(user)/settings/_components/settings-sidebar.tsx
Normal file
55
app/home/(user)/settings/_components/settings-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
21
app/home/(user)/settings/_lib/account-settings.schema.ts
Normal file
21
app/home/(user)/settings/_lib/account-settings.schema.ts
Normal 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>;
|
||||||
66
app/home/(user)/settings/_lib/server/actions.ts
Normal file
66
app/home/(user)/settings/_lib/server/actions.ts
Normal 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 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
24
app/home/(user)/settings/preferences/page.tsx
Normal file
24
app/home/(user)/settings/preferences/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
app/home/(user)/settings/security/page.tsx
Normal file
17
app/home/(user)/settings/security/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
8362
current-test-data.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -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('/');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const defaultI18nNamespaces = [
|
|||||||
'orders',
|
'orders',
|
||||||
'analysis-results',
|
'analysis-results',
|
||||||
'doctor',
|
'doctor',
|
||||||
|
'error',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
32
lib/services/doctor-jobs.service.ts
Normal file
32
lib/services/doctor-jobs.service.ts
Normal 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) || [];
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
@@ -480,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"?>
|
||||||
@@ -666,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 ?? '',
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -695,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('*')
|
||||||
@@ -715,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 });
|
||||||
@@ -727,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: '',
|
||||||
@@ -784,17 +772,21 @@ export async function sendOrderToMedipost({
|
|||||||
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[];
|
||||||
@@ -802,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) {
|
||||||
@@ -841,12 +841,13 @@ 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({
|
export async function createMedipostActionLog({
|
||||||
@@ -858,10 +859,10 @@ export async function createMedipostActionLog({
|
|||||||
hasError = false,
|
hasError = false,
|
||||||
}: {
|
}: {
|
||||||
action:
|
action:
|
||||||
| 'send_order_to_medipost'
|
| 'send_order_to_medipost'
|
||||||
| 'sync_analysis_results_from_medipost'
|
| 'sync_analysis_results_from_medipost'
|
||||||
| 'send_fake_analysis_results_to_medipost'
|
| 'send_fake_analysis_results_to_medipost'
|
||||||
| 'send_analysis_results_to_medipost';
|
| 'send_analysis_results_to_medipost';
|
||||||
xml: string;
|
xml: string;
|
||||||
hasAnalysisResults?: boolean;
|
hasAnalysisResults?: boolean;
|
||||||
medusaOrderId?: string | null;
|
medusaOrderId?: string | null;
|
||||||
@@ -882,3 +883,39 @@ export async function createMedipostActionLog({
|
|||||||
.select('id')
|
.select('id')
|
||||||
.throwOnError();
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
23
lib/utils.ts
23
lib/utils.ts
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
5
local-sync/listofproductids.js
Normal file
5
local-sync/listofproductids.js
Normal 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(', '));
|
||||||
8
local-sync/local-order.http
Normal file
8
local-sync/local-order.http
Normal 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'" }'
|
||||||
|
}
|
||||||
|
|
||||||
|
#
|
||||||
16
local-sync/syncinbrowser.js
Normal file
16
local-sync/syncinbrowser.js
Normal 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()
|
||||||
157
local-sync/test-cron-setup.md
Normal file
157
local-sync/test-cron-setup.md
Normal 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
|
||||||
1090
local-sync/xmls/SendPrivateMessageToTervise_cURL.sh
Executable file
1090
local-sync/xmls/SendPrivateMessageToTervise_cURL.sh
Executable file
File diff suppressed because it is too large
Load Diff
138
local-sync/xmls/curl2.sh
Executable file
138
local-sync/xmls/curl2.sh
Executable 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'
|
||||||
98
local-sync/xmls/order6.xml
Normal file
98
local-sync/xmls/order6.xml
Normal 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
|
||||||
|
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>
|
||||||
|
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>
|
||||||
76
local-sync/xmls/order9.xml
Normal file
76
local-sync/xmls/order9.xml
Normal 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>
|
||||||
41813
local-sync/xmls/public-message.xml
Normal file
41813
local-sync/xmls/public-message.xml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
|||||||
103
ostukorv-order-xml-ferritiin+kliiniline_vereanalyys.xml
Normal file
103
ostukorv-order-xml-ferritiin+kliiniline_vereanalyys.xml
Normal 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>
|
||||||
126
ostukorv-order-xml-premiumpakett3tootega.xml
Normal file
126
ostukorv-order-xml-premiumpakett3tootega.xml
Normal 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>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { z } from 'zod';
|
|||||||
export const UpdateHealthBenefitSchema = z.object({
|
export const UpdateHealthBenefitSchema = z.object({
|
||||||
occurance: z
|
occurance: z
|
||||||
.string({
|
.string({
|
||||||
required_error: 'Occurance is required',
|
error: 'Occurance is required',
|
||||||
})
|
})
|
||||||
.nonempty(),
|
.nonempty(),
|
||||||
amount: z.number({ required_error: 'Amount is required' }),
|
amount: z.number({ error: 'Amount is required' }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ export const MontonioServerEnvSchema = z
|
|||||||
.object({
|
.object({
|
||||||
secretKey: z
|
secretKey: z
|
||||||
.string({
|
.string({
|
||||||
required_error: `Please provide the variable MONTONIO_SECRET_KEY`,
|
error: `Please provide the variable MONTONIO_SECRET_KEY`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
apiUrl: z
|
apiUrl: z
|
||||||
.string({
|
.string({
|
||||||
required_error: `Please provide the variable MONTONIO_API_URL`,
|
error: `Please provide the variable MONTONIO_API_URL`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ export const StripeServerEnvSchema = z
|
|||||||
.object({
|
.object({
|
||||||
secretKey: z
|
secretKey: z
|
||||||
.string({
|
.string({
|
||||||
required_error: `Please provide the variable STRIPE_SECRET_KEY`,
|
error: `Please provide the variable STRIPE_SECRET_KEY`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
webhooksSecret: z
|
webhooksSecret: z
|
||||||
.string({
|
.string({
|
||||||
required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
|
error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
|
||||||
})
|
})
|
||||||
.min(1),
|
.min(1),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
renderAllResultsReceivedEmail,
|
||||||
|
renderFirstResultsReceivedEmail,
|
||||||
|
} from '@kit/email-templates';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAssignedDoctorAccount,
|
||||||
|
getDoctorAccounts,
|
||||||
|
} from '../../../../../lib/services/account.service';
|
||||||
|
import {
|
||||||
|
NotificationAction,
|
||||||
|
createNotificationLog,
|
||||||
|
} from '../../../../../lib/services/audit/notificationEntries.service';
|
||||||
|
import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service';
|
||||||
import { RecordChange, Tables } from '../record-change.type';
|
import { RecordChange, Tables } from '../record-change.type';
|
||||||
|
|
||||||
export function createDatabaseWebhookRouterService(
|
export function createDatabaseWebhookRouterService(
|
||||||
@@ -42,6 +55,12 @@ class DatabaseWebhookRouterService {
|
|||||||
return this.handleAccountsWebhook(payload);
|
return this.handleAccountsWebhook(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'analysis_orders': {
|
||||||
|
const payload = body as RecordChange<typeof body.table>;
|
||||||
|
|
||||||
|
return this.handleAnalysisOrdersWebhook(payload);
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,4 +102,69 @@ class DatabaseWebhookRouterService {
|
|||||||
return service.handleAccountDeletedWebhook(body.old_record);
|
return service.handleAccountDeletedWebhook(body.old_record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleAnalysisOrdersWebhook(
|
||||||
|
body: RecordChange<'analysis_orders'>,
|
||||||
|
) {
|
||||||
|
if (body.type === 'UPDATE' && body.record && body.old_record) {
|
||||||
|
const { record, old_record } = body;
|
||||||
|
|
||||||
|
if (record.status === old_record.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let action;
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
analysisOrderId: record.id,
|
||||||
|
language: 'et',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
|
||||||
|
action = NotificationAction.NEW_JOBS_ALERT;
|
||||||
|
|
||||||
|
const doctorAccounts = await getDoctorAccounts();
|
||||||
|
const doctorEmails: string[] = doctorAccounts
|
||||||
|
.map(({ email }) => email)
|
||||||
|
.filter((email): email is string => !!email);
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderFirstResultsReceivedEmail,
|
||||||
|
data,
|
||||||
|
doctorEmails,
|
||||||
|
);
|
||||||
|
} else if (record.status === 'FULL_ANALYSIS_RESPONSE') {
|
||||||
|
action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT;
|
||||||
|
const doctorAccount = await getAssignedDoctorAccount(record.id);
|
||||||
|
const assignedDoctorEmail = doctorAccount?.email;
|
||||||
|
|
||||||
|
if (!assignedDoctorEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderAllResultsReceivedEmail,
|
||||||
|
data,
|
||||||
|
assignedDoctorEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
await createNotificationLog({
|
||||||
|
action,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
relatedRecordId: record.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (action)
|
||||||
|
await createNotificationLog({
|
||||||
|
action,
|
||||||
|
status: 'FAIL',
|
||||||
|
comment: e?.message,
|
||||||
|
relatedRecordId: record.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { DatabaseWebhookVerifierService } from './database-webhook-verifier.serv
|
|||||||
|
|
||||||
const webhooksSecret = z
|
const webhooksSecret = z
|
||||||
.string({
|
.string({
|
||||||
description: `The secret used to verify the webhook signature`,
|
error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
|
||||||
required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
|
|
||||||
})
|
})
|
||||||
|
.describe(`The secret used to verify the webhook signature`,)
|
||||||
.min(1)
|
.min(1)
|
||||||
.parse(process.env.SUPABASE_DB_WEBHOOK_SECRET);
|
.parse(process.env.SUPABASE_DB_WEBHOOK_SECRET);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
render
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
|
import { EmailContent } from '../components/content';
|
||||||
|
import { EmailButton } from '../components/email-button';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderAllResultsReceivedEmail({
|
||||||
|
language,
|
||||||
|
analysisOrderId,
|
||||||
|
}: {
|
||||||
|
language: string;
|
||||||
|
analysisOrderId: number;
|
||||||
|
}) {
|
||||||
|
const namespace = 'all-results-received-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`);
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`);
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`)}
|
||||||
|
</Text>
|
||||||
|
<EmailButton
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`)}
|
||||||
|
</EmailButton>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,16 +19,14 @@ import { initializeEmailI18n } from '../lib/i18n';
|
|||||||
|
|
||||||
export async function renderDoctorSummaryReceivedEmail({
|
export async function renderDoctorSummaryReceivedEmail({
|
||||||
language,
|
language,
|
||||||
recipientEmail,
|
|
||||||
recipientName,
|
recipientName,
|
||||||
orderNr,
|
orderNr,
|
||||||
orderId,
|
analysisOrderId,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
recipientEmail: string;
|
|
||||||
orderNr: string;
|
orderNr: string;
|
||||||
orderId: number;
|
analysisOrderId: number;
|
||||||
}) {
|
}) {
|
||||||
const namespace = 'doctor-summary-received-email';
|
const namespace = 'doctor-summary-received-email';
|
||||||
|
|
||||||
@@ -37,8 +35,6 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
namespace: [namespace, 'common'],
|
namespace: [namespace, 'common'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const to = recipientEmail;
|
|
||||||
|
|
||||||
const previewText = t(`${namespace}:previewText`, {
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
orderNr,
|
orderNr,
|
||||||
});
|
});
|
||||||
@@ -73,13 +69,13 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<EmailButton
|
<EmailButton
|
||||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||||
>
|
>
|
||||||
{t(`${namespace}:linkText`, { orderNr })}
|
{t(`${namespace}:linkText`, { orderNr })}
|
||||||
</EmailButton>
|
</EmailButton>
|
||||||
<Text>
|
<Text>
|
||||||
{t(`${namespace}:ifButtonDisabled`)}{' '}
|
{t(`${namespace}:ifButtonDisabled`)}{' '}
|
||||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||||
</Text>
|
</Text>
|
||||||
<CommonFooter t={t} />
|
<CommonFooter t={t} />
|
||||||
</EmailContent>
|
</EmailContent>
|
||||||
@@ -92,6 +88,5 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
return {
|
return {
|
||||||
html,
|
html,
|
||||||
subject,
|
subject,
|
||||||
to,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
render,
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
|
import { EmailContent } from '../components/content';
|
||||||
|
import { EmailButton } from '../components/email-button';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderFirstResultsReceivedEmail({
|
||||||
|
language,
|
||||||
|
analysisOrderId,
|
||||||
|
}: {
|
||||||
|
language: string;
|
||||||
|
analysisOrderId: number;
|
||||||
|
}) {
|
||||||
|
const namespace = 'first-results-received-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`);
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`);
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:resultsReceivedForOrders`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<EmailButton
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`)}
|
||||||
|
</EmailButton>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
render
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
|
import { EmailContent } from '../components/content';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderNewJobsAvailableEmail({
|
||||||
|
language,
|
||||||
|
analysisResponseIds,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
analysisResponseIds: number[];
|
||||||
|
}) {
|
||||||
|
const namespace = 'new-jobs-available-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:resultsReceivedForOrders`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<ul className="list-none text-[16px] leading-[24px]">
|
||||||
|
{analysisResponseIds.map((analysisResponseId, index) => (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
key={analysisResponseId}
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`, { nr: index + 1 })}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/open-jobs`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export async function renderOtpEmail(props: Props) {
|
|||||||
|
|
||||||
<Section className="mb-[16px] mt-[16px] text-center">
|
<Section className="mb-[16px] mt-[16px] text-center">
|
||||||
<Button className={'w-full rounded bg-neutral-950 text-center'}>
|
<Button className={'w-full rounded bg-neutral-950 text-center'}>
|
||||||
<Text className="text-[16px] font-medium font-semibold leading-[16px] text-white">
|
<Text className="text-[16px] font-semibold leading-[16px] text-white">
|
||||||
{props.otp}
|
{props.otp}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user