36 Commits

Author SHA1 Message Date
a89d8d3153 fix whitespace 2025-09-08 00:57:18 +03:00
1b29cb222b prefer pathsConfig urls 2025-09-08 00:56:55 +03:00
dfcfdb8f97 Merge pull request #76 from MR-medreport/improvements-0609
update order xml for live, allow adding discounts in cart
2025-09-06 19:58:59 +00:00
d87d08aaea Merge pull request #75 from MR-medreport/main
main <-> develop
2025-09-06 19:57:59 +00:00
c83694222d allow adding discounts in cart 2025-09-06 22:56:54 +03:00
c08fe26b36 remove comments from order xml 2025-09-05 15:13:14 +03:00
d3202a2cb2 remove comments from order xml 2025-09-05 15:12:58 +03:00
2435e6f113 update medipost order xml for live 2025-09-05 15:09:27 +03:00
54856b0e45 update medipost order xml for live 2025-09-05 15:09:11 +03:00
95e72bb3f8 log out of medusa and reset cart on supabase logout 2025-09-05 14:15:03 +03:00
3d268b6061 retry initial dockerfile 2025-09-05 14:09:02 +03:00
5c6280ec42 retry updated dockerfile 2025-09-05 14:01:22 +03:00
0de9dcf7e3 retry 2025-09-05 13:49:52 +03:00
f3a6fb627c react compiler uses too much memory 2025-09-05 13:39:04 +03:00
c1746c6c20 retry 2025-09-05 13:20:17 +03:00
c6f56f6e11 retry pipeline with updated parameters 2025-09-05 12:54:58 +03:00
a6b246cdf3 improve dockerfile 2025-09-05 12:52:06 +03:00
a705dea9cf retry 2025-09-05 12:19:00 +03:00
8485d2e9a3 retry 2025-09-05 12:11:34 +03:00
c356f69656 retry 2025-09-05 12:03:26 +03:00
cacd23be40 layer cache before envs 2025-09-05 11:53:39 +03:00
f8765dce49 update dockerfile 2025-09-05 11:44:25 +03:00
42bebb6d93 all needed variables in buildtime 2025-09-05 11:39:12 +03:00
354a0c04ee prefer to use env from parameter store 2025-09-05 01:44:05 +03:00
72bb9a33ef Merge branch 'develop' 2025-09-05 01:39:25 +03:00
771c28f8ef rerun codepipeline 2025-09-04 17:07:52 +03:00
Danel Kungla
e9497c3d52 update staging env 2025-09-04 14:26:21 +03:00
70188f297f Merge pull request #73 from MR-medreport/develop
develop -> main
2025-09-04 10:42:33 +00:00
e0940a1600 allow transferCart to fail on register 2025-09-04 13:41:21 +03:00
65eb6c780d allow transferCart to fail on login/register 2025-09-04 13:20:12 +03:00
6e9cde6b95 medusa product can have either analysiselement or analysis originalId 2025-09-04 13:20:09 +03:00
3a062eaa9c hide dashboard recommendations block 2025-09-04 13:20:05 +03:00
c07acb85a2 fix tooltip should wrap long text 2025-09-04 13:20:02 +03:00
1de564b917 rerun codepipeline 2025-09-03 15:15:02 +03:00
a2c080914a Merge branch 'develop' 2025-09-03 13:32:15 +03:00
Danel Kungla
94dd00b9ca Add data.sql to .gitignore 2025-08-29 18:05:29 +03:00
80 changed files with 611 additions and 53298 deletions

View File

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

5
.env
View File

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

View File

@@ -3,7 +3,6 @@
# SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_PASSWORD=true
# SUPABASE DEVELOPMENT
@@ -26,65 +25,14 @@ EMAIL_PORT=1025 # or 465 for SSL
EMAIL_TLS=false
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_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
#MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
#MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_827a2ab863021cb67993f1d81078f81bfce4b4e0da642d8c0f5398ded9d8fd32
#MEDUSA_BACKEND_URL=https://backoffice.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice.medreport.ee
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_068d930c33fea53608a410d84a51935f6ce2ccec5bef8e0ecf75eaee602ac486
#MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
#MEDUSA_BACKEND_PUBLIC_URL=http://5.181.51.38:9000
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
#NEXT_PUBLIC_MONTONIO_ACCESS_KEY=13e3686a-e7ad-41f6-998b-3f7d7de17654
#MONTONIO_SECRET_KEY=wTd4BZ01h80KZLMPL4mjt0RCFxKaYRSu9mMB1PQZCxnw
#MONTONIO_API_URL=https://stargate.montonio.com
# JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1Njk4NDYyOCwiZXhwIjoyMDcyNTYwNjI4fQ.1UZR7AqSD9bOy1gtZRGhOCNoESsw2W-DoFDDsNNMwoE
#NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
#NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
#SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
#NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
#NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
#SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
### TEST.MEDREPORT.ee ###
DB_PASSWORD=T#u-$M7%RjbA@L@

View File

@@ -6,10 +6,10 @@
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
# NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
# # MONTONIO
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

View File

@@ -6,10 +6,10 @@
## 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_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
# # MONTONIO
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

View File

@@ -22,12 +22,12 @@ COPY . .
ENV NODE_ENV=production
RUN set -a \
&& . .env \
&& . .env.production \
&& . .env.staging \
&& set +a \
&& node check-env.js \
&& pnpm build
&& . .env \
&& . .env.production \
&& . .env.staging \
&& set +a \
&& node check-env.js \
&& pnpm build
# --- Stage 2: Runtime ---
@@ -41,13 +41,13 @@ COPY --from=builder /app ./
RUN cp ".env.${APP_ENV}" .env.local
RUN npm install -g pnpm@9 \
&& pnpm install --prod --frozen-lockfile
&& pnpm install --prod --frozen-lockfile
ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug
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
EXPOSE 3000

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { z } from 'zod';
import parsePhoneNumber from 'libphonenumber-js/min';
export const UpdateAccountSchema = z.object({
firstName: z
@@ -24,20 +23,7 @@ export const UpdateAccountSchema = z.object({
.string({
error: 'Phone number is required',
})
.nonempty()
.refine(
(phone) => {
try {
const phoneNumber = parsePhoneNumber(phone);
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPhoneNumber',
}
),
.nonempty(),
city: z.string().optional(),
weight: z
.number({

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client"
import { Badge, Heading, Text } from "@medusajs/ui"
import { Badge, Text } from "@medusajs/ui"
import { toast } from '@kit/ui/sonner';
import React, { useActionState } from "react";
import { applyPromotions, submitPromotionForm } from "@lib/data/cart"
@@ -31,11 +32,19 @@ export default function DiscountCode({ cart }: {
const removePromotionCode = async (code: string) => {
const validPromotions = promotions.filter(
(promotion) => promotion.code !== code
(promotion) => promotion.code !== code,
)
await applyPromotions(
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!)
validPromotions.filter((p) => p.code === undefined).map((p) => p.code!),
{
onSuccess: () => {
toast.success(t('cart:discountCode.removeSuccess'));
},
onError: () => {
toast.error(t('cart:discountCode.removeError'));
},
}
)
}
@@ -45,7 +54,14 @@ export default function DiscountCode({ cart }: {
.map((p) => p.code!)
codes.push(code.toString())
await applyPromotions(codes)
await applyPromotions(codes, {
onSuccess: () => {
toast.success(t('cart:discountCode.addSuccess'));
},
onError: () => {
toast.error(t('cart:discountCode.addError'));
},
});
form.reset()
}
@@ -64,7 +80,7 @@ export default function DiscountCode({ cart }: {
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
className="w-full mb-2 flex gap-x-2"
className="w-full mb-2 flex gap-x-2 sm:flex-row flex-col gap-y-2"
>
<FormField
name={'code'}
@@ -87,16 +103,12 @@ export default function DiscountCode({ cart }: {
</form>
</Form>
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
{promotions.length > 0 && (
<div className="w-full flex items-center">
<div className="flex flex-col w-full">
<Heading className="txt-medium mb-2">
Promotion(s) applied:
</Heading>
{promotions.length > 0 ? (
<div className="w-full flex items-center mt-4">
<div className="flex flex-col w-full gap-y-2">
<p>
<Trans i18nKey={'cart:discountCode.appliedCodes'} />
</p>
{promotions.map((promotion) => {
return (
@@ -110,6 +122,7 @@ export default function DiscountCode({ cart }: {
<Badge
color={promotion.is_automatic ? "green" : "grey"}
size="small"
className="px-4"
>
{promotion.code}
</Badge>{" "}
@@ -151,7 +164,7 @@ export default function DiscountCode({ cart }: {
>
<Trash size={14} />
<span className="sr-only">
Remove discount code from order
<Trans i18nKey={'cart:discountCode.remove'} />
</span>
</button>
)}
@@ -160,6 +173,10 @@ export default function DiscountCode({ cart }: {
})}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
)}
</div>
)

View File

@@ -17,9 +17,8 @@ import { formatCurrency } from "@/packages/shared/src/utils";
import { useTranslation } from "react-i18next";
import { handleNavigateToPayment } from "@/lib/services/medusaCart.service";
import AnalysisLocation from "./analysis-location";
import { composeOrderXML, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
const IS_DISCOUNT_SHOWN = false as boolean;
const IS_DISCOUNT_SHOWN = true as boolean;
export default function Cart({
cart,
@@ -70,7 +69,7 @@ export default function Cart({
const hasCartItems = Array.isArray(cart.items) && cart.items.length > 0;
const isLocationsShown = synlabAnalyses.length > 0;
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4">
<div className="flex flex-col bg-white gap-y-6">
@@ -78,28 +77,62 @@ export default function Cart({
<CartItems cart={cart} items={ttoServiceItems} productColumnLabelKey="cart:items.ttoServices.productColumnLabel" />
</div>
{hasCartItems && (
<div className="flex justify-end gap-x-4 px-6 py-4">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm">
<Trans i18nKey="cart:total" />
</p>
<>
<div className="flex justify-end gap-x-4 px-6 pt-4">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<Trans i18nKey="cart:subtotal" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
<div className="flex justify-end gap-x-4 px-6 py-2">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm text-muted-foreground">
<Trans i18nKey="cart:promotionsTotal" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-x-4 px-6">
<div className="mr-[36px]">
<p className="ml-0 font-bold text-sm">
<Trans i18nKey="cart:total" />
</p>
</div>
<div className="mr-[116px]">
<p className="text-sm">
{formatCurrency({
value: cart.total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
</>
)}
<div className="flex gap-y-6 py-8">
<div className="flex sm:flex-row flex-col gap-y-6 py-8 gap-x-4">
{IS_DISCOUNT_SHOWN && (
<Card
className="flex flex-col justify-between w-1/2"
className="flex flex-col justify-between w-full sm:w-1/2"
>
<CardHeader className="pb-4">
<h5>
@@ -114,7 +147,7 @@ export default function Cart({
{isLocationsShown && (
<Card
className="flex flex-col justify-between w-1/2"
className="flex flex-col justify-between w-full sm:w-1/2"
>
<CardHeader className="pb-4">
<h5>
@@ -134,26 +167,6 @@ export default function Cart({
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>
<Button type='button' onClick={async () => {
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder: { items: cart.items ?? [] } });
const xml = await composeOrderXML({
person: {
idCode: '1234567890',
firstName: 'John',
lastName: 'Doe',
phone: '1234567890',
},
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: '1234567890',
orderCreatedAt: new Date(),
});
console.log('test', { items: cart.items, ids: orderedAnalysisElementsIds, xml });
console.log('test', xml);
}}>
Test
</Button>
</div>
);
}

View File

@@ -84,7 +84,7 @@ const cards = ({
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0)?.toString() ?? '-',
description: bmiFromMetric(weight || 0, height || 0).toString(),
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -2,7 +2,7 @@ import type { Tables } from '@/packages/supabase/src/database.types';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { IUuringElement } from "./medipost.types";
export type AnalysesWithGroupsAndElements = ({
type AnalysesWithGroupsAndElements = ({
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};

View File

@@ -10,6 +10,7 @@ import {
getClientInstitution,
getClientPerson,
getConfidentiality,
getOrderEnteredPerson,
getPais,
getPatient,
getProviderInstitution,
@@ -37,10 +38,10 @@ import { Tables } from '@kit/supabase/database';
import { createAnalysisGroup } from './analysis-group.service';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { getOrder, updateOrderStatus } from './order.service';
import { AnalysisElement, getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { AnalysesWithGroupsAndElements, getAnalyses } from './analyses.service';
import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service';
import { getAnalyses } from './analyses.service';
import { getAccountAdmin } from './account.service';
import { StoreOrder, StoreOrderLineItem } from '@medusajs/types';
import { StoreOrder } from '@medusajs/types';
import { listProducts } from '@lib/data/products';
import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
@@ -480,60 +481,72 @@ export async function composeOrderXML({
throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`);
}
const uniques = [
...analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ?? [],
...analyses?.flatMap(({ analysis_elements }) => analysis_elements.analysis_groups) ?? []
];
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
uniqBy(uniques, 'id');
console.log('analysisGroups', { analysisGroups, uniques });
uniqBy(
(
analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ??
[]
).concat(
analyses?.flatMap(
({ analysis_elements }) => analysis_elements.analysis_groups,
) ?? [],
),
'id',
);
const specimenSection = [];
const analysisSection = [];
let order = 1;
for (const currentGroup of analysisGroups) {
const relatedAnalysisElements = await getRelatedAnalysisElements({
analysisElements,
analyses,
currentGroup,
let relatedAnalysisElement = analysisElements?.find(
(element) => element.analysis_groups.id === currentGroup.id,
);
const relatedAnalyses = analyses?.filter((analysis) => {
return analysis.analysis_elements.analysis_groups.id === currentGroup.id;
});
for (const relatedAnalysisElement of relatedAnalysisElements) {
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
throw new Error(
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
);
}
for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
const materials = toArray(group.Materjal);
const specimenXml = materials.flatMap(
({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => {
return toArray(Konteiner).map((container) =>
getSpecimen(
MaterjaliTyypOID,
MaterjaliTyyp,
MaterjaliNimi,
order,
container.ProovinouKoodOID,
container.ProovinouKood,
),
);
},
);
specimenSection.push(...specimenXml);
}
const groupXml = getAnalysisGroup(
currentGroup.original_id,
currentGroup.name,
order,
relatedAnalysisElement,
);
order++;
analysisSection.push(groupXml);
if (!relatedAnalysisElement) {
relatedAnalysisElement = relatedAnalyses?.find(
(relatedAnalysis) =>
relatedAnalysis.analysis_elements.analysis_groups.id ===
currentGroup.id,
)?.analysis_elements;
}
if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) {
throw new Error(
`Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`,
);
}
for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) {
const materials = toArray(group.Materjal);
const specimenXml = materials.flatMap(
({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => {
return toArray(Konteiner).map((container) =>
getSpecimen(
MaterjaliTyypOID,
MaterjaliTyyp,
MaterjaliNimi,
order,
container.ProovinouKoodOID,
container.ProovinouKood,
),
);
},
);
specimenSection.push(...specimenXml);
}
const groupXml = getAnalysisGroup(
currentGroup.original_id,
currentGroup.name,
order,
relatedAnalysisElement,
);
order++;
analysisSection.push(groupXml);
}
return `<?xml version="1.0" encoding="UTF-8"?>
@@ -541,14 +554,12 @@ export async function composeOrderXML({
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
<Tellimus cito="EI">
<ValisTellimuseId>${orderId}</ValisTellimuseId>
<!--<TellijaAsutus>-->
${getClientInstitution()}
<!--<TeostajaAsutus>-->
${getProviderInstitution()}
<!--<TellijaIsik>-->
${getClientPerson(person)}
${getClientPerson()}
${getOrderEnteredPerson()}
<TellijaMarkused>${comment ?? ''}</TellijaMarkused>
${getPatient(person)}
${getPatient(person)}
${getConfidentiality()}
${specimenSection.join('')}
${analysisSection?.join('')}
@@ -683,7 +694,7 @@ async function syncPrivateMessage({
);
}
const { data: allOrderResponseElements } = await supabase
const { data: allOrderResponseElements} = await supabase
.schema('medreport')
.from('analysis_response_elements')
.select('*')
@@ -772,13 +783,10 @@ export async function sendOrderToMedipost({
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
}
type OrderItems = {
items: Pick<StoreOrderLineItem, 'product'>[];
}
export async function getOrderedAnalysisIds({
medusaOrder,
}: {
medusaOrder: OrderItems;
medusaOrder: StoreOrder;
}): Promise<{
analysisElementId?: number;
analysisId?: number;
@@ -786,7 +794,7 @@ export async function getOrderedAnalysisIds({
const countryCodes = await listRegions();
const countryCode = countryCodes[0]!.countries![0]!.iso_2!;
async function getOrderedAnalysisElements(medusaOrder: OrderItems) {
async function getOrderedAnalysisElements(medusaOrder: StoreOrder) {
const originalIds = (medusaOrder?.items ?? [])
.map((a) => a.product?.metadata?.analysisIdOriginal)
.filter((a) => typeof a === 'string') as string[];
@@ -794,7 +802,7 @@ export async function getOrderedAnalysisIds({
return analysisElements.map(({ id }) => ({ analysisElementId: id }));
}
async function getOrderedAnalyses(medusaOrder: OrderItems) {
async function getOrderedAnalyses(medusaOrder: StoreOrder) {
const originalIds = (medusaOrder?.items ?? [])
.map((a) => a.product?.metadata?.analysisIdOriginal)
.filter((a) => typeof a === 'string') as string[];
@@ -802,7 +810,7 @@ export async function getOrderedAnalysisIds({
return analyses.map(({ id }) => ({ analysisId: id }));
}
async function getOrderedAnalysisPackages(medusaOrder: OrderItems) {
async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) {
const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX));
const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[];
if (orderedPackageIds.length === 0) {
@@ -859,10 +867,10 @@ export async function createMedipostActionLog({
hasError = false,
}: {
action:
| 'send_order_to_medipost'
| 'sync_analysis_results_from_medipost'
| 'send_fake_analysis_results_to_medipost'
| 'send_analysis_results_to_medipost';
| 'send_order_to_medipost'
| 'sync_analysis_results_from_medipost'
| 'send_fake_analysis_results_to_medipost'
| 'send_analysis_results_to_medipost';
xml: string;
hasAnalysisResults?: boolean;
medusaOrderId?: string | null;
@@ -883,39 +891,3 @@ export async function createMedipostActionLog({
.select('id')
.throwOnError();
}
async function getRelatedAnalysisElements({
analysisElements,
analyses,
currentGroup,
}: {
analysisElements: AnalysisElement[];
analyses: AnalysesWithGroupsAndElements;
currentGroup: {
created_at: string;
id: number;
name: string;
order: number;
original_id: string;
updated_at: string | null;
};
}) {
const relatedAnalysisElements: AnalysisElement[] = [];
const related1 = analysisElements?.filter(
(element) => element.analysis_groups.id === currentGroup.id,
);
if (related1) {
relatedAnalysisElements.push(...related1);
}
const related2 = analyses
?.filter(({ analysis_elements }) => analysis_elements.analysis_groups.id === currentGroup.id)
?.filter(({ analysis_elements }) => analysis_elements.analysis_groups.id === currentGroup.id)
?.flatMap(({ analysis_elements }) => analysis_elements);
if (related2) {
relatedAnalysisElements.push(...related2);
}
return relatedAnalysisElements;
}

View File

@@ -3,6 +3,7 @@
import {
getClientInstitution,
getClientPerson,
getOrderEnteredPerson,
getPais,
getPatient,
getProviderInstitution,
@@ -104,7 +105,8 @@ export async function composeOrderTestResponseXML({
<ValisTellimuseId>${orderId}</ValisTellimuseId>
${getClientInstitution({ index: 1 })}
${getProviderInstitution({ index: 1 })}
${getClientPerson(person)}
${getClientPerson()}
${getOrderEnteredPerson()}
<TellijaMarkused>Siia tuleb tellija poolne märkus</TellijaMarkused>
${getPatient(person)}

View File

@@ -110,7 +110,7 @@ export async function handleNavigateToPayment({
const paymentLink =
await new MontonioOrderHandlerService().getMontonioPaymentLink({
notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`,
returnUrl: `${"https://webhook.site"}/home/cart/montonio-callback`,
returnUrl: `${env().siteUrl}/home/cart/montonio-callback`,
amount: cart.total,
currency: cart.currency_code.toUpperCase(),
description: `Order from Medreport`,

View File

@@ -21,70 +21,48 @@ export const getPais = (
<Saaja>${recipient}</Saaja>
<Aeg>${format(createdAt, DATE_TIME_FORMAT)}</Aeg>
<SaadetisId>${orderId}</SaadetisId>
<Email>argo@medreport.ee</Email>
<Email>info@medreport.ee</Email>
</Pais>`;
};
export const getClientInstitution = ({ index }: { index?: number } = {}) => {
if (isProd) {
// return correct data
}
return `<Asutus tyyp="TELLIJA" ${index ? ` jarjenumber="${index}"` : ''}>
<AsutuseId>16381793</AsutuseId>
<AsutuseNimi>MedReport OÜ</AsutuseNimi>
<AsutuseKood>TSU</AsutuseKood>
<AsutuseKood>MRP</AsutuseKood>
<Telefon>+37258871517</Telefon>
</Asutus>`;
};
export const getProviderInstitution = ({ index }: { index?: number } = {}) => {
if (isProd) {
// return correct data
}
return `<Asutus tyyp="TEOSTAJA" ${index ? ` jarjenumber="${index}"` : ''}>
<AsutuseId>11107913</AsutuseId>
<AsutuseNimi>Synlab HTI Tallinn</AsutuseNimi>
<AsutuseKood>SLA</AsutuseKood>
<AsutuseNimi>Synlab Eesti OÜ</AsutuseNimi>
<AsutuseKood>HTI</AsutuseKood>
<AllyksuseNimi>Synlab HTI Tallinn</AllyksuseNimi>
<Telefon>+3723417123</Telefon>
<Telefon>+37217123</Telefon>
</Asutus>`;
};
export const getClientPerson = ({
idCode,
firstName,
lastName,
phone,
}: {
idCode: string,
firstName: string,
lastName: string,
phone: string,
}) => {
if (isProd) {
// return correct data
}
export const getClientPerson = () => {
return `<Personal tyyp="TELLIJA" jarjenumber="1">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>${idCode}</PersonalKood>
<PersonalPerekonnaNimi>${lastName}</PersonalPerekonnaNimi>
<PersonalEesNimi>${firstName}</PersonalEesNimi>
${phone ? `<Telefon>${phone.startsWith('+372') ? phone : `+372${phone}`}</Telefon>` : ''}
<PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
<Telefon>+37258131202</Telefon>
</Personal>`;
};
// export const getOrderEnteredPerson = () => {
// if (isProd) {
// // return correct data
// }
// return `<Personal tyyp="SISESTAJA" jarjenumber="1">
// <PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
// <PersonalKood>D07907</PersonalKood>
// <PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
// <PersonalEesNimi>Tsvetkov</PersonalEesNimi>
// <Telefon>+37258131202</Telefon>
// </Personal>`;
// };
export const getOrderEnteredPerson = () => {
return `<Personal tyyp="SISESTAJA" jarjenumber="2">
<PersonalOID>1.3.6.1.4.1.28284.6.2.4.9</PersonalOID>
<PersonalKood>D07907</PersonalKood>
<PersonalPerekonnaNimi>Eduard</PersonalPerekonnaNimi>
<PersonalEesNimi>Tsvetkov</PersonalEesNimi>
<Telefon>+37258131202</Telefon>
</Personal>`;
};
export const getPatient = ({
idCode,

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@ const config = {
},
experimental: {
mdxRs: true,
reactCompiler: ENABLE_REACT_COMPILER,
reactCompiler: false,
optimizePackageImports: [
'recharts',
'lucide-react',

View File

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

View File

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

View File

@@ -69,7 +69,6 @@
"fast-xml-parser": "^5.2.5",
"isikukood": "3.1.7",
"jsonwebtoken": "9.0.2",
"libphonenumber-js": "^1.12.15",
"lodash": "^4.17.21",
"lucide-react": "^0.510.0",
"next": "15.3.2",

View File

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

View File

@@ -48,41 +48,6 @@ class AccountsApi {
return data;
}
/**
* @name getPersonalAccountByUserId
* @description Get the personal account data for the given user ID.
* @param userId
*/
async getPersonalAccountByUserId(userId: string): Promise<AccountWithParams> {
const { data, error } = await this.client
.schema('medreport')
.from('accounts')
.select(
'*, accountParams: account_params (weight, height, isSmoker:is_smoker)',
)
.eq('primary_owner_user_id', userId)
.eq('is_personal_account', true)
.single();
if (error) {
throw error;
}
const { personal_code, ...rest } = data;
return {
...rest,
personal_code: (() => {
if (!personal_code) {
return null;
}
if (personal_code.toLowerCase().startsWith('ee')) {
return personal_code.substring(2);
}
return personal_code;
})(),
};
}
/**
* @name getAccountWorkspace
* @description Get the account workspace data.

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,7 +87,10 @@ export async function getOrSetCart(countryCode: string) {
return cart;
}
export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & { id?: string }) {
export async function updateCart(
{ id, ...data }: HttpTypes.StoreUpdateCart & { id?: string },
{ onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} },
) {
const cartId = id || (await getCartId());
if (!cartId) {
@@ -109,9 +112,13 @@ export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & {
const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag);
onSuccess();
return cart;
})
.catch(medusaError);
.catch((e) => {
onError();
return medusaError(e);
});
}
export async function addToCart({
@@ -259,7 +266,10 @@ export async function initiatePaymentSession(
.catch(medusaError);
}
export async function applyPromotions(codes: string[]) {
export async function applyPromotions(
codes: string[],
{ onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} },
) {
const cartId = await getCartId();
if (!cartId) {
@@ -278,8 +288,13 @@ export async function applyPromotions(codes: string[]) {
const fulfillmentCacheTag = await getCacheTag("fulfillment");
revalidateTag(fulfillmentCacheTag);
onSuccess();
})
.catch(medusaError);
.catch((e) => {
onError();
return medusaError(e);
});
}
export async function applyGiftCard(code: string) {
@@ -427,7 +442,7 @@ export async function placeOrder(cartId?: string, options: { revalidateCacheTags
} else {
throw new Error("Cart is not an order");
}
return retrieveOrder(cartRes.order.id);
}

View File

@@ -4,6 +4,7 @@ import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
@@ -126,7 +127,7 @@ export async function login(_currentState: unknown, formData: FormData) {
}
}
export async function medusaLogout(countryCode = 'ee') {
export async function signout(countryCode?: string, shouldRedirect = true) {
await sdk.auth.logout()
await removeAuthToken()
@@ -138,6 +139,10 @@ export async function medusaLogout(countryCode = 'ee') {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
if (shouldRedirect) {
redirect(`/${countryCode!}/account`)
}
}
export async function transferCart() {
@@ -257,110 +262,72 @@ export const updateCustomerAddress = async (
})
}
async function medusaLogin(email: string, password: string) {
const token = await sdk.auth.login("customer", "emailpass", { email, password });
await setAuthToken(token as string);
try {
await transferCart();
} catch (e) {
console.error("Failed to transfer cart", e);
}
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found for active session");
}
return customer.id;
}
async function medusaRegister({
email,
password,
name,
lastName,
}: {
email: string;
password: string;
name: string | undefined;
lastName: string | undefined;
}) {
console.info(`Creating new Medusa account for Keycloak user with email=${email}`);
const registerToken = await sdk.auth.register("customer", "emailpass", { email, password });
await setAuthToken(registerToken);
console.info(`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`);
await sdk.store.customer.create(
{ email, first_name: name, last_name: lastName },
{},
{
...(await getAuthHeaders()),
});
}
export async function medusaLoginOrRegister(credentials: {
email: string
supabaseUserId?: string
name?: string,
lastName?: string,
} & ({ isDevPasswordLogin: true; password: string } | { isDevPasswordLogin?: false; password?: undefined })) {
const { email, supabaseUserId, name, lastName } = credentials;
const password = await (async () => {
if (credentials.isDevPasswordLogin) {
return credentials.password;
}
return generateDeterministicPassword(email, supabaseUserId);
})();
password?: string
}) {
const { email, password } = credentials;
try {
return await medusaLogin(email, password);
} catch (loginError) {
console.error("Failed to login customer, attempting to register", loginError);
const token = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(token as string);
try {
await medusaRegister({ email, password, name, lastName });
return await medusaLogin(email, password);
await transferCart();
} catch (e) {
console.error("Failed to transfer cart", e);
}
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
}
return customer.id;
} catch (error) {
console.error("Failed to login customer, attempting to register", error);
try {
const registerToken = await sdk.auth.register("customer", "emailpass", {
email: email,
password: password,
})
await setAuthToken(registerToken as string);
const headers = {
...(await getAuthHeaders()),
};
await sdk.store.customer.create({ email }, {}, headers);
const loginToken = await sdk.auth.login("customer", "emailpass", {
email,
password,
});
await setAuthToken(loginToken as string);
const customerCacheTag = await getCacheTag("customers");
revalidateTag(customerCacheTag);
try {
await transferCart();
} catch (e) {
console.error("Failed to transfer cart", e);
}
const customer = await retrieveCustomer();
if (!customer) {
throw new Error("Customer not found");
}
return customer.id;
} catch (registerError) {
console.error("Failed to create Medusa account for user with email=${email}", registerError);
throw medusaError(registerError);
}
}
}
/**
* Generate a deterministic password based on user identifier
* This ensures the same user always gets the same password for Medusa
*/
async function generateDeterministicPassword(email: string, userId?: string): Promise<string> {
// Use the user ID or email as the base for deterministic generation
const baseString = userId || email;
const secret = process.env.MEDUSA_PASSWORD_SECRET!;
// Create a deterministic password using HMAC
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(baseString);
// Import key for HMAC
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// Generate HMAC
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// Convert to base64 and make it a valid password
const hashArray = Array.from(new Uint8Array(signature));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// Take first 24 characters and add some complexity
const basePassword = hashHex.substring(0, 24);
// Add some required complexity for Medusa (uppercase, lowercase, numbers, symbols)
return `Mk${basePassword}9!`;
}

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ import type { User } from '@supabase/supabase-js';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { ApplicationRole } from '@kit/accounts/types/accounts';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { useUser } from '@kit/supabase/hooks/use-user';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useUser } from '@kit/supabase/hooks/use-user';
const paths = {
home: pathsConfig.app.home,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
import { signout } from '../../../features/medusa-storefront/src/lib/data/customer';
export function useSignOut() {
const client = useSupabase();
return useMutation({
mutationFn: async () => {
try {
try {
const { medusaLogout } = await import('../../../features/medusa-storefront/src/lib/data/customer');
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;
}
await signout(undefined, false);
return client.auth.signOut();
},
});
}

View File

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

57
pnpm-lock.yaml generated
View File

@@ -128,9 +128,6 @@ importers:
jsonwebtoken:
specifier: 9.0.2
version: 9.0.2
libphonenumber-js:
specifier: ^1.12.15
version: 1.12.15
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -478,10 +475,10 @@ importers:
dependencies:
'@keystatic/core':
specifier: 0.5.47
version: 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystatic/next':
specifier: ^5.0.4
version: 5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc':
specifier: ^0.5.1
version: 0.5.4(@types/react@19.1.4)(react@19.1.0)
@@ -1272,7 +1269,7 @@ importers:
dependencies:
'@sentry/nextjs':
specifier: ^9.19.0
version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)
import-in-the-middle:
specifier: 1.13.2
version: 1.13.2
@@ -8177,9 +8174,6 @@ packages:
engines: {node: '>=16'}
hasBin: true
libphonenumber-js@1.12.15:
resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==}
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@@ -11461,7 +11455,7 @@ snapshots:
'@juggle/resize-observer@3.4.0': {}
'@keystar/ui@0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@keystar/ui@0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@emotion/css': 11.13.5
@@ -11554,18 +11548,18 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- supports-color
'@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@braintree/sanitize-url': 6.0.4
'@emotion/weak-memoize': 0.3.1
'@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@internationalized/string': 3.2.7
'@keystar/ui': 0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystar/ui': 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0)
'@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -11636,13 +11630,13 @@ snapshots:
- next
- supports-color
'@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.6
'@keystatic/core': 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@keystatic/core': 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@types/react': 19.1.4
chokidar: 3.6.0
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
server-only: 0.0.1
@@ -17236,7 +17230,7 @@ snapshots:
'@sentry/core@9.46.0': {}
'@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
'@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.34.0
@@ -17249,7 +17243,7 @@ snapshots:
'@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
'@sentry/webpack-plugin': 3.5.0(webpack@5.101.3)
chalk: 3.0.0
next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
resolve: 1.22.8
rollup: 4.35.0
stacktrace-parser: 0.1.11
@@ -20636,8 +20630,6 @@ snapshots:
dependencies:
isomorphic.js: 0.2.5
libphonenumber-js@1.12.15: {}
lightningcss-darwin-arm64@1.30.1:
optional: true
@@ -21273,6 +21265,31 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.5.2
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001723
postcss: 8.4.31
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@19.1.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.2
'@next/swc-darwin-x64': 15.5.2
'@next/swc-linux-arm64-gnu': 15.5.2
'@next/swc-linux-arm64-musl': 15.5.2
'@next/swc-linux-x64-gnu': 15.5.2
'@next/swc-linux-x64-musl': 15.5.2
'@next/swc-win32-arm64-msvc': 15.5.2
'@next/swc-win32-x64-msvc': 15.5.2
'@opentelemetry/api': 1.9.0
babel-plugin-react-compiler: 19.1.0-rc.2
sharp: 0.34.3
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
no-case@3.0.4:
dependencies:
lower-case: 2.0.2

View File

@@ -68,7 +68,7 @@
"acceptTermsAndConditions": "I accept the <TermsOfServiceLink /> and <PrivacyPolicyLink />",
"termsOfService": "Terms of Service",
"privacyPolicy": "Privacy Policy",
"orContinueWith": "Continue with",
"orContinueWith": "Or continue with",
"redirecting": "You're in! Please wait...",
"errors": {
"Invalid login credentials": "The credentials entered are invalid",

View File

@@ -5,6 +5,7 @@
"emptyCartMessageDescription": "Add items to your cart to continue.",
"subtotal": "Subtotal",
"total": "Total",
"promotionsTotal": "Promotions total",
"table": {
"item": "Item",
"quantity": "Quantity",
@@ -24,10 +25,13 @@
"timeoutAction": "Continue"
},
"discountCode": {
"title": "Gift card or promotion code",
"label": "Add Promotion Code(s)",
"apply": "Apply",
"subtitle": "If you wish, you can add a promotion code",
"placeholder": "Enter promotion code"
"placeholder": "Enter promotion code",
"remove": "Remove promotion code",
"appliedCodes": "Promotion(s) applied:"
},
"items": {
"synlabAnalyses": {

View File

@@ -128,9 +128,6 @@
"amount": "Amount",
"selectDate": "Select date"
},
"formFieldError": {
"invalidPhoneNumber": "Please enter a valid Estonian phone number (must include country code +372)"
},
"wallet": {
"balance": "Your MedReport account balance",
"expiredAt": "Valid until {{expiredAt}}"

View File

@@ -68,7 +68,7 @@
"acceptTermsAndConditions": "Ma nõustun <TermsOfServiceLink /> ja <PrivacyPolicyLink />",
"termsOfService": "Kasutustingimused",
"privacyPolicy": "Privaatsuspoliitika",
"orContinueWith": "Jätka",
"orContinueWith": "Või jätka koos",
"redirecting": "Oled sees! Palun oota...",
"errors": {
"Invalid login credentials": "Sisestatud andmed on valed",

View File

@@ -4,6 +4,7 @@
"emptyCartMessage": "Sinu ostukorv on tühi",
"emptyCartMessageDescription": "Lisa tooteid ostukorvi, et jätkata.",
"subtotal": "Vahesumma",
"promotionsTotal": "Soodustuse summa",
"total": "Summa",
"table": {
"item": "Toode",
@@ -28,7 +29,13 @@
"label": "Lisa promo kood",
"apply": "Rakenda",
"subtitle": "Kui soovid, võid lisada promo koodi",
"placeholder": "Sisesta promo kood"
"placeholder": "Sisesta promo kood",
"remove": "Eemalda promo kood",
"appliedCodes": "Rakendatud sooduskoodid:",
"removeError": "Sooduskoodi eemaldamine ebaõnnestus",
"removeSuccess": "Sooduskood eemaldatud",
"addError": "Sooduskoodi rakendamine ebaõnnestus",
"addSuccess": "Sooduskood rakendatud"
},
"items": {
"synlabAnalyses": {

View File

@@ -128,9 +128,6 @@
"amount": "Summa",
"selectDate": "Vali kuupäev"
},
"formFieldError": {
"invalidPhoneNumber": "Palun sisesta Eesti telefoninumber (peab sisaldama riigikoodi +372)"
},
"wallet": {
"balance": "Sinu MedReporti konto saldo",
"expiredAt": "Kehtiv kuni {{expiredAt}}"

View File

@@ -68,7 +68,7 @@
"acceptTermsAndConditions": "Я принимаю <TermsOfServiceLink /> и <PrivacyPolicyLink />",
"termsOfService": "Условия использования",
"privacyPolicy": "Политика конфиденциальности",
"orContinueWith": "Продолжить через",
"orContinueWith": "Или продолжить через",
"redirecting": "Вы вошли! Пожалуйста, подождите...",
"errors": {
"Invalid login credentials": "Введенные данные недействительны",

8
run-test-sync-local.sh Executable file → Normal file
View File

@@ -1,6 +1,6 @@
#!/bin/bash
MEDUSA_ORDER_ID="order_01K2JFCZ609YF5G84ZKDCBWPM6"
MEDUSA_ORDER_ID="order_01K1TQQHZGPXKDHAH81TDSNGXR"
# HOSTNAME="https://test.medreport.ee"
# JOBS_API_TOKEN="fd26ec26-70ed-11f0-9e95-431ac3b15a84"
@@ -33,7 +33,7 @@ function sync_analysis_groups_store() {
# Requirements
# 1. Sync analysis groups from Medipost to B2B
#sync_analysis_groups
sync_analysis_groups
# 2. Optional - sync all Medipost analysis groups from B2B to Medusa (or add manually)
#sync_analysis_groups_store
@@ -41,7 +41,7 @@ function sync_analysis_groups_store() {
# 3. Set up products configurations in Medusa so B2B "Telli analüüs" page shows the product and you can do payment flow
# 4. After payment is done, run `send_medipost_test_response` to send the fake test results to Medipost
#send_medipost_test_response
# send_medipost_test_response
# 5. Run `sync_analysis_results` to sync the all new Medipost results to B2B
sync_analysis_results
# sync_analysis_results

View File

@@ -1,5 +0,0 @@
#!/bin/bash
docker build -t medreport-b2b:latest .
# Get size of built image and display it

View File

@@ -105,9 +105,9 @@ file_size_limit = "50MiB"
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:3000"
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000","http://127.0.0.1:3000","http://localhost:3000","http://localhost:3000/auth/callback", "http://localhost:3000/update-password"]
additional_redirect_urls = ["https://127.0.0.1:3000","http://localhost:3000/auth/callback", "http://localhost:3000/update-password"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
@@ -269,14 +269,6 @@ url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
[auth.external.keycloak]
enabled = true
client_id = "env(SUPABASE_AUTH_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_KEYCLOAK_SECRET)"
redirect_uri = "env(SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL)"
url = "env(SUPABASE_AUTH_KEYCLOAK_URL)"
skip_nonce_check = true
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false

View File

@@ -1,94 +0,0 @@
-- Update the user creation trigger to properly handle Keycloak user metadata
CREATE OR REPLACE FUNCTION kit.setup_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $$
DECLARE
user_name text;
picture_url text;
personal_code text;
full_name text;
given_name text;
family_name text;
preferred_username text;
BEGIN
-- Extract data from Keycloak user metadata
-- Check raw_user_meta_data first (this is where Keycloak data is stored)
IF new.raw_user_meta_data IS NOT NULL THEN
-- Try full_name first, then name field
full_name := new.raw_user_meta_data ->> 'full_name';
IF full_name IS NULL THEN
full_name := new.raw_user_meta_data ->> 'name';
END IF;
-- Extract individual name components
given_name := new.raw_user_meta_data -> 'custom_claims' ->> 'given_name';
family_name := new.raw_user_meta_data -> 'custom_claims' ->> 'family_name';
preferred_username := new.raw_user_meta_data -> 'custom_claims' ->> 'preferred_username';
-- Use given_name (first name) for the name field
IF given_name IS NOT NULL THEN
user_name := given_name;
ELSIF full_name IS NOT NULL THEN
user_name := full_name;
ELSIF preferred_username IS NOT NULL THEN
user_name := preferred_username;
END IF;
-- Extract personal code from preferred_username (Keycloak provides Estonian personal codes here)
IF preferred_username IS NOT NULL THEN
personal_code := preferred_username;
END IF;
-- Also try personalCode field as fallback
IF personal_code IS NULL THEN
personal_code := new.raw_user_meta_data ->> 'personalCode';
END IF;
END IF;
-- Fall back to email if no name found
IF user_name IS NULL AND new.email IS NOT NULL THEN
user_name := split_part(new.email, '@', 1);
END IF;
-- Default empty string if still no name
IF user_name IS NULL THEN
user_name := '';
END IF;
-- Extract picture URL
IF new.raw_user_meta_data ->> 'avatar_url' IS NOT NULL THEN
picture_url := new.raw_user_meta_data ->> 'avatar_url';
ELSE
picture_url := null;
END IF;
-- Insert into medreport.accounts
INSERT INTO medreport.accounts (
id,
primary_owner_user_id,
name,
last_name,
is_personal_account,
picture_url,
email,
personal_code,
application_role
)
VALUES (
new.id,
new.id,
user_name,
family_name,
true,
picture_url,
NULL, -- Keycloak email !== customer personal email, they will set this later
personal_code,
'user' -- Default role for new users
);
RETURN new;
END;
$$;

View File

@@ -1,64 +0,0 @@
-- Enable required extensions for cron jobs and HTTP requests
create extension if not exists pg_cron;
create extension if not exists pg_net;
-- Enable the cron extension to be used in all databases
-- This needs to be run with superuser privileges or via Supabase Dashboard
grant usage on schema cron to postgres;
grant all privileges on all tables in schema cron to postgres;
-- Function to safely schedule the cron job with environment variables
-- This approach allows for easier management and avoids hardcoding sensitive values
create or replace function schedule_sync_analysis_results_cron(
api_url text,
api_token text
) returns text as $$
declare
job_exists boolean;
begin
-- Check if job already exists
select exists(
select 1 from cron.job
where jobname = 'sync-analysis-results-every-15-minutes'
) into job_exists;
-- If job exists, unschedule it first
if job_exists then
perform cron.unschedule('sync-analysis-results-every-15-minutes');
end if;
-- Schedule the new job
perform cron.schedule(
'sync-analysis-results-every-15-minutes',
'*/15 * * * *', -- Every 15 minutes
format(
'select net.http_post(url := ''%s/api/job/sync-analysis-results'', headers := jsonb_build_object(''Content-Type'', ''application/json'', ''x-jobs-api-key'', ''%s''), body := jsonb_build_object(''triggered_by'', ''supabase_cron'', ''timestamp'', now())) as request_id;',
api_url, api_token
)
);
return 'Cron job scheduled successfully';
end;
$$ language plpgsql;
-- Example usage (replace with your actual values):
-- select schedule_sync_analysis_results_cron(
-- 'https://your-domain.com',
-- 'your-jobs-api-token-here'
-- );
-- Utility queries for managing the cron job:
-- 1. Check if the job is scheduled
-- select * from cron.job where jobname = 'sync-analysis-results-every-15-minutes';
-- 2. View job execution history
-- select * from cron.job_run_details
-- where jobid = (select jobid from cron.job where jobname = 'sync-analysis-results-every-15-minutes')
-- order by start_time desc limit 10;
-- 3. Unschedule the job if needed
-- select cron.unschedule('sync-analysis-results-every-15-minutes');
-- 4. Check all scheduled jobs
-- select jobid, schedule, active, jobname from cron.job;

View File

@@ -1,122 +0,0 @@
# Supabase Cron Job Setup for sync-analysis-results
This document explains how to set up a Supabase cron job to automatically call the `sync-analysis-results` API endpoint every 15 minutes.
## Prerequisites
1. Supabase project with database access
2. Your application deployed and accessible via a public URL
3. `JOBS_API_TOKEN` environment variable configured
## Setup Steps
### 1. Enable Required Extensions
First, enable the required PostgreSQL extensions in your Supabase project:
```sql
create extension if not exists pg_cron;
create extension if not exists pg_net;
```
You can run this either:
- In the Supabase Dashboard → Database → SQL Editor
- Or deploy the migration file: `supabase/migrations/setup_sync_analysis_results_cron.sql`
### 2. Schedule the Cron Job
Use the helper function to schedule the cron job with your specific configuration:
```sql
select schedule_sync_analysis_results_cron(
'https://your-actual-domain.com', -- Replace with your API URL
'your-actual-jobs-api-token' -- Replace with your JOBS_API_TOKEN
);
```
### 3. Verify the Setup
Check if the job was scheduled successfully:
```sql
select * from cron.job where jobname = 'sync-analysis-results-every-15-minutes';
```
## Management Commands
### View Job Execution History
```sql
select * from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'sync-analysis-results-every-15-minutes')
order by start_time desc limit 10;
```
### Check All Scheduled Jobs
```sql
select jobid, schedule, active, jobname from cron.job;
```
### Unschedule the Job
```sql
select cron.unschedule('sync-analysis-results-every-15-minutes');
```
## Configuration Details
- **Schedule**: `*/15 * * * *` (every 15 minutes)
- **HTTP Method**: POST
- **Headers**:
- `Content-Type: application/json`
- `x-jobs-api-key: YOUR_JOBS_API_TOKEN`
- **Body**: JSON with metadata about the cron trigger
## Security Considerations
1. **API Token**: Store your `JOBS_API_TOKEN` securely and never commit it to version control
2. **Network Access**: Ensure your Supabase instance can reach your deployed application
3. **Rate Limiting**: The 15-minute interval should be appropriate for your use case
## Troubleshooting
### Job Not Running
1. Check if extensions are enabled:
```sql
select * from pg_extension where extname in ('pg_cron', 'pg_net');
```
2. Verify job is active:
```sql
select * from cron.job where jobname = 'sync-analysis-results-every-15-minutes';
```
3. Check for execution errors:
```sql
select * from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'sync-analysis-results-every-15-minutes')
and status = 'failed'
order by start_time desc;
```
### API Authentication Issues
1. Verify your `JOBS_API_TOKEN` is correct
2. Test the 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"
```
## Alternative: Using Supabase Edge Functions
If you prefer using Supabase Edge Functions instead of pg_cron, you can:
1. Create an Edge Function that calls your API
2. Use Supabase's built-in cron triggers for Edge Functions
3. This approach provides better logging and error handling
Contact your team lead for assistance with Edge Functions setup if needed.