310 Commits

Author SHA1 Message Date
aa441d4055 update medreport.accounts with keycloak data when supabase auth.user is created 2025-09-08 01:05:02 +03:00
c882a24415 update envs for keycloak 2025-09-08 01:04:37 +03:00
077aaee181 handle keycloak user prefills in update-account form 2025-09-08 01:03:17 +03:00
57a998d215 fix NaN for bmi when divide-by-zero 2025-09-08 01:02:42 +03:00
f01829de96 update keycloak signup / login 2025-09-08 01:02:10 +03:00
7815a1c011 add phone number validation to update account form 2025-09-08 00:59:17 +03:00
96eea95fb9 fix password signup not redirecting to update-account view 2025-09-08 00:58:34 +03:00
6495d1c4a3 fix toTitleCase 2025-09-08 00:58:02 +03:00
a9612ad992 remove useless await 2025-09-08 00:57:38 +03:00
ab2176bc69 fix analyses loading 2025-09-08 00:57:28 +03:00
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
5c8f8b73d7 try to display price before adding to cart 2025-09-04 12:39:24 +03:00
5b52da0a62 fix adding to cart loading 2025-09-04 12:39:21 +03:00
Helena
21375cf55f MED-149: add more estonian translations (#72) 2025-09-04 12:38:46 +03:00
Helena
9122acc89f MED-151: add profile view and working smoking dashboard card (#71)
* MED-151: add profile view and working smoking dashboard card

* update zod

* move some components to shared

* move some components to shared

* remove console.logs

* remove unused password form components

* only check null for variant

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

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

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

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

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

* update permissions

* fix import, permissions

* casing, let email be null

* unused import
2025-09-02 12:14:01 +03:00
Helena
56a832b96b MED-75: add russian translations (#70) 2025-09-02 11:53:30 +03:00
Danel Kungla
568104eaff updated test envs in aws 2025-09-01 23:25:24 +03:00
Danel Kungla
2eac3a6836 updated test envs in aws 2025-09-01 18:07:54 +03:00
2df366c14a feat(MED-121): fix variant age range check 2025-09-01 14:29:14 +03:00
Danel Kungla
6ce60eacc7 Add data.sql to .gitignore 2025-08-29 18:05:51 +03:00
Danel Kungla
94dd00b9ca Add data.sql to .gitignore 2025-08-29 18:05:29 +03:00
danelkungla
6e76e75e85 Merge pull request #66 from MR-medreport/main
Main
2025-08-29 16:17:54 +03:00
71c3e2ef1e Merge pull request #64 from MR-medreport/MED-85
feat(MED-85-105-123): some testing feedback, other improvements
2025-08-29 11:52:55 +03:00
d83319a094 Merge branch 'main' into MED-85 2025-08-29 11:45:36 +03:00
Helena
505ef0d91b Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:28:15 +03:00
Helena
fce4355be8 Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:27:46 +03:00
Helena
da9658ad7a fix email button 2025-08-29 10:27:12 +03:00
danelkungla
e023d54a2a Merge pull request #63 from MR-medreport/main
update develop
2025-08-29 09:50:32 +03:00
danelkungla
bdaacbe78a MED-104: booking page
MED-104
2025-08-29 09:48:26 +03:00
Danel Kungla
5479f310d7 Merge branch 'main' into MED-104 2025-08-29 09:46:04 +03:00
Helena
5cf29447b3 add email sender 2025-08-28 18:03:28 +03:00
Helena
7d1400fba6 log email result and subject 2025-08-28 17:30:32 +03:00
Helena
5ee161f482 remove html length limit from emails 2025-08-28 16:47:27 +03:00
815b877b5b Merge branch 'main' into MED-85 2025-08-28 16:12:46 +03:00
4588d11d5a feat(MED-86): don't prettify results sync log since aws will split it up 2025-08-28 15:49:30 +03:00
e0688eb539 feat(MED-86): fix status check for fake responses 2025-08-28 15:49:12 +03:00
0c28f9681b feat(MED-86): fix status check for fake responses 2025-08-28 15:49:03 +03:00
70b85dc967 feat(MED-86): don't prettify results sync log since aws will split it up 2025-08-28 15:37:29 +03:00
71f5a25632 Merge branch 'main' into MED-85 2025-08-28 14:57:09 +03:00
d072226a5c feat(MED-105): organize env specific required migrations 2025-08-28 14:54:51 +03:00
da7f574234 feat(MED-86): user can see in Medusa BO if sending order to medipost succeeded 2025-08-28 14:42:07 +03:00
b3505c1627 feat(MED-100): show cart line item quantities total instead of items total count 2025-08-28 14:41:59 +03:00
Danel Kungla
ad28352fc8 MED-104: create booking view with categories 2025-08-28 14:11:54 +03:00
b931035c3b feat(MED-86): add db fn to show medipost dispatch error for order in Medusa 2025-08-28 13:36:07 +03:00
f723633646 feat(MED-123): don't redirect to cart on single analysis select 2025-08-28 13:31:29 +03:00
6e6ad13b52 feat(MED-123): show toast on adding analysis package to cart 2025-08-28 13:30:25 +03:00
Danel Kungla
31bc4b6cff initial commit 2025-08-28 13:15:39 +03:00
Helena
86dc221cc6 MED-145: send notification to patient when summary completed (#61)
* MED-145: send notification to patient when summary completed

* MED-145: send notification to patient when summary completed

* use aliased imports where possible, revert cart service urls

* save language preference to local db

* remove unnecessary optional chaning
2025-08-28 13:05:07 +03:00
3ddc0a2716 feat(MED-123): show toast on adding analysis to cart 2025-08-28 12:44:07 +03:00
49eeaa1876 feat(MED-123): update translations 2025-08-28 12:40:33 +03:00
2ffad84100 feat(MED-105): log opening analysis results from orders view 2025-08-28 12:35:30 +03:00
b4985afdf0 feat(MED-85): add logging for medipost response error xml 2025-08-28 12:25:18 +03:00
a37c4cad9c feat(MED-85): add logging for medipost actions with xml and related order id 2025-08-28 11:56:07 +03:00
47ab39172e feat(MED-85): run sending fake medipost results in dev from Medusa BO 2025-08-28 10:30:43 +03:00
d760f86632 feat(MED-85): run force medipost results sync from Medusa BO 2025-08-28 09:54:32 +03:00
Helena
5159325e6d fix missing id 2025-08-27 09:11:21 +03:00
794d5b7079 Merge pull request #60 from MR-medreport/MED-85
feat(MED-85): testing feedback updates, some fixes+improvements
2025-08-27 08:40:25 +03:00
a547503887 Merge branch 'main' into MED-85 2025-08-27 08:31:26 +03:00
94439432fd feat(MED-85): improve naming 2025-08-27 08:29:36 +03:00
a03db16092 feat(MED-85): add get_order_possible_actions function for backoffice 2025-08-27 08:27:44 +03:00
Helena
8c6ce29c23 MED-147: add doctor actions logging (#59)
* MED-147: add doctor actions logging

* enum casing
2025-08-27 08:11:13 +03:00
380363922c feat(MED-85): improve results sync logs 2025-08-27 08:07:18 +03:00
68e5101885 feat(MED-85): update order status translations 2025-08-27 08:06:30 +03:00
7087c9a6da feat(MED-85): fix missing async 2025-08-27 08:06:00 +03:00
2b2a0b8bc4 feat(MED-85): update dispatch order to medipost retry 2025-08-27 08:05:52 +03:00
Danel Kungla
f33f2b6db4 feat: update AppLogo component to use pathsConfig for home navigation 2025-08-26 16:36:06 +03:00
Danel Kungla
db6d29c208 feat: update audit logging function for tracking changes in database 2025-08-26 16:13:17 +03:00
Helena
5d92c34259 MED-56: show specific error when job taken, other small improvements (#58)
* show specific error when job taken, other small improvements

* enum name case

* enum value case, actually
2025-08-26 11:43:31 +03:00
danelkungla
f5abf2d21e Merge pull request #56 from MR-medreport/MED-57
MED-57: update dashboard with real data
2025-08-26 11:31:25 +03:00
Danel Kungla
dedb42f15b Merge branch 'main' into MED-57 2025-08-26 11:30:34 +03:00
Danel Kungla
54df7aae69 fix: update translation key for date selection in Estonian locale 2025-08-26 11:28:10 +03:00
Helena
5663f41f22 switch weight and height parameters in analysis view 2025-08-25 19:48:37 +03:00
Helena
38d8ca9cfb add isBefore check to previous analysis find 2025-08-25 19:34:47 +03:00
Helena
d017834b8c MED-90: improve doctor analysis detail view (#57)
* add doctor jobs view

* change translation

* another translation change

* clean up

* add analaysis detail view to paths config

* translation

* merge fix

* fix path

* MED-90: improve doctor analysis detail view

* add key
2025-08-25 16:49:04 +03:00
Danel Kungla
4b7d5ff7bb fix(sign-up): move emailRedirectTo option into signUp options 2025-08-25 16:16:56 +03:00
Danel Kungla
2ac2e517f2 Merge branch 'main' into MED-57 2025-08-25 15:24:30 +03:00
Danel Kungla
a088da28d8 refactor: reorder import statements for improved clarity and consistency 2025-08-25 15:22:13 +03:00
Danel Kungla
d57c691a71 update pnpm-lock 2025-08-25 12:57:39 +03:00
Helena
7ac31991c0 fix import paths 2025-08-25 12:52:28 +03:00
Danel Kungla
81a32199e8 refactor: reorganize imports in various components for consistency 2025-08-25 12:46:24 +03:00
Danel Kungla
63c5caca3d fix(page): remove unused imports from TeamAccountHomePage component 2025-08-25 12:25:27 +03:00
Danel Kungla
e0a8173368 Merge branch 'main' into MED-57 2025-08-25 12:25:18 +03:00
Danel Kungla
56ffd7591e feat(team-accounts): enhance team account statistics and health details components with new data and improved calculations 2025-08-25 12:24:27 +03:00
da8b5aa59f feat(MED-85): create logs of sending order to medipost success/error 2025-08-25 12:22:29 +03:00
828f32ee81 feat(MED-85): create audit log on orders view + export csv 2025-08-25 11:51:43 +03:00
eb1eeb690b feat(MED-85): create audit logs for medusa admin actions 2025-08-25 11:51:35 +03:00
811e860965 feat(MED-85): fix delete company account error 2025-08-25 11:51:27 +03:00
89d6035151 feat(MED-85): create customer group for company account in Medusa 2025-08-25 11:51:12 +03:00
5108087cc5 feat(MED-100): improve translation for status 2025-08-25 11:51:04 +03:00
4e6f12a9a0 feat(MED-100): add partner locations list for cart 2025-08-25 11:50:55 +03:00
1d1b10d094 feat(MED-100): show analysis packages and analyses in separate block from tto services 2025-08-25 11:50:47 +03:00
e8e762e7ee feat(MED-100): update analysis level bars 2025-08-25 11:50:32 +03:00
b6a0940506 feat(MED-87): don't show 'waiting for results' text if order is cancelled 2025-08-25 11:50:22 +03:00
38d73e27ad feat(MED-121): use age+sex specific analysis package variants 2025-08-25 11:50:03 +03:00
Helena
195af1db3d MED-137: add doctor other jobs view (#55)
* add doctor jobs view

* change translation

* another translation change

* clean up

* add analaysis detail view to paths config

* translation

* merge fix

* fix path

* move components to shared

* refactor

* imports

* clean up
2025-08-25 11:12:57 +03:00
Danel Kungla
ee86bb8829 fix(sign-up): remove unused emailRedirectTo option from signUp function 2025-08-25 10:34:43 +03:00
Danel Kungla
d8c617eb34 fix(utils): round BMI calculation to return a valid number or NaN 2025-08-22 09:16:06 +03:00
Danel Kungla
9a5cf3ea84 refactor(accounts): remove translation for create company account button 2025-08-21 23:04:41 +03:00
Danel Kungla
1fb8df7c89 feat(dashboard, api): integrate BMI thresholds and enhance dashboard with health metrics 2025-08-21 22:09:17 +03:00
Danel Kungla
b1b0846234 feat(dashboard, api): enhance dashboard card calculations and add team membership check 2025-08-21 21:32:22 +03:00
Danel Kungla
492327c5c7 feat(account): add team membership check and refactor user account loading in HomeLayout 2025-08-21 21:32:17 +03:00
Danel Kungla
cdf1491e53 refactor(auth): remove personal code from sign-up flow and update related components
feat(i18n): update translations for company account creation and get started
2025-08-21 21:32:03 +03:00
Danel Kungla
6f67a21cc1 refactor(footer, header): remove unused components and clean up code 2025-08-21 21:30:29 +03:00
danelkungla
8dfa0e5f4a Merge pull request #54 from MR-medreport/MED-57
MED-57: Team account dashboard
2025-08-18 17:57:42 +03:00
Danel Kungla
2b79a9e401 Refactor team account statistics and health details
- Simplified the TeamAccountStatistics component by removing unused code and integrating translation for health details.
- Updated the load-team-account-health-details to calculate average BMI based on member parameters.
- Adjusted the billing page to correctly reference the number of team members.
- Enhanced the main account page to pass member parameters to the Dashboard component.
- Modified the admin account page to destructure member data from the API response.
- Updated the TeamAccountsApi to return member parameters alongside member data.
- Added new translations for health details in Estonian locale.
2025-08-18 16:50:26 +03:00
Danel Kungla
937f3e4a71 feat(account): refactor team account retrieval and update SQL function for workspace 2025-08-18 15:59:59 +03:00
Danel Kungla
1427bcd5a9 Merge branch 'main' into MED-57 2025-08-18 15:15:27 +03:00
64f8c44c74 Merge pull request #53 from MR-medreport/MED-105-v3
feat(MED-123): fix medreport.notifications.body only allows 5000 chars, cannot log longer emails
2025-08-18 15:11:20 +03:00
Karli
9995c7d170 feat(MED-123): fix medreport.notifications.body only allows 5000 chars, cannot log longer emails 2025-08-18 15:10:25 +03:00
Danel Kungla
c48a4b482f feat(dashboard): add dynamic loading for dashboard component
feat(team-account-benefit-statistics): implement benefit statistics card with budget and booking details

feat(team-account-health-details): create health details component displaying account health metrics

feat(team-account-statistics): develop team account statistics page with charts and customer table

feat(load-team-account-health-details): add server-side function to retrieve account health details

chore(migrations): create trigger for logging changes in account memberships
2025-08-18 14:54:46 +03:00
07ffd6814e Merge pull request #52 from MR-medreport/MED-105-v3
feat(MED-123): update "order analysis" page
2025-08-18 14:14:45 +03:00
Karli
2f732b254f feat(MED-123): log order analysis page view 2025-08-18 14:11:27 +03:00
Karli
abf455117d feat(MED-123): analysis to order can be not-available 2025-08-18 14:11:19 +03:00
Karli
9b50b0f92a feat(MED-123): update dashboard cards for "order analysis" 2025-08-18 13:58:42 +03:00
8a6e805afc Merge pull request #51 from MR-medreport/MED-105-v3
feat(MED-101): disable cart timer, create audit log on "remove item from cart"
2025-08-18 13:28:22 +03:00
Karli
ec866c7f29 feat(MED-101): add audit log to cart item delete 2025-08-18 13:27:13 +03:00
Karli
43117985dd feat(MED-101): disable timer until TTO products are used 2025-08-18 13:21:56 +03:00
e6d768d6ed Merge pull request #50 from MR-medreport/MED-105-v3
feat(MED-105): update sending test analysis results
2025-08-18 13:20:54 +03:00
Karli
d65a6e8a4a feat(MED-105): update sending test analysis results 2025-08-18 13:20:06 +03:00
44670965ae feat(MED-105): update email 2025-08-16 14:59:46 +03:00
bf61763452 Merge pull request #49 from MR-medreport/MED-105-v3
feat(MED-105): update analysis results view to be by analysis order, create notifications entry on email
2025-08-14 13:00:30 +03:00
4c5ec1cfac feat(MED-105): update orders table to be by each separate order 2025-08-14 12:59:42 +03:00
465d2e0a00 feat(MED-48): update email title 2025-08-14 12:28:41 +03:00
5ff0500a9a Merge branch 'main' into MED-105-v3 2025-08-14 12:21:46 +03:00
1285b02f9c feat(MED-105): update analysis results view to be by analysis order 2025-08-14 12:10:12 +03:00
Helena
b4b75438d2 Fix: add application_role to account-related fields (#48)
* Fix: fix accounts view, menu

* add migration

* add application_role to account-related fields
2025-08-14 11:40:53 +03:00
Danel Kungla
bbb5e83ed9 feat(audit): implement page view logging for membership confirmation and analysis results 2025-08-14 09:37:03 +03:00
Danel Kungla
536f915c69 fix: ensure form resets and button state reflects dirty status 2025-08-14 09:24:20 +03:00
d3b393156a feat(MED-105): create update_analysis_order_status for cancelling 2025-08-14 01:21:22 +03:00
87751e10b4 feat(MED-105): allow sending partial results in test 2025-08-14 01:20:45 +03:00
8e9da80e5c Merge branch 'main' into MED-105-v3 2025-08-14 01:19:48 +03:00
Helena
c22667dab4 Fix: add missing paths to dropdown (#47) 2025-08-13 14:42:26 +03:00
5bd1483aa8 Run test sync with curl 2025-08-13 13:01:56 +03:00
Helena
3c6c86c7c8 MED-109: add doctor role and basic view (#45)
* MED-109: add doctor role and basic view

* add role to accounts

* remove old super admin and doctor sql
2025-08-13 12:28:50 +03:00
5d6aa96324 feat(MED-105): create notifications entry on email 2025-08-13 11:45:04 +03:00
Danel Kungla
ce7b04fda8 feat(mailer): add CONTACT_EMAIL environment variable for email sending 2025-08-13 09:51:20 +03:00
Danel Kungla
00bfb3574e refactor: remove next-transpile-modules and update related configurations 2025-08-12 16:49:33 +03:00
Danel Kungla
e085a69442 feat(billing): remove unused BillingErrorPage component 2025-08-12 16:23:04 +03:00
Danel Kungla
b4a811e781 feat(migrations): add missing fields and triggers for auditing changes in accounts and company_params 2025-08-12 16:07:56 +03:00
danelkungla
b3620fbb19 Merge pull request #46 from MR-medreport/MED-91
Med 91
2025-08-12 15:51:06 +03:00
Danel Kungla
4d1cf6a84a Merge branch 'main' into MED-91 2025-08-12 15:50:40 +03:00
Danel Kungla
4c38da445c Merge branch 'main' into MED-91 2025-08-12 15:50:14 +03:00
Danel Kungla
3295ce7d04 feat(auth): refactor account submission data structure and remove unused billing error component 2025-08-12 15:47:01 +03:00
7094523d7e feat(MED-105): reset instrumentation.ts 2025-08-12 12:12:11 +03:00
Danel Kungla
801f5f45e2 Implement code changes to enhance functionality and improve performance 2025-08-11 17:58:36 +03:00
Danel Kungla
c3cab7c06d feat: update README for payment process clarity and enhance success notification button behavior 2025-08-11 17:51:35 +03:00
Danel Kungla
99ba14bab2 Refactor code structure and remove redundant sections for improved readability and maintainability 2025-08-11 17:51:12 +03:00
Danel Kungla
9298abe354 feat(billing): enhance health benefit form and yearly expenses overview with employee count and loading state 2025-08-11 15:04:16 +03:00
2c78b921b9 Merge pull request #43 from MR-medreport/MED-105-1108
feat(MED-105): small fixes
2025-08-11 14:06:52 +03:00
91e0987831 feat(MED-105): tooltip content too wide 2025-08-11 14:06:05 +03:00
4d76dd83ad feat(MED-105): disable scheduler 2025-08-11 14:05:54 +03:00
6e63646944 Merge pull request #42 from MR-medreport/MED-105-1108
feat(MED-105): update analysis results page, analysis order new statuses
2025-08-11 10:45:32 +03:00
5264455793 feat(MED-87): improve naming 2025-08-11 10:44:53 +03:00
63b86c0abb feat(MED-87): update status on results 2025-08-11 10:41:01 +03:00
d582e222ce feat(MED-105): update order details redirect and shown page 2025-08-11 09:21:40 +03:00
556d7bd321 feat(MED-105): show analysis date in tooltip for now 2025-08-11 09:21:33 +03:00
37f233e363 feat(MED-105): show empty text on no ordered analyses 2025-08-11 09:21:27 +03:00
c8621c4453 feat(MED-105): sort analysis result elements by date 2025-08-11 09:21:20 +03:00
83fff1ffe7 feat(MED-105): create audit entry on analysis results view 2025-08-11 09:21:13 +03:00
49fc75b17b feat(MED-105): update analysis results page 2025-08-11 09:21:06 +03:00
Danel Kungla
aba4596edd fix: update email recipient logic in renderCompanyOfferEmail function 2025-08-07 18:04:44 +03:00
Danel Kungla
2850478443 feat: enable TLS for email and improve loading state handling in CompanyOfferForm 2025-08-07 15:10:59 +03:00
09155ae110 feat(MED-131): revert to use server action for montonio token for revalidateTag 2025-08-05 10:59:44 +03:00
d9b619d560 feat(MED-131): rerun 2025-08-05 10:33:59 +03:00
7f1596b141 feat(MED-131): keep unused app/store pages for now but don't build them since its unstable 2025-08-05 10:29:37 +03:00
e375f22b57 feat(MED-131): route sometimes redirects back to login instead 2025-08-05 10:07:59 +03:00
4617c483fa feat(MED-131): env fix v2 2025-08-04 19:03:18 +03:00
d68ed20ca9 feat(MED-131): temp fix for env 2025-08-04 18:42:28 +03:00
danelkungla
90e0049d65 Merge pull request #41 from MR-medreport/MED-111
feat(MED-111): update navigation links and adjust invitation handling
2025-08-04 18:35:08 +03:00
47675252ed feat(MED-131): conditional env 2025-08-04 18:31:17 +03:00
bd78b28f3c feat(MED-131): move dev envs to .env.development 2025-08-04 18:23:52 +03:00
e0064b9d55 feat(MED-131): fix migration date 2025-08-04 18:16:47 +03:00
Danel Kungla
cb7f4f7ba5 feat(MED-111): update navigation links and adjust invitation handling 2025-08-04 18:15:22 +03:00
edfc9776d3 feat(MED-131): remove fake url 2025-08-04 17:56:04 +03:00
77d58ef5b1 feat(MED-131): fix analysis element ids 2025-08-04 17:41:32 +03:00
0ba23b6c8e feat(MED-131): show other (non analysis-package) orders 2025-08-04 17:32:54 +03:00
74ccbfd4d7 feat(MED-131): update default region 2025-08-04 17:20:23 +03:00
9854a65484 Revert "feat(MED-131): check permissions for 'audit' schema"
This reverts commit ff3335d331.
2025-08-04 17:16:09 +03:00
ff3335d331 feat(MED-131): check permissions for 'audit' schema 2025-08-04 16:51:52 +03:00
4b198cd10c Merge pull request #40 from MR-medreport/MED-131-v2
feat(MED-131): update analysis/package -> cart -> medipost flow, many fixes/improvements
2025-08-04 16:32:30 +03:00
e8dae56d7e feat(MED-131): update type 2025-08-04 16:30:42 +03:00
0c6bda607d feat(MED-131): support multiple elements under same group for creating fake medipost responses 2025-08-04 16:30:17 +03:00
84b629ab0b feat(MED-131): remove private messages with unknown order ID from queue 2025-08-04 16:29:50 +03:00
0a4be89fc9 feat(MED-131): initial fix for analysis results table so result elements are shown correctly 2025-08-04 16:28:57 +03:00
30c7d192fa feat(MED-131): fix validation 2025-08-04 16:28:24 +03:00
8790b515d5 feat(MED-131): fix medusa vs medipost vs b2b product ids mixed 2025-08-04 16:27:54 +03:00
36816cfcd5 feat(MED-131): improve logging, error validation 2025-08-04 16:26:26 +03:00
58e2b8dc81 feat(MED-131): send to medipost on montonio callback 2025-08-04 16:24:28 +03:00
b2003ad30d feat(MED-131): move medipost xml logic to service 2025-08-04 12:49:03 +03:00
853bd75119 feat(MED-131): analysis package has list of medusa product IDs, not medipost IDs 2025-08-04 12:44:36 +03:00
0d1e255fe2 feat(MED-131): redirect to /home in case of no redirect path 2025-08-04 11:55:27 +03:00
7c3aa45ec7 feat(MED-131): update analyses on package logic 2025-08-04 11:55:23 +03:00
c02cb046a5 feat(MED-131): update readme for medipost and jobs 2025-08-04 11:55:13 +03:00
f4dcc44428 feat(MED-131): reset loading on error, show spinner 2025-08-04 11:53:33 +03:00
606fd55a42 feat(MED-131): use constants 2025-08-04 11:53:27 +03:00
0e9d9a212a feat(MED-131): sync analysis results in job handler 2025-08-04 11:53:18 +03:00
69f41430e2 feat(MED-131): send fake test response for an order 2025-08-04 11:53:12 +03:00
91f6dd11be feat(MED-131): handle analysis order 2025-08-04 11:53:04 +03:00
08950896e5 feat(MED-131): improve xml response code validation 2025-08-04 11:52:57 +03:00
dfce4047cd feat(MED-131): change all jobs apis to POST 2025-08-04 11:52:48 +03:00
5966fb2a77 feat(MED-131): store medusa_store_id along with analysis_order 2025-08-04 11:52:39 +03:00
c681063e8d feat(MED-131): show separate text in case of no existing analysis results 2025-08-04 11:52:31 +03:00
12cd61840c feat(MED-131): update location in action so cart is always reloaded properly 2025-08-04 11:52:22 +03:00
959646a319 feat(MED-131): direct imports 2025-08-04 11:52:14 +03:00
ee60a78335 feat(MED-131): update analyses sync to medusa store 2025-08-04 11:52:09 +03:00
b665678dbb feat(MED-131): display analyses options and add to cart 2025-08-04 11:51:49 +03:00
8c4df731aa feat(MED-131): show package analysis elements in comparison modal 2025-08-04 11:51:38 +03:00
d7d089c11d feat(MED-131): use existing supabase server admin client conf 2025-08-04 11:51:28 +03:00
12465e18fb feat(MED-131): update translations 2025-08-04 11:51:21 +03:00
43493c261c feat(MED-131): move jobs to /api/job/* secured with key 2025-08-04 11:51:11 +03:00
danelkungla
5746e1b087 Merge pull request #39 from MR-medreport/MED-111
MED-111: fix company creation for admin and inviting of new employees
2025-08-04 11:33:08 +03:00
Danel Kungla
0254805743 use isikukood library to validate in zod 2025-08-04 11:32:41 +03:00
Danel Kungla
6ba33a99b2 clean 2025-07-31 12:31:58 +03:00
Danel Kungla
7031bc1176 Merge branch 'main' into MED-111 2025-07-31 12:30:25 +03:00
Danel Kungla
a39c21e4e7 fix company creation for admin and inviting of new employees 2025-07-31 12:27:30 +03:00
2caf847296 feat(MED-85): update webhook url to have public path in test env 2025-07-31 12:02:01 +03:00
23a07658c9 feat(MED-85): redirect to dashboard after MFA is done 2025-07-31 12:00:51 +03:00
c91adc7521 feat(MED-85): fix wrong env variable name 2025-07-31 12:00:13 +03:00
Danel Kungla
87363051cd feat: implement loadCurrentUserAccounts for user account management in SiteLayout 2025-07-24 16:13:32 +03:00
Danel Kungla
a85f26d55b feat: refactor SiteLayout to use Supabase client for user authentication 2025-07-24 12:07:53 +03:00
1d746e8e6d feat(MED-48): local docker mailer to readme 2025-07-24 12:02:17 +03:00
6374bfb602 feat(MED-48): retry 2025-07-24 10:42:43 +03:00
790103a55f Merge pull request #38 from MR-medreport/MED-48
feat(MED-48 MED-100): update products -> cart -> montonio -> orders flow, send email
2025-07-24 10:26:34 +03:00
Danel Kungla
f0e956a7e8 fix: remove redundant build step in Dockerfile 2025-07-24 09:43:04 +03:00
danelkungla
65f0114ae4 Merge pull request #37 from MR-medreport/MED-91
add health benefit form, fix super admin
2025-07-24 09:26:06 +03:00
894cf1b454 feat(MED-100): partner location metadata stored on order line instead of order 2025-07-24 09:23:44 +03:00
3341dbd306 feat(MED-48): improvements 2025-07-24 08:52:59 +03:00
d7499fbc13 feat(MED-50): update default env 2025-07-24 08:40:27 +03:00
6dcb9ae4db feat(MED-50): apply medusa middleware for cache id 2025-07-24 08:40:01 +03:00
f26085e362 feat(MED-50): add placeholder pages 2025-07-24 08:38:30 +03:00
349e3e3143 feat(MED-50): use account_params in dashboard 2025-07-24 08:38:12 +03:00
66760adc06 feat(MED-50): initial analysis package orders table 2025-07-24 08:06:33 +03:00
57e30911e9 feat(MED-100): show cart in mobile menu 2025-07-24 08:06:01 +03:00
b9f40d6a2d feat(MED-100): analysis location select in cart 2025-07-24 08:05:10 +03:00
23f656ccc9 feat(MED-100): analysis package products by product type 2025-07-24 08:04:04 +03:00
e59ad6af00 feat(MED-100): update montonio redirect 2025-07-24 08:03:55 +03:00
8633ac4bce feat(MED-48): add synlab analysis package email 2025-07-24 07:59:17 +03:00
Danel Kungla
0bd67ec4e8 refactor: move role creation and permissions to super_admin_fix.sql 2025-07-23 17:01:13 +03:00
Danel Kungla
86b86c6752 add health benefit form
fix super admin
2025-07-23 16:33:24 +03:00
Danel Kungla
2db67b7f20 fix: ensure environment variables are loaded before building 2025-07-22 15:19:17 +03:00
Danel Kungla
c8be061ddc remove git for /health 2025-07-22 10:55:40 +03:00
Danel Kungla
aa62aa69f3 need git in dockerfile for /health page 2025-07-22 10:42:37 +03:00
Danel Kungla
8ee9271c2e feat: add commit hash to environment variables and create HealthPage component to display it 2025-07-21 14:59:29 +03:00
Danel Kungla
8e4d634a7d fix build 2025-07-21 13:56:52 +03:00
93e44cda83 Merge pull request #36 from MR-medreport/feature/MED-100
feat(MED-100): update cart with montonio
2025-07-21 09:05:00 +00:00
185a8a8293 feat(MED-100): update env 2025-07-21 11:54:27 +03:00
5d53e53ba8 feat(MED-100): small improvements 2025-07-21 11:54:07 +03:00
15ed539283 Merge branch 'main' into feature/MED-100 2025-07-21 11:38:47 +03:00
Danel Kungla
eec8a12db2 feat: fix lucide-react renders
feat: fix mobile designs
feat: remove conflicting react-hook-form
feat: change update-account-form path
2025-07-18 17:19:12 +03:00
8c090f9d68 Merge pull request #34 from MR-medreport/MED-122
feat(MED-122): update analysis packages page, pageheader usage
2025-07-18 13:12:23 +00:00
c8e5d8835b Merge branch 'main' into MED-122 2025-07-18 16:11:50 +03:00
danelkungla
2cb378a88a Merge pull request #35 from MR-medreport/MED-105
MED-105: create analysis results page
2025-07-18 15:40:58 +03:00
Danel Kungla
3a5058fc44 Merge branch 'main' into MED-105 2025-07-18 15:35:44 +03:00
Danel Kungla
dca5f04739 add env logging 2025-07-17 12:39:21 +03:00
Danel Kungla
79b6652748 Add missing .env file copy in Dockerfile for build stage 2025-07-16 16:07:27 +03:00
Danel Kungla
2689b87977 add logs 2025-07-16 14:48:18 +03:00
Danel Kungla
6232ffc50d Remove unused Stripe publishable key from development and production environment files 2025-07-16 13:35:01 +03:00
Danel Kungla
b028aae1ba Fix environment variable copying in Dockerfile for runtime stage 2025-07-16 13:29:29 +03:00
Danel Kungla
d572ba52a8 Refactor Dockerfile for improved build and runtime stages 2025-07-16 12:09:14 +03:00
Danel Kungla
4e0ef7b9de Fix environment variable configuration in .env and .env.production files 2025-07-16 11:21:08 +03:00
Danel Kungla
571637f96a Add .env.production copy to Dockerfile for environment configuration 2025-07-16 10:16:47 +03:00
Danel Kungla
53e9349f37 Update Supabase configuration in development and production environment files 2025-07-15 17:13:12 +03:00
Danel Kungla
87dfcf55e6 MED-105: create analysis results page 2025-07-15 17:12:52 +03:00
535 changed files with 26973 additions and 7554 deletions

23
.env
View File

@@ -5,7 +5,6 @@
# TO OVERRIDE THESE VARIABLES IN A SPECIFIC ENVIRONMENT, PLEASE ADD THEM TO THE SPECIFIC ENVIRONMENT FILE (e.g. .env.development, .env.production)
# SITE
NEXT_PUBLIC_SITE_URL=https://localhost:3000
NEXT_PUBLIC_PRODUCT_NAME=MedReport
NEXT_PUBLIC_SITE_TITLE="MedReport"
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
@@ -14,12 +13,12 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# AUTH
NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
# BILLING
NEXT_PUBLIC_BILLING_PROVIDER=stripe
NEXT_PUBLIC_BILLING_PROVIDER=montonio
# CMS
CMS_CLIENT=keystatic
@@ -35,13 +34,25 @@ NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
NEXT_PUBLIC_LANGUAGE_PRIORITY=application
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
WEBHOOK_SENDER_PROVIDER=postgres
# MAILER DEV
CONTACT_EMAIL=info@medreport.ee
MAILER_PROVIDER=nodemailer
EMAIL_SENDER=info@medreport.ee
EMAIL_USER= # refer to your email provider's documentation
EMAIL_PASSWORD= # refer to your email provider's documentation
EMAIL_HOST=localhost # refer to your email provider's documentation
EMAIL_PORT=1025 # or 465 for SSL
EMAIL_TLS=true
NODE_TLS_REJECT_UNAUTHORIZED=0
# NEXTJS
NEXT_TELEMETRY_DISABLED=1
@@ -53,5 +64,7 @@ NEXT_PUBLIC_DEFAULT_LOCALE=et
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
# MEDUSA
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
# Configure Medusa password secret for Keycloak users
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==

View File

@@ -1,10 +1,11 @@
# This file is used to define environment variables for the development environment.
# These values are only used when running the app in development mode.
# SUPABASE
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
# SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_PASSWORD=true
# SUPABASE DEVELOPMENT
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
@@ -14,14 +15,25 @@ SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
# CONTACT FORM
CONTACT_EMAIL=test@makerkit.dev
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# MAILER
MAILER_PROVIDER=nodemailer
# EMAIL_SENDER=
# EMAIL_USER= # refer to your email provider's documentation
# EMAIL_PASSWORD= # refer to your email provider's documentation
# EMAIL_HOST= # refer to your email provider's documentation
# EMAIL_PORT= # or 465 for SSL
# EMAIL_TLS= # or false for SSL (see provider documentation)
EMAIL_SENDER=info@medreport.ee
EMAIL_USER= # refer to your email provider's documentation
EMAIL_PASSWORD= # refer to your email provider's documentation
EMAIL_HOST=localhost # refer to your email provider's documentation
EMAIL_PORT=1025 # or 465 for SSL
EMAIL_TLS=false
NODE_TLS_REJECT_UNAUTHORIZED=0
# MEDUSA
MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
# MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
# JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197

View File

@@ -2,7 +2,7 @@
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
MEDIPOST_URL=your-medipost-url
MEDIPOST_USER=your-medipost-user
@@ -23,3 +23,5 @@ NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197

View File

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

15
.env.staging Normal file
View File

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

1
.gitignore vendored
View File

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

View File

@@ -1,17 +1,55 @@
# --- Stage 1: Build ---
FROM node:20-alpine as builder
WORKDIR /app
RUN npm install -g pnpm@9
RUN npm install -g dotenv-cli
# Copy necessary files for workspace resolution
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages packages
COPY tooling tooling
COPY .env .env
COPY .env.production .env.production
COPY .env.staging .env.staging
# Install all dependencies
# Load env file and echo a specific variable
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
ENV NODE_ENV=production
RUN set -a \
&& . .env \
&& . .env.production \
&& . .env.staging \
&& set +a \
&& node check-env.js \
&& pnpm build
# --- Stage 2: Runtime ---
FROM node:20-alpine
ARG APP_ENV=production
WORKDIR /app
COPY --from=builder /app ./
RUN cp ".env.${APP_ENV}" .env.local
RUN npm install -g pnpm@9 \
&& 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
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@@ -85,5 +85,58 @@ To access admin pages follow these steps:
- Register new user
- Go to Profile and add Multi-Factor Authentication
- Sign out and Sign in
- Authenticate with mfa (at current time profile page prompts it again)
- update your role. look at `supabase/sql/super-admin.sql`
- Sign out and Sign in
## Company User
- With admin account go to `http://localhost:3000/admin/accounts`
- For Create Company Account to work you need to have rows in `medreport.roles` table. For that you can sql in `supabase/sql/super-admin.sql`
## Start email server
`docker run -p 1080:1080 -p 1025:1025 maildev/maildev`
- [View emails](http://localhost:1080/#/)
- Mail server is running on `localhost:1025` without password
## Medipost flow
1. Customer adds analysis to cart in **B2B** storefront
2. Customer checks out from cart and is redirected to **Montonio**
3. Customer pays and is redirected back to **B2B** `GET B2B/home/cart/montonio-callback?order-token=$JWT`
- **Medusa** order is created and cart is emptied
- email is sent to customer
- B2B sends order XML as private message to Medipost.
When **Montonio** has confirmed payment, it will call **Medusa** webhook endpoint and **Medusa** will mark order payment as captured.
In background a job will call `POST B2B/api/job/sync-analysis-results` every n minutes and sync private messages with responses from **Medipost**.
In local dev environment, you can create a private message with analysis responses in **Medipost** system for a submitted order:
`POST B2B/api/order/medipost-test-response body={medusaOrderId:'input here'}`
After that run `POST /api/job/sync-analysis-results` and analysis results should be synced.
In local dev environment, you can import products from B2B to Medusa with this API:
- `POST /api/job/sync-analysis-groups-store`
- Syncs required data of `analyses`, `analysis_elements` data from **B2B** to **Medusa** and creates relevant products and categories.
If product or category already exists, then it is not recreated. Old entries are not deleted either currently.
## Jobs
Required headers:
- `x-jobs-api-key` in UUID format
Endpoints:
- `POST /api/job/sync-analysis-groups`
- Queries **Medipost** for public messages list and takes analysis info from the latest event.
Updates `analyses` and `analysis_elements` lists in **B2B**.
- `POST /api/job/sync-analysis-results`
- Queries **Medipost** for latest private message that has a response and updates order analysis results from lab results data.
- `POST /api/job/sync-connected-online`
- TODO

View File

@@ -2,6 +2,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();

View File

@@ -2,6 +2,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();

View File

@@ -1,14 +1,13 @@
import { AppLogo } from '@kit/shared/components/app-logo';
import { appConfig } from '@kit/shared/config';
import { Footer } from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans';
import { AppLogo } from '~/components/app-logo';
import appConfig from '~/config/app.config';
export function SiteFooter() {
return (
<Footer
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
description={<Trans i18nKey="marketing:footerDescription" />}
description=""
copyright={
<Trans
i18nKey="marketing:copyright"
@@ -19,22 +18,6 @@ export function SiteFooter() {
/>
}
sections={[
{
heading: <Trans i18nKey="marketing:about" />,
links: [
{ href: '/blog', label: <Trans i18nKey="marketing:blog" /> },
{ href: '/contact', label: <Trans i18nKey="marketing:contact" /> },
],
},
{
heading: <Trans i18nKey="marketing:product" />,
links: [
{
href: '/docs',
label: <Trans i18nKey="marketing:documentation" />,
},
],
},
{
heading: <Trans i18nKey="marketing:legal" />,
links: [

View File

@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { useQuery } from '@tanstack/react-query';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
@@ -12,8 +13,7 @@ import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import featuresFlagConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
const ModeToggle = dynamic(() =>
import('@kit/ui/mode-toggle').then((mod) => ({
@@ -23,13 +23,20 @@ const ModeToggle = dynamic(() =>
const paths = {
home: pathsConfig.app.home,
admin: pathsConfig.app.admin,
doctor: pathsConfig.app.doctor,
personalAccountSettings: pathsConfig.app.personalAccountSettings,
};
const features = {
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
enableThemeToggle: featureFlagsConfig.enableThemeToggle,
};
export function SiteHeaderAccountSection() {
export function SiteHeaderAccountSection({
accounts,
}: {
accounts: UserWorkspace['accounts'];
}) {
const session = useSession();
const signOut = useSignOut();
@@ -41,6 +48,7 @@ export function SiteHeaderAccountSection() {
features={features}
user={session.data.user}
signOutRequested={() => signOut.mutateAsync()}
accounts={accounts}
/>
);
}
@@ -58,17 +66,19 @@ function AuthButtons() {
</div>
<div className={'flex gap-x-2.5'}>
<Button className={'hidden md:block'} asChild variant={'ghost'}>
<Button className={'block'} asChild variant={'ghost'}>
<Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth:signIn'} />
</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>
)}
</div>
</div>
);

View File

@@ -1,16 +1,20 @@
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { Header } from '@kit/ui/marketing';
import { AppLogo } from '~/components/app-logo';
import { AppLogo } from '@kit/shared/components/app-logo';
import { SiteHeaderAccountSection } from './site-header-account-section';
import { SiteNavigation } from './site-navigation';
export function SiteHeader() {
export function SiteHeader({
accounts,
}: {
accounts: UserWorkspace['accounts'];
}) {
return (
<Header
logo={<AppLogo />}
navigation={<SiteNavigation />}
actions={<SiteHeaderAccountSection />}
actions={<SiteHeaderAccountSection accounts={accounts} />}
/>
);
}

View File

@@ -22,10 +22,6 @@ const links = {
label: 'marketing:documentation',
path: '/docs',
},
Pricing: {
label: 'marketing:pricing',
path: '/pricing',
},
FAQ: {
label: 'marketing:faq',
path: '/faq',

View File

@@ -8,6 +8,7 @@ import { createCmsClient } from '@kit/cms';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Post } from '../../blog/_components/post';
interface BlogPageProps {

View File

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

View File

@@ -6,6 +6,7 @@ import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();

View File

@@ -9,6 +9,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();

View File

@@ -1,11 +1,16 @@
import { use } from 'react';
import { SiteFooter } from '~/(marketing)/_components/site-footer';
import { SiteHeader } from '~/(marketing)/_components/site-header';
import { withI18n } from '~/lib/i18n/with-i18n';
import { loadCurrentUserAccounts } from '~/home/(user)/_lib/server/load-accounts';
function SiteLayout(props: React.PropsWithChildren) {
const accounts = use(loadCurrentUserAccounts());
return (
<div className={'flex min-h-[100vh] flex-col'}>
<SiteHeader />
<SiteHeader accounts={accounts} />
{props.children}

View File

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

View File

@@ -1,21 +1,26 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { SubmitButton } from '@/components/ui/submit-button';
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
import { CompanySubmitData } from '@/lib/types/company';
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { renderCompanyOfferEmail } from '@kit/email-templates';
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
import { FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
const CompanyOfferForm = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const language = useTranslation().i18n.language;
const {
@@ -28,16 +33,29 @@ const CompanyOfferForm = () => {
});
const onSubmit = async (data: CompanySubmitData) => {
setIsLoading(true);
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) formData.append(key, value);
});
try {
sendCompanyOfferEmail(data, language)
sendEmailFromTemplate(
renderCompanyOfferEmail,
{
companyData: data,
language,
},
process.env.CONTACT_EMAIL!,
)
.then(() => router.push('/company-offer/success'))
.catch((error) => alert('error: ' + error));
.catch((error) => {
setIsLoading(false);
alert('Could not send email at this time');
console.warn('error: ' + error);
});
} catch (err: unknown) {
setIsLoading(false);
if (err instanceof Error) {
console.warn('Server validation error: ' + err.message);
}
@@ -76,11 +94,15 @@ const CompanyOfferForm = () => {
<Input type="tel" {...register('phone')} />
</FormItem>
<SubmitButton
disabled={!isValid || isSubmitting}
disabled={isLoading || !isValid || isSubmitting}
pendingText="Saatmine..."
type="submit"
>
{isLoading ? (
<Spinner />
) : (
<Trans i18nKey={'account:requestCompanyAccount:button'} />
)}
</SubmitButton>
</form>
);

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { MedReportLogo } from '@/components/med-report-logo';
import { withI18n } from '@/lib/i18n/with-i18n';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Trans } from '@kit/ui/trans';
import CompanyOfferForm from './_components/company-offer-form';
@@ -10,7 +9,7 @@ import CompanyOfferForm from './_components/company-offer-form';
function CompanyOffer() {
return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
<div className="flex w-1/2 flex-col px-12 py-14 text-center">
<div className="flex flex-col px-12 py-14 text-center md:w-1/2">
<MedReportLogo />
<h1 className="pt-8">
<Trans i18nKey={'account:requestCompanyAccount:title'} />
@@ -20,7 +19,7 @@ function CompanyOffer() {
</p>
<CompanyOfferForm />
</div>
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></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

@@ -1,5 +1,6 @@
import { withI18n } from '~/lib/i18n/with-i18n';
function SiteLayout(props: React.PropsWithChildren) {
return (
<div className={'flex min-h-[100vh] flex-col justify-center items-center'}>

View File

@@ -3,6 +3,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { LayoutDashboard, Users } from 'lucide-react';
import {
@@ -18,13 +19,16 @@ import {
useSidebar,
} from '@kit/ui/shadcn-sidebar';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
export function AdminSidebar() {
export function AdminSidebar({
accounts,
}: {
accounts: UserWorkspace['accounts'];
}) {
const path = usePathname();
const { open } = useSidebar();
return (
<Sidebar collapsible="icon">
<SidebarHeader className={'m-2'}>
@@ -62,7 +66,7 @@ export function AdminSidebar() {
</SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer />
<ProfileAccountDropdownContainer accounts={accounts} />
</SidebarFooter>
</Sidebar>
);

View File

@@ -2,7 +2,7 @@ import { cache } from 'react';
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getAccount } from '~/lib/services/account.service';
interface Params {
params: Promise<{
@@ -28,21 +28,4 @@ async function AccountPage(props: Params) {
export default AdminGuard(AccountPage);
const loadAccount = cache(accountLoader);
async function accountLoader(id: string) {
const client = getSupabaseServerClient();
const { data, error } = await client
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
.eq('id', id)
.single();
if (error) {
throw error;
}
return data;
}
const loadAccount = cache(getAccount);

View File

@@ -1,8 +1,8 @@
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
import { AdminCreateCompanyDialog } from '@kit/admin/components/admin-create-company-dialog';
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
@@ -24,7 +24,7 @@ export const metadata = {
};
async function AccountsPage(props: AdminAccountsPageProps) {
const client = getSupabaseServerClient();
const client = getSupabaseServerClient().schema('medreport');
const searchParams = await props.searchParams;
const page = searchParams.page ? parseInt(searchParams.page) : 1;
@@ -33,18 +33,19 @@ async function AccountsPage(props: AdminAccountsPageProps) {
<PageHeader description={<AppBreadcrumbs />}>
<div className="flex justify-end gap-2">
<AdminCreateUserDialog>
<Button data-test="admin-create-user-button">Create Personal Account</Button>
<Button data-test="admin-create-user-button">
Create Personal Account
</Button>
</AdminCreateUserDialog>
<AdminCreateCompanyDialog>
<Button>Create Company Account</Button>
</AdminCreateCompanyDialog>
</div>
</PageHeader>
<PageBody>
<ServerDataLoader
table={'accounts'}
table="accounts"
client={client}
page={page}
where={(queryBuilder) => {
@@ -55,7 +56,9 @@ async function AccountsPage(props: AdminAccountsPageProps) {
}
if (query) {
queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%,personal_code.ilike.%${query}%`);
queryBuilder.or(
`name.ilike.%${query}%,email.ilike.%${query}%,personal_code.ilike.%${query}%`,
);
}
return queryBuilder;

View File

@@ -8,6 +8,8 @@ import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
export const metadata = {
title: `Super Admin`,
};
@@ -16,12 +18,13 @@ export const dynamic = 'force-dynamic';
export default function AdminLayout(props: React.PropsWithChildren) {
const state = use(getLayoutState());
const workspace = use(loadUserWorkspace());
return (
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<AdminSidebar />
<AdminSidebar accounts={workspace.accounts} />
</PageNavigation>
<PageMobileNavigation>

View File

@@ -4,7 +4,7 @@ import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import billingConfig from '~/config/billing.config';
import { billingConfig } from '@kit/shared/config';
/**
* @description Handle the webhooks from Stripe related to checkouts

View File

@@ -0,0 +1,8 @@
import { config } from 'dotenv';
export default function loadEnv() {
config({ path: `.env` });
if (['local', 'test', 'development', 'production'].includes(process.env.NODE_ENV!)) {
config({ path: `.env.${process.env.NODE_ENV}` });
}
}

View File

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

View File

@@ -0,0 +1,241 @@
import Medusa from "@medusajs/js-sdk"
import type { AdminProductCategory } from "@medusajs/types";
import { listProductTypes } from "@lib/data/products";
import { getAnalysisElements } from "~/lib/services/analysis-element.service";
import { getAnalysisGroups } from "~/lib/services/analysis-group.service";
import { createMedusaSyncFailEntry, createMedusaSyncSuccessEntry } from "~/lib/services/analyses.service";
const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services';
const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis';
const BASE_ANALYSIS_PRODUCT_HANDLE = 'analysis-base';
const getAdminSdk = () => {
const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!;
const medusaPublishableApiKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
const key = process.env.MEDUSA_SECRET_API_KEY!;
if (!medusaBackendUrl || !medusaPublishableApiKey) {
throw new Error('Medusa environment variables not set');
}
return new Medusa({
baseUrl: medusaBackendUrl,
debug: process.env.NODE_ENV === 'development',
apiKey: key,
});
}
async function createProductCategories({
medusa,
}: {
medusa: Medusa;
}) {
const { product_categories: existingProductCategories } = await medusa.admin.productCategory.list();
const parentCategory = existingProductCategories.find(({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE);
if (!parentCategory) {
throw new Error('Parent category not found');
}
const analysisGroups = await getAnalysisGroups();
if (!analysisGroups) {
throw new Error('Analysis groups not found');
}
const createdCategories: AdminProductCategory[] = [];
for (const analysisGroup of analysisGroups) {
console.info(`Processing analysis group '${analysisGroup.name}'`);
const isExisting = existingProductCategories.find(({ name }) => name === analysisGroup.name);
const isNewlyCreated = createdCategories.find(({ name }) => name === analysisGroup.name);
if (isExisting || isNewlyCreated) {
console.info(`Analysis group '${analysisGroup.name}' already exists`);
continue;
}
const createResponse = await medusa.admin.productCategory.create({
name: analysisGroup.name,
handle: analysisGroup.name,
parent_category_id: parentCategory.id,
is_active: true,
metadata: {
analysisGroupOriginalId: analysisGroup.original_id,
analysisGroupId: analysisGroup.id,
},
});
console.info(`Successfully created category, id=${createResponse.product_category.id}`);
createdCategories.push(createResponse.product_category);
}
}
async function getChildProductCategories({
medusa,
}: {
medusa: Medusa;
}) {
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
const childCategories = allCategories.filter(({ parent_category_id }) => parent_category_id !== null);
return childCategories;
}
async function deleteProductCategories({
medusa,
categories,
}: {
medusa: Medusa;
categories: AdminProductCategory[];
}) {
for (const category of categories) {
await medusa.admin.productCategory.delete(category.id);
}
}
/**
* In case a reset is needed
*/
async function deleteProducts({
medusa,
}: {
medusa: Medusa;
}) {
const { products: existingProducts } = await medusa.admin.product.list({
fields: 'id,collection_id',
limit: 1000,
});
await Promise.all(existingProducts.filter((a) => !a.collection_id).map(({ id }) => medusa.admin.product.delete(id)));
}
async function getAnalysisPackagesType() {
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE);
if (!analysisPackagesType) {
throw new Error('Synlab analysis packages type not found');
}
return analysisPackagesType;
}
async function getProductDefaultFields({
medusa,
}: {
medusa: Medusa;
}) {
const baseProductsResponse = await medusa.admin.product.list({ handle: BASE_ANALYSIS_PRODUCT_HANDLE })
const baseProduct = baseProductsResponse.products[0];
if (!baseProduct) {
throw new Error('Base product not found');
}
const defaultSalesChannels = baseProduct.sales_channels;
if (!Array.isArray(defaultSalesChannels)) {
throw new Error('Base analysis product has no required sales channels');
}
const defaultProductOption = baseProduct.options;
if (!Array.isArray(defaultProductOption)) {
throw new Error('Base analysis product has no required options');
}
const defaultProductVariant = baseProduct.variants?.[0];
if (!defaultProductVariant) {
throw new Error('Base analysis product has no required variant');
}
return {
defaultSalesChannels,
defaultProductOption,
defaultProductVariant,
}
}
async function createProducts({
medusa,
}: {
medusa: Medusa;
}) {
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
const [
{ products: existingProducts },
analysisElements,
analysisPackagesType,
{
defaultSalesChannels,
defaultProductOption,
defaultProductVariant,
}
] = await Promise.all([
medusa.admin.product.list({
category_id: allCategories.map(({ id }) => id),
}),
getAnalysisElements({}),
getAnalysisPackagesType(),
getProductDefaultFields({ medusa }),
])
for (const analysisElement of analysisElements) {
const { analysis_id_original: originalId } = analysisElement;
const isExisting = existingProducts.find(({ metadata }) => metadata?.analysisIdOriginal === originalId);
if (isExisting) {
console.info(`Analysis element '${analysisElement.analysis_name_lab}' already exists`);
continue;
}
const { analysis_name_lab: name } = analysisElement;
if (!name) {
console.error(`Analysis element '${originalId}' has no name`);
continue;
}
const category = allCategories.find(({ metadata }) => metadata?.analysisGroupId === analysisElement.parent_analysis_group_id);
if (!category) {
console.error(`Category not found for analysis element '${name}'`);
continue;
}
await medusa.admin.product.create({
title: name,
handle: `analysis-element-${analysisElement.id}`,
categories: [{ id: category.id }],
options: defaultProductOption.map(({ id, title, values }) => ({
id,
title,
values: values?.map(({ value }) => value) ?? [],
})),
metadata: {
analysisIdOriginal: originalId,
},
is_giftcard: false,
discountable: false,
status: 'published',
sales_channels: defaultSalesChannels.map(({ id }) => ({ id })),
variants: [
{
title: defaultProductVariant.title!,
prices: defaultProductVariant.prices!,
manage_inventory: false,
},
],
type_id: analysisPackagesType.id,
});
}
}
export default async function syncAnalysisGroupsStore() {
const medusa = getAdminSdk();
try {
await createProductCategories({ medusa });
// const categories = await getChildProductCategories({ medusa });
// await deleteProductCategories({ medusa, categories });
// await deleteProducts({ medusa });
// return;
await createProducts({ medusa });
await createMedusaSyncSuccessEntry();
} catch (e) {
await createMedusaSyncFailEntry(JSON.stringify(e));
console.error(e);
throw new Error(
`Failed to sync analyses to Medusa, error: ${JSON.stringify(e)}`,
);
}
}

View File

@@ -0,0 +1,178 @@
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 } 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 } 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';
function toArray<T>(input?: T | T[] | null): T[] {
if (!input) return [];
return Array.isArray(input) ? input : [input];
}
const WRITE_XML_TO_FILE = false as boolean;
export default async function syncAnalysisGroups() {
const baseUrl = process.env.MEDIPOST_URL;
const user = process.env.MEDIPOST_USER;
const password = process.env.MEDIPOST_PASSWORD;
const sender = process.env.MEDIPOST_MESSAGE_SENDER;
if (!baseUrl || !user || !password || !sender) {
throw new Error('Could not access all necessary environment variables');
}
try {
console.info('Getting latest public message id');
const lastCheckedDate = await getLastCheckedDate();
const latestMessage = await getLatestPublicMessageListItem();
if (!latestMessage) {
console.info('No new data received');
await createNoNewDataReceivedEntry();
return;
}
console.info('Getting public message with id: ', latestMessage.messageId);
const { data: publicMessageData } = await axios.get(baseUrl, {
params: {
Action: 'GetPublicMessage',
User: user,
Password: password,
MessageId: latestMessage.messageId,
},
headers: {
Accept: 'application/xml',
},
});
if (WRITE_XML_TO_FILE) {
fs.writeFileSync('public-messages-list-response.xml', publicMessageData);
}
const parser = new XMLParser({ ignoreAttributes: false });
const parsed: IMedipostPublicMessageDataParsed = parser.parse(publicMessageData);
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
throw new Error(
`Failed to get public message (id: ${latestMessage.messageId})`,
);
}
const existingAnalysisGroups = await getAnalysisGroups();
// SAVE PUBLIC MESSAGE DATA
const providers = toArray(parsed?.Saadetis?.Teenused.Teostaja);
const analysisGroups = providers.flatMap((provider) =>
toArray(provider.UuringuGrupp),
);
if (!parsed || !analysisGroups.length) {
console.info('No analysis groups data received');
await createNoDataReceivedEntry();
return;
}
const codes: ICode[] = [];
for (const analysisGroup of analysisGroups) {
const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId);
if (existingAnalysisGroup) {
console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`);
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;
const insertedAnalysisElementId = await createAnalysisElement({
analysisElement,
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 = 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,
})),
);
}
}
}
}
}
console.info('Inserting codes');
await createCodes(codes);
console.info('Inserting sync entry');
await createSyncSuccessEntry();
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
await createSyncFailEntry(JSON.stringify({
message: errorMessage,
stack: e instanceof Error ? e.stack : undefined,
name: e instanceof Error ? e.name : 'Unknown',
}, null, 2));
console.error('Sync failed:', e);
throw new Error(
`Failed to sync public message data, error: ${errorMessage}`,
);
}
}

View File

@@ -0,0 +1,62 @@
import { readPrivateMessageResponse } from "~/lib/services/medipost.service";
type ProcessedMessage = {
messageId: string;
hasAnalysisResponse: boolean;
hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined;
};
type GroupedResults = {
processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
};
export default async function syncAnalysisResults() {
console.info("Syncing analysis results");
let processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = [];
while (true) {
const result = await readPrivateMessageResponse({ excludedMessageIds });
if (result.messageId) {
processedMessages.push(result as ProcessedMessage);
}
if (!result.messageId) {
console.info("No more messages to process");
break;
}
if (!excludedMessageIds.includes(result.messageId)) {
excludedMessageIds.push(result.messageId);
} else {
break;
}
}
const groupedResults = processedMessages.reduce((acc, result) => {
if (result.medusaOrderId) {
if (result.hasAnalysisResponse) {
if (!acc.processed) {
acc.processed = [];
}
acc.processed.push({
messageId: result.messageId,
medusaOrderId: result.medusaOrderId,
});
} else {
if (!acc.waitingForResults) {
acc.waitingForResults = [];
}
acc.waitingForResults.push({
messageId: result.messageId,
medusaOrderId: result.medusaOrderId,
});
}
}
return acc;
}, {} as GroupedResults);
console.info(`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`);
}

View File

@@ -1,46 +1,29 @@
import { createClient as createCustomClient } from '@supabase/supabase-js';
import axios from 'axios';
import { config } from 'dotenv';
async function syncData() {
if (process.env.NODE_ENV === 'local') {
config({ path: `.env.${process.env.NODE_ENV}` });
}
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { ISearchLoadResponse } from '~/lib/types/connected-online';
export default async function syncConnectedOnline() {
const isProd = process.env.NODE_ENV === 'production';
const baseUrl = process.env.CONNECTED_ONLINE_URL;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceRoleKey =
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY;
if (!baseUrl || !supabaseUrl || !supabaseServiceRoleKey) {
if (!baseUrl) {
throw new Error('Could not access all necessary environment variables');
}
const supabase = createCustomClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
});
const supabase = getSupabaseServerAdminClient();
try {
const response = await axios.post(`${baseUrl}/Search_Load`, {
const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, {
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
param: "{'Value':'|et|-1'}", // get all available services in Estonian
});
const responseData: {
Value: any;
Data: any;
ErrorCode: number;
ErrorMessage: string;
} = JSON.parse(response.data.d);
const responseData: ISearchLoadResponse = JSON.parse(response.data.d);
if (responseData?.ErrorCode !== 0) {
throw new Error('Failed to get Connected Online data');
@@ -88,19 +71,19 @@ async function syncData() {
return {
id: service.ID,
clinic_id: service.ClinicID,
code: service.Code,
description: service.Description || null,
display: service.Display,
duration: service.Duration,
has_free_codes: !!service.HasFreeCodes,
sync_id: service.SyncID,
name: service.Name,
description: service.Description || null,
price: service.Price,
requires_payment: !!service.RequiresPayment,
duration: service.Duration,
neto_duration: service.NetoDuration,
display: service.Display,
price_periods: service.PricePeriods || null,
online_hide_duration: service.OnlineHideDuration,
online_hide_price: service.OnlineHidePrice,
price: service.Price,
price_periods: service.PricePeriods || null,
requires_payment: !!service.RequiresPayment,
sync_id: service.SyncID,
code: service.Code,
has_free_codes: !!service.HasFreeCodes,
};
});
@@ -148,5 +131,3 @@ async function syncData() {
);
}
}
syncData();

View File

@@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
export default function validateApiKey(request: NextRequest) {
const envApiKey = process.env.JOBS_API_TOKEN;
const requestApiKey = request.headers.get('x-jobs-api-key');
if (requestApiKey !== envApiKey) {
throw new Error('Unauthorized');
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
import { retrieveOrder } from "@lib/data/orders";
import { getMedipostDispatchTries } from "~/lib/services/audit.service";
export const POST = async (request: NextRequest) => {
loadEnv();
const { medusaOrderId } = await request.json();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
const tries = await getMedipostDispatchTries(medusaOrderId);
if (tries >= 3) {
return NextResponse.json({
message: 'Order has been retried too many times',
}, { status: 400 });
}
try {
const medusaOrder = await retrieveOrder(medusaOrderId);
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
console.info("Successfully sent order to medipost");
return NextResponse.json({
message: 'Successfully sent order to medipost',
}, { status: 200 });
} catch (e) {
console.error("Error sending order to medipost", e);
return NextResponse.json({
message: 'Failed to send order to medipost',
}, { status: 500 });
}
};

View File

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

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import syncAnalysisGroupsStore from "../handler/sync-analysis-groups-store";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncAnalysisGroupsStore();
console.info("Successfully synced analysis groups store");
return NextResponse.json({
message: 'Successfully synced analysis groups store',
}, { status: 200 });
} catch (e) {
console.error("Error syncing analysis groups store", e);
return NextResponse.json({
message: 'Failed to sync analysis groups store',
}, { status: 500 });
}
};

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import syncAnalysisGroups from "../handler/sync-analysis-groups";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncAnalysisGroups();
console.info("Successfully synced analysis groups");
return NextResponse.json({
message: 'Successfully synced analysis groups',
}, { status: 200 });
} catch (e) {
console.error("Error syncing analysis groups", e);
return NextResponse.json({
message: 'Failed to sync analysis groups',
}, { status: 500 });
}
};

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import syncAnalysisResults from "../handler/sync-analysis-results";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncAnalysisResults();
console.info("Successfully synced analysis results");
return NextResponse.json({
message: 'Successfully synced analysis results',
}, { status: 200 });
} catch (e) {
console.error("Error syncing analysis results", e);
return NextResponse.json({
message: 'Failed to sync analysis results',
}, { status: 500 });
}
};

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
import syncConnectedOnline from "../handler/sync-connected-online";
export const POST = async (request: NextRequest) => {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await syncConnectedOnline();
console.info("Successfully synced connected-online");
return NextResponse.json({
message: 'Successfully synced connected-online',
}, { status: 200 });
} catch (e) {
console.error("Error syncing connected-online", e);
return NextResponse.json({
message: 'Failed to sync connected-online',
}, { status: 500 });
}
};

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { getAnalysisOrdersAdmin } from "~/lib/services/order.service";
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from "~/lib/services/account.service";
import { getOrderedAnalysisIds } from "~/lib/services/medipost.service";
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key";
export async function POST(request: NextRequest) {
loadEnv();
try {
validateApiKey(request);
} catch (e) {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
const analysisOrders = await getAnalysisOrdersAdmin({ orderStatus: 'PROCESSING' });
console.error(`Sending test responses for ${analysisOrders.length} analysis orders`);
for (const medreportOrder of analysisOrders) {
const medusaOrderId = medreportOrder.medusa_order_id;
const medusaOrder = await retrieveOrder(medusaOrderId)
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
const idsToSend = orderedAnalysisElementsIds;
const messageXml = await composeOrderTestResponseXML({
person: {
idCode: account.personal_code!,
firstName: account.name ?? '',
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
});
try {
await sendPrivateMessageTestResponse({ messageXml });
} catch (error) {
console.error("Error sending private message test response: ", error);
}
}
return NextResponse.json({ success: true });
}

View File

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

View File

@@ -7,9 +7,11 @@ import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
import { pathsConfig } from '@kit/shared/config';
import { withI18n } from '~/lib/i18n/with-i18n';
interface AuthCallbackErrorPageProps {
searchParams: Promise<{
error: string;

View File

@@ -1,6 +1,6 @@
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;

View File

@@ -1,18 +1,62 @@
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth';
import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { pathsConfig } from '@kit/shared/config';
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
const ERROR_PATH = '/auth/callback/error';
const redirectOnError = (searchParams?: string) => {
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
}
export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());
const { searchParams } = new URL(request.url);
const { nextPath } = await service.exchangeCodeForSession(request, {
joinTeamPath: pathsConfig.app.joinTeam,
redirectPath: pathsConfig.app.home,
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') ?? '',
});
return redirect(nextPath);
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 account = await api.getPersonalAccountByUserId(
oauthResult.user.id,
);
if (!account.email || !account.name || !account.last_name) {
return redirect(pathsConfig.auth.updateAccount);
}
return redirect(redirectPath);
}

View File

@@ -1,6 +1,6 @@
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;

View File

@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { pathsConfig } from '@kit/shared/config';
export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());

View File

@@ -2,7 +2,7 @@
import React from 'react';
import pathsConfig from '@/config/paths.config';
import { pathsConfig } from '@kit/shared/config';
import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
@@ -25,7 +25,7 @@ const MembershipConfirmationNotification: React.FC<{
descriptionKey="account:membershipConfirmation:successDescription"
buttonProps={{
buttonTitleKey: 'account:membershipConfirmation:successButton',
href: pathsConfig.app.selectPackage,
href: pathsConfig.app.home,
}}
/>
);

View File

@@ -1,5 +1,6 @@
import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) {
return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>

View File

@@ -1,14 +1,17 @@
import { redirect } from 'next/navigation';
import pathsConfig from '@/config/paths.config';
import { pathsConfig } from '@kit/shared/config';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import MembershipConfirmationNotification from './_components/membership-confirmation-notification';
async function UpdateAccountSuccess() {
async function MembershipConfirmation() {
const client = getSupabaseServerClient();
const {
@@ -18,8 +21,12 @@ async function UpdateAccountSuccess() {
if (!user?.id) {
redirect(pathsConfig.app.home);
}
await createPageViewLog({
accountId: user.id,
action: PageViewAction.REGISTRATION_SUCCESS,
});
return <MembershipConfirmationNotification userId={user.id} />;
}
export default withI18n(UpdateAccountSuccess);
export default withI18n(MembershipConfirmation);

View File

@@ -1,6 +1,6 @@
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;

View File

@@ -5,10 +5,12 @@ import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import pathsConfig from '~/config/paths.config';
import { pathsConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;

View File

@@ -1,16 +1,9 @@
import Link from 'next/link';
import { pathsConfig, authConfig } from '@kit/shared/config';
import { register } from 'module';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
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<{
@@ -28,47 +21,14 @@ export const generateMetadata = async () => {
};
async function SignInPage({ searchParams }: SignInPageProps) {
const { invite_token: inviteToken, next = pathsConfig.app.home } =
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
await searchParams;
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
if (authConfig.providers.password) {
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
}
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>
</>
);
return <SignInPageClientRedirect />;
}
export default withI18n(SignInPage);

View File

@@ -1,6 +1,6 @@
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;

View File

@@ -1,13 +1,14 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
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 authConfig from '~/config/auth.config';
import pathsConfig from '~/config/paths.config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => {
@@ -37,6 +38,10 @@ 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,8 +2,6 @@
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,34 +19,54 @@ import {
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../schemas/update-account.schema';
import { onUpdateAccount } from '../server/actions/update-account-actions';
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account';
import { z } from 'zod';
export function UpdateAccountForm({ user }: { user: User }) {
type UpdateAccountFormValues = z.infer<typeof UpdateAccountSchema>;
export function UpdateAccountForm({
defaultValues,
}: {
defaultValues: UpdateAccountFormValues,
}) {
const form = useForm({
resolver: zodResolver(UpdateAccountSchema),
mode: 'onChange',
defaultValues: {
firstName: '',
lastName: '',
personalCode: user.user_metadata.personalCode ?? '',
email: user.email,
phone: '',
city: '',
weight: user.user_metadata.weight ?? undefined,
height: user.user_metadata.height ?? undefined,
userConsent: false,
},
defaultValues,
});
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(onUpdateAccount)}
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
>
<FormField
name="firstName"
disabled={hasFirstName}
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -64,6 +82,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="lastName"
disabled={hasLastName}
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -79,6 +98,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="personalCode"
disabled={hasPersonalCode}
render={({ field }) => (
<FormItem>
<FormLabel>
@@ -94,13 +114,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField
name="email"
disabled={hasEmail}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:email'} />
</FormLabel>
<FormControl>
<Input {...field} disabled />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -0,0 +1,62 @@
import { z } from 'zod';
import parsePhoneNumber from 'libphonenumber-js/min';
export const UpdateAccountSchema = z.object({
firstName: z
.string({
error: 'First name is required',
})
.nonempty(),
lastName: z
.string({
error: 'Last name is required',
})
.nonempty(),
personalCode: z
.string({
error: 'Personal code is required',
})
.nonempty(),
email: z.string().email({
message: 'Email is required',
}),
phone: z
.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',
}
),
city: z.string().optional(),
weight: z
.number({
error: (issue) =>
issue.input === undefined
? 'Weight is required'
: 'Weight must be a number',
})
.gt(0, { message: 'Weight must be greater than 0' }),
height: z
.number({
error: (issue) =>
issue.input === undefined
? 'Height is required'
: 'Height must be a number',
})
.gt(0, { message: 'Height must be greater than 0' }),
userConsent: z.boolean().refine((val) => val === true, {
message: 'Must be true',
}),
});

View File

@@ -2,26 +2,16 @@
import { redirect } from 'next/navigation';
import { updateCustomer } from '@lib/data/customer';
import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { updateCustomer } from '@lib/data/customer';
import { pathsConfig } from '@kit/shared/config';
import { UpdateAccountSchema } from '../../schemas/update-account.schema';
import { createAuthApi } from '../api';
export interface AccountSubmitData {
firstName: string;
lastName: string;
personalCode: string;
email: string;
phone?: string;
city?: string;
weight: number | null;
height: number | null;
userConsent: boolean;
}
import { UpdateAccountSchema } from '../schemas/update-account.schema';
export const onUpdateAccount = enhanceAction(
async (params: AccountSubmitData) => {
@@ -38,11 +28,15 @@ 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);
}
const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation();

View File

@@ -1,5 +1,6 @@
import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) {
return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>

View File

@@ -1,30 +1,52 @@
import { redirect } from 'next/navigation';
import { BackButton } from '@/components/back-button';
import { MedReportLogo } from '@/components/med-report-logo';
import pathsConfig from '@/config/paths.config';
import { signOutAction } from '@/lib/actions/sign-out';
import { UpdateAccountForm } from '@/packages/features/auth/src/components/update-account-form';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { BackButton } from '@kit/shared/components/back-button';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { pathsConfig } from '@kit/shared/config';
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 w-1/2 min-w-md flex-col px-12 pt-7 pb-22 text-center">
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
<BackButton onBack={signOutAction} />
<MedReportLogo />
<h1 className="pt-8">
@@ -33,9 +55,9 @@ async function UpdateAccount() {
<p className="text-muted-foreground pt-1 text-sm">
<Trans i18nKey={'account:updateAccount:description'} />
</p>
<UpdateAccountForm user={user} />
<UpdateAccountForm defaultValues={defaultValues} />
</div>
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></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

@@ -1,6 +1,6 @@
import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '~/components/app-logo';
import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) {
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;

View File

@@ -4,7 +4,8 @@ import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { pathsConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
@@ -40,7 +41,7 @@ async function VerifyPage(props: Props) {
}
const nextPath = (await props.searchParams).next;
const redirectPath = nextPath ?? pathsConfig.app.home;
const redirectPath = !!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
return (
<MultiFactorChallengeContainer

View File

@@ -0,0 +1,289 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { capitalize } from 'lodash';
import { useForm } from 'react-hook-form';
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
import {
getDOBWithAgeStringFromPersonalCode,
getResultSetName,
} from '@kit/doctor/lib/helpers';
import {
AnalysisResponse,
DoctorFeedback,
Order,
Patient,
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
import {
DoctorAnalysisFeedbackForm,
doctorAnalysisFeedbackFormSchema,
} from '@kit/doctor/schema/doctor-analysis.schema';
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
import {
useCurrentLocaleLanguageNames
} from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
import { Trans } from '@kit/ui/trans';
import { bmiFromMetric } from '~/lib/utils';
import DoctorAnalysisWrapper from './doctor-analysis-wrapper';
import DoctorJobSelect from './doctor-job-select';
export default function AnalysisView({
patient,
order,
analyses,
feedback,
}: {
patient: Patient;
order: Order;
analyses: AnalysisResponse[];
feedback?: DoctorFeedback;
}) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isDraftSubmitting, setIsDraftSubmitting] = useState(false);
const { data: user } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const isInProgress = !!(
!!feedback?.status &&
feedback?.doctor_user_id &&
feedback?.status !== 'COMPLETED'
);
const isCurrentDoctorJob =
!!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id;
const isReadOnly =
!isInProgress ||
(!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id);
const form = useForm({
resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
reValidateMode: 'onChange',
defaultValues: {
feedbackValue: feedback?.value ?? '',
userId: patient.userId,
},
});
const queryClient = useQueryClient();
if (!patient || !order || !analyses) {
return null;
}
const onSubmit = async (
data: DoctorAnalysisFeedbackForm,
status: 'DRAFT' | 'COMPLETED',
) => {
const result = await giveFeedbackAction({
...data,
analysisOrderId: order.analysisOrderId,
status,
});
if (!result.success) {
return toast.error(<Trans i18nKey="common:genericServerError" />);
}
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
});
toast.success(<Trans i18nKey={'doctor:updateFeedbackSuccess'} />);
return setIsConfirmOpen(false);
};
const handleDraftSubmit = async (e: React.FormEvent) => {
setIsDraftSubmitting(true);
e.preventDefault();
form.formState.errors.feedbackValue = undefined;
const formData = form.getValues();
await onSubmit(formData, 'DRAFT');
setIsDraftSubmitting(false);
};
const handleCompleteSubmit = form.handleSubmit(async () => {
setIsConfirmOpen(true);
});
const confirmComplete = form.handleSubmit(async (data) => {
await onSubmit(data, 'COMPLETED');
});
return (
<>
<div className="xs:flex xs:justify-between">
<h3>
<Trans
i18nKey={getResultSetName(
order.title,
order.isPackage,
Object.keys(analyses)?.length,
)}
/>
</h3>
<div className="xs:flex hidden">
<DoctorJobSelect
analysisOrderId={order.analysisOrderId}
userId={patient.userId}
doctorUserId={feedback?.doctor_user_id}
isRemovable={isCurrentDoctorJob && isInProgress}
onJobUpdate={() =>
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
})
}
/>
</div>
</div>
<div className="xs:grid-cols-2 grid">
<div className="font-bold">
<Trans i18nKey="doctor:name" />
</div>
<div>{getFullName(patient.firstName, patient.lastName)}</div>
<div className="font-bold">
<Trans i18nKey="doctor:personalCode" />
</div>
<div>{patient.personalCode ?? ''}</div>
<div className="font-bold">
<Trans i18nKey="doctor:dobAndAge" />
</div>
<div>{getDOBWithAgeStringFromPersonalCode(patient.personalCode)}</div>
<div className="font-bold">
<Trans i18nKey="doctor:height" />
</div>
<div>{patient.height}</div>
<div className="font-bold">
<Trans i18nKey="doctor:weight" />
</div>
<div>{patient.weight}</div>
<div className="font-bold">
<Trans i18nKey="doctor:bmi" />
</div>
<div>
{patient?.weight && patient?.height
? bmiFromMetric(patient.weight, patient.height)
: '-'}
</div>
<div className="font-bold">
<Trans i18nKey="doctor:smoking" />
</div>
<div>-</div>
<div className="font-bold">
<Trans i18nKey="doctor:phone" />
</div>
<div>{patient.phone}</div>
<div className="font-bold">
<Trans i18nKey="doctor:email" />
</div>
<div>{patient.email}</div>
<div className="font-bold">
<Trans i18nKey="common:language" />
</div>
<div>
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
</div>
</div>
<div className="xs:hidden block">
<DoctorJobSelect
className="w-full"
analysisOrderId={order.analysisOrderId}
userId={patient.userId}
doctorUserId={feedback?.doctor_user_id}
isRemovable={isCurrentDoctorJob && isInProgress}
onJobUpdate={() =>
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
})
}
/>
</div>
<h3>
<Trans i18nKey="doctor:results" />
</h3>
<div className="flex flex-col gap-2">
{analyses.map((analysisData) => {
return (
<DoctorAnalysisWrapper
key={analysisData.id}
analysisData={analysisData}
/>
);
})}
</div>
<h3>
<Trans i18nKey="doctor:feedback" />
</h3>
<p>{feedback?.value ?? '-'}</p>
{!isReadOnly && (
<Form {...form}>
<form className="space-y-4 lg:w-1/2">
<FormField
control={form.control}
name="feedbackValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea {...field} disabled={isReadOnly} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="xs:flex block justify-end gap-2 space-y-2">
<Button
type="button"
variant="outline"
onClick={handleDraftSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
<Trans i18nKey="common:saveAsDraft" />
</Button>
<Button
type="button"
onClick={handleCompleteSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
<Trans i18nKey="common:save" />
</Button>
</div>
</form>
</Form>
)}
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={confirmComplete}
titleKey="doctor:confirmFeedbackModal.title"
descriptionKey="doctor:confirmFeedbackModal.description"
/>
</>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
import { AnalysisResponse } from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { formatDate } from '@kit/shared/utils';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@kit/ui/collapsible';
import { Trans } from '@kit/ui/trans';
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
export default function DoctorAnalysisWrapper({
analysisData,
}: {
analysisData: AnalysisResponse;
}) {
const { t } = useTranslation();
return (
<Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis}
asChild
>
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
<Analysis
startIcon={
analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" />
)
}
endIcon={
analysisData.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:
</strong>{' '}
{analysisData.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: analysisData.analysis_name,
}}
results={analysisData}
/>
</div>
</CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && (
<CollapsibleContent>
<div className="my-1 flex flex-col">
<Analysis
endIcon={
analysisData.latestPreviousAnalysis.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.latestPreviousAnalysis.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:{' '}
</strong>
{analysisData.latestPreviousAnalysis.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: t('doctor:previousResults', {
date: formatDate(
analysisData.latestPreviousAnalysis.response_time,
),
}),
}}
results={analysisData.latestPreviousAnalysis}
/>
</div>
</CollapsibleContent>
)}
</Collapsible>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import {
getOpenResponsesAction,
getOtherResponsesAction,
getUserDoneResponsesAction,
getUserInProgressResponsesAction,
} from '@kit/doctor/actions/table-data-fetching-actions';
import ResultsTableWrapper from './results-table-wrapper';
export default function Dashboard() {
return (
<>
<ResultsTableWrapper
titleKey="doctor:openReviews"
action={getOpenResponsesAction}
queryKey="doctor-open-jobs"
/>
<ResultsTableWrapper
titleKey="doctor:myReviews"
action={getUserInProgressResponsesAction}
queryKey="doctor-in-progress-jobs"
/>
<ResultsTableWrapper
titleKey="doctor:completedReviews"
action={getUserDoneResponsesAction}
queryKey="doctor-done-jobs"
/>
<ResultsTableWrapper
titleKey="doctor:otherReviews"
action={getOtherResponsesAction}
queryKey="doctor-other-jobs"
/>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isDoctor } from '@kit/doctor/lib/server/utils/is-doctor';
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
/**
* DoctorGuard is a server component wrapper that checks if the user is a doctor before rendering the component.
* If the user is not a doctor, we redirect to a 404.
* @param Component - The Page or Layout component to wrap
*/
export function DoctorGuard<Params extends object>(
Component: LayoutOrPageComponent<Params>,
) {
return async function DoctorGuardServerComponentWrapper(params: Params) {
const client = getSupabaseServerClient();
const isUserDoctor = await isDoctor(client);
// if the user is not a super-admin, we redirect to a 404
if (!isUserDoctor) {
notFound();
}
return <Component {...params} />;
};
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { LoaderCircle } from 'lucide-react';
import {
selectJobAction,
unselectJobAction,
} from '@kit/doctor/actions/doctor-server-actions';
import { ErrorReason } from '@kit/doctor/schema/error.type';
import { Button, ButtonProps } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
export default function DoctorJobSelect({
className,
size = 'sm',
doctorUserId,
doctorName,
analysisOrderId,
userId,
isRemovable,
onJobUpdate,
linkTo,
}: {
className?: string;
size?: ButtonProps['size'];
doctorUserId?: string | null;
doctorName?: string;
analysisOrderId: number;
userId: string;
isRemovable?: boolean;
linkTo?: string;
onJobUpdate: () => void;
}) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleSelectJob = () => {
startTransition(async () => {
const result = await selectJobAction({
analysisOrderId,
userId,
});
if (result?.success) {
onJobUpdate();
linkTo && router.push(linkTo);
} else {
toast.error(
<Trans
i18nKey={`doctor:error.${result.reason ?? ErrorReason.UNKNOWN}`}
/>,
);
if (result.reason === ErrorReason.JOB_ASSIGNED) {
onJobUpdate();
}
}
});
};
const handleUnselectJob = () => {
startTransition(async () => {
const result = await unselectJobAction({
analysisOrderId,
});
if (result?.success) {
onJobUpdate();
} else {
toast.error(
<Trans
i18nKey={`doctor:error.${result.reason ?? ErrorReason.UNKNOWN}`}
/>,
);
}
});
};
if (isRemovable) {
return (
<Button
className={cn('w-16', className)}
size={size}
variant="destructive"
onClick={handleUnselectJob}
disabled={isPending}
>
{isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Trans i18nKey="doctor:unselectJob" />
)}
</Button>
);
}
if (!doctorUserId) {
return (
<Button
className={cn('w-16', className)}
size={size}
onClick={handleSelectJob}
disabled={isPending}
>
{isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Trans i18nKey="doctor:selectJob" />
)}
</Button>
);
}
return <>{doctorName}</>;
}

View File

@@ -0,0 +1,103 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { LayoutDashboard } from 'lucide-react';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { pathsConfig } from '@kit/shared/config';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
useSidebar,
} from '@kit/ui/shadcn-sidebar';
import { Trans } from '@kit/ui/trans';
export function DoctorSidebar({
accounts,
}: {
accounts: UserWorkspace['accounts'];
}) {
const path = usePathname();
const { open } = useSidebar();
return (
<Sidebar collapsible="icon">
<SidebarHeader className={'m-2'}>
<AppLogo
href={pathsConfig.app.doctor}
className="max-w-full"
compact={!open}
/>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>
<Trans i18nKey="common:doctor" />
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuButton
isActive={path === pathsConfig.app.doctor}
asChild
>
<Link className={'flex gap-2.5'} href={pathsConfig.app.doctor}>
<LayoutDashboard className={'h-4'} />
<Trans i18nKey={'doctor:sidebar.dashboard'} />
</Link>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path === pathsConfig.app.openJobs}
asChild
>
<Link
className={'flex gap-2.5'}
href={pathsConfig.app.openJobs}
>
<LayoutDashboard className={'h-4'} />
<Trans i18nKey={'doctor:sidebar.openReviews'} />
</Link>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path === pathsConfig.app.myJobs}
asChild
>
<Link className={'flex gap-2.5'} href={pathsConfig.app.myJobs}>
<LayoutDashboard className={'h-4'} />
<Trans i18nKey={'doctor:sidebar.myReviews'} />
</Link>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path === pathsConfig.app.completedJobs}
asChild
>
<Link
className={'flex gap-2.5'}
href={pathsConfig.app.completedJobs}
>
<LayoutDashboard className={'h-4'} />
<Trans i18nKey={'doctor:sidebar.completedReviews'} />
</Link>
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer accounts={accounts} />
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,50 @@
import Link from 'next/link';
import { Menu } from 'lucide-react';
import { pathsConfig } from '@kit/shared/config';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { Trans } from '@kit/ui/trans';
export function DoctorMobileNavigation() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-8 w-8'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Link href={pathsConfig.app.home}>
<Trans i18nKey={'common:routes.home'} />
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={pathsConfig.app.doctor}>
<Trans i18nKey={'doctor:sidebar.dashboard'} />
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={pathsConfig.app.openJobs}>
<Trans i18nKey={'doctor:sidebar.openReviews'} />
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={pathsConfig.app.myJobs}>
<Trans i18nKey={'doctor:sidebar.myReviews'} />
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={pathsConfig.app.completedJobs}>
<Trans i18nKey={'doctor:sidebar.completedReviews'} />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
PaginatedData,
ResponseTable,
ServerActionResponse,
} from '@kit/doctor/schema/doctor-analysis.schema';
import TableSkeleton from '@kit/shared/components/table-skeleton';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import ResultsTable from './results-table';
const PAGE_SIZE = 10;
export default function ResultsTableWrapper({
titleKey,
action,
queryKey,
}: {
titleKey: string;
action: ({
page,
pageSize,
}: {
page: number;
pageSize: number;
}) => Promise<ServerActionResponse<PaginatedData<ResponseTable>>>;
queryKey: string;
}) {
const [page, setPage] = useState(1);
const {
data: jobs,
isLoading,
isError,
isFetching,
} = useQuery({
queryKey: [queryKey, 'doctor-jobs', page],
queryFn: async () => await action({ page: page, pageSize: PAGE_SIZE }),
placeholderData: (previousData) => previousData,
});
const queryClient = useQueryClient();
const onJobUpdate = () => {
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
});
};
const createPageChangeHandler = (setPage: (page: number) => void) => {
return async ({ page }: { page: number; pageSize: number }) => {
setPage(page);
return { success: true, data: null };
};
};
if (isLoading) {
return (
<>
<h3>
<Trans i18nKey={titleKey} />
</h3>
<div className="relative">
<div
className={`transition-opacity duration-200 ${
isFetching ? 'opacity-50' : 'opacity-100'
}`}
>
<TableSkeleton />
</div>
</div>
</>
);
}
if (isError) {
return (
<>
<h3>
<Trans i18nKey={titleKey} />
</h3>
<div className="flex items-center justify-center p-8">
<div className="text-lg text-red-600">
<Trans i18nKey="common:genericServerError" />
</div>
</div>
</>
);
}
return (
<>
<h3>
<Trans i18nKey={titleKey} />
</h3>
<div className="relative">
<div
className={cn('opacity-100 transition-opacity duration-200', {
'opacity-50': isFetching,
})}
>
<ResultsTable
results={jobs?.data?.data || []}
pagination={jobs?.data?.pagination}
fetchAction={createPageChangeHandler(setPage)}
onJobUpdate={onJobUpdate}
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import { useTransition } from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { Eye } from 'lucide-react';
import { getResultSetName } from '@kit/doctor/lib/helpers';
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
import { pathsConfig } from '@kit/shared/config';
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table';
import { Trans } from '@kit/ui/trans';
import DoctorJobSelect from './doctor-job-select';
import { capitalize } from 'lodash';
export default function ResultsTable({
results = [],
pagination = {
currentPage: 1,
totalPages: 1,
totalCount: 0,
pageSize: 10,
},
fetchAction,
onJobUpdate,
}: {
results: ResponseTable[] | null;
pagination?: {
currentPage: number;
totalPages: number;
totalCount: number;
pageSize: number;
};
fetchAction: ({
page,
pageSize,
}: {
page: number;
pageSize: number;
}) => Promise<{
success: boolean;
data: null;
}>;
onJobUpdate: () => void;
}) {
const [isPending, startTransition] = useTransition();
const { data: currentUser } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const fetchPage = async (page: number) => {
startTransition(async () => {
const result = await fetchAction({
page,
pageSize: pagination.pageSize,
});
if (!result.success) {
toast.error('common.genericServerError');
}
});
};
const handleNextPage = () => {
if (pagination.currentPage < pagination.totalPages) {
fetchPage(pagination.currentPage + 1);
}
};
const handlePrevPage = () => {
if (pagination.currentPage > 1) {
fetchPage(pagination.currentPage - 1);
}
};
const handleJobUpdate = () => {
onJobUpdate();
fetchPage(pagination.currentPage);
};
if (!results?.length) {
return (
<p>
<Trans i18nKey="common:noData" />.
</p>
);
}
return (
<>
<Table className="border-separate rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="w-6"></TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.patientName" />
</TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.serviceName" />
</TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.orderNr" />
</TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.time" />
</TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
</TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.language" />
</TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.assignedTo" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results
?.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((result) => {
const isCompleted = result.feedback?.status === 'COMPLETED';
const isCurrentDoctorJob =
!!result.doctor?.primary_owner_user_id &&
result.doctor?.primary_owner_user_id === currentUser?.id;
const resultsReceived = result.elements.length || 0;
const elementsInOrder = Array.from(
new Set(result.analysis_order_id.analysis_element_ids),
)?.length;
return (
<TableRow key={result.order_number}>
<TableCell className="text-center">
<Link
href={`/${pathsConfig.app.analysisDetails}/${result.id}`}
>
<Eye />
</Link>
</TableCell>
<TableCell>
{getFullName(
result.patient?.name,
result.patient?.last_name,
)}
</TableCell>
<TableCell>
<Trans
i18nKey={getResultSetName(
result.order?.title ?? '-',
result.order?.isPackage,
result.elements?.length || 0,
)}
/>
</TableCell>
<TableCell>{result.order_number}</TableCell>
<TableCell>
{result.firstSampleGivenAt
? format(result.firstSampleGivenAt, 'dd.MM.yyyy HH:mm')
: '-'}
</TableCell>
<TableCell>
<Trans
i18nKey={
resultsReceived === elementsInOrder
? 'doctor:resultsTable.responsesReceived'
: 'doctor:resultsTable.waitingForNr'
}
values={{
nr: elementsInOrder - resultsReceived,
}}
/>
</TableCell>
<TableCell>
{capitalize(
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
)}
</TableCell>
<TableCell>
<DoctorJobSelect
doctorUserId={result.doctor?.primary_owner_user_id}
doctorName={getFullName(
result.doctor?.name,
result.doctor?.last_name,
)}
analysisOrderId={result.analysis_order_id?.id}
userId={result.patient?.id}
isRemovable={!isCompleted && isCurrentDoctorJob}
onJobUpdate={handleJobUpdate}
linkTo={`/${pathsConfig.app.analysisDetails}/${result.id}`}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="mt-4 flex items-center justify-between">
<Button
onClick={handlePrevPage}
disabled={pagination.currentPage === 1 || isPending}
variant="outline"
>
<Trans i18nKey="common:previous" />
</Button>
<span className="text-sm text-gray-600">
<Trans
i18nKey={'common:pageOfPages'}
values={{
page: pagination.currentPage,
total: pagination.totalPages,
}}
/>
</span>
<Button
onClick={handleNextPage}
disabled={
pagination.currentPage === pagination.totalPages || isPending
}
variant="outline"
>
<Trans i18nKey="common:next" />
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { cache } from 'react';
import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service';
import { PageBody, PageHeader } from '@kit/ui/page';
import {
DoctorPageViewAction,
createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service';
import AnalysisView from '../../_components/analysis-view';
import { DoctorGuard } from '../../_components/doctor-guard';
async function AnalysisPage({
params,
}: {
params: Promise<{
id: string;
}>;
}) {
const { id: analysisOrderId } = await params;
const analysisResultDetails = await loadResult(Number(analysisOrderId));
if (!analysisResultDetails) {
return null;
}
if (analysisResultDetails) {
await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
recordKey: analysisOrderId,
dataOwnerUserId: analysisResultDetails.patient.userId,
});
}
return (
<>
<PageHeader />
<PageBody>
<AnalysisView
patient={analysisResultDetails.patient}
order={analysisResultDetails.order}
analyses={analysisResultDetails.analysisResponse}
feedback={analysisResultDetails.doctorFeedback}
/>
</PageBody>
</>
);
}
export default DoctorGuard(AnalysisPage);
const loadResult = cache(getAnalysisResultsForDoctor);

View File

@@ -0,0 +1,31 @@
import { getUserDoneResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
import { PageBody, PageHeader } from '@kit/ui/page';
import {
DoctorPageViewAction,
createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service';
import { DoctorGuard } from '../_components/doctor-guard';
import ResultsTableWrapper from '../_components/results-table-wrapper';
async function CompletedJobsPage() {
await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_DONE_JOBS,
});
return (
<>
<PageHeader />
<PageBody>
<ResultsTableWrapper
titleKey="doctor:completedReviews"
action={getUserDoneResponsesAction}
queryKey="doctor-done-jobs"
/>
</PageBody>
</>
);
}
export default DoctorGuard(CompletedJobsPage);

47
app/doctor/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
import { DoctorSidebar } from './_components/doctor-sidebar';
import { DoctorMobileNavigation } from './_components/mobile-navigation';
export const metadata = {
title: `Doctor`,
};
export const dynamic = 'force-dynamic';
export default function DoctorLayout(props: React.PropsWithChildren) {
const state = use(getLayoutState());
const workspace = use(loadUserWorkspace());
return (
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<DoctorSidebar accounts={workspace.accounts} />
</PageNavigation>
<PageMobileNavigation>
<DoctorMobileNavigation />
</PageMobileNavigation>
{props.children}
</Page>
</SidebarProvider>
);
}
async function getLayoutState() {
const cookieStore = await cookies();
const sidebarOpenCookie = cookieStore.get('sidebar:state');
return {
open: sidebarOpenCookie?.value !== 'true',
};
}

3
app/doctor/loading.tsx Normal file
View File

@@ -0,0 +1,3 @@
import { GlobalLoader } from '@kit/ui/global-loader';
export default GlobalLoader;

View File

@@ -0,0 +1,31 @@
import { getUserInProgressResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
import { PageBody, PageHeader } from '@kit/ui/page';
import {
DoctorPageViewAction,
createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service';
import { DoctorGuard } from '../_components/doctor-guard';
import ResultsTableWrapper from '../_components/results-table-wrapper';
async function MyReviewsPage() {
await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_OWN_JOBS,
});
return (
<>
<PageHeader />
<PageBody>
<ResultsTableWrapper
titleKey="doctor:myReviews"
action={getUserInProgressResponsesAction}
queryKey="doctor-in-progress-jobs"
/>
</PageBody>
</>
);
}
export default DoctorGuard(MyReviewsPage);

View File

@@ -0,0 +1,31 @@
import { getOpenResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
import { PageBody, PageHeader } from '@kit/ui/page';
import {
DoctorPageViewAction,
createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service';
import { DoctorGuard } from '../_components/doctor-guard';
import ResultsTableWrapper from '../_components/results-table-wrapper';
async function OpenJobsPage() {
await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_OPEN_JOBS,
});
return (
<>
<PageHeader />
<PageBody>
<ResultsTableWrapper
titleKey="doctor:openReviews"
action={getOpenResponsesAction}
queryKey="doctor-open-jobs"
/>
</PageBody>
</>
);
}
export default DoctorGuard(OpenJobsPage);

26
app/doctor/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { PageBody, PageHeader } from '@kit/ui/page';
import {
DoctorPageViewAction,
createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service';
import Dashboard from './_components/doctor-dashboard';
import { DoctorGuard } from './_components/doctor-guard';
async function DoctorPage() {
await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_DASHBOARD,
});
return (
<>
<PageHeader />
<PageBody>
<Dashboard />
</PageBody>
</>
);
}
export default DoctorGuard(DoctorPage);

View File

@@ -10,7 +10,7 @@ import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { SiteHeader } from '~/(marketing)/_components/site-header';
import { RootProviders } from '~/components/root-providers';
import { RootProviders } from '@kit/shared/components/root-providers';
const GlobalErrorPage = ({
error,

7
app/health/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
const HealthPage = () => {
const commit = process.env.NEXT_PUBLIC_COMMIT_HASH;
return <div>{commit}</div>;
};
export default HealthPage;

View File

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

View File

@@ -0,0 +1,134 @@
import { useMemo } from 'react';
import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis';
export enum AnalysisResultLevel {
VERY_LOW = 0,
LOW = 1,
NORMAL = 2,
HIGH = 3,
VERY_HIGH = 4,
}
const Level = ({
isActive = false,
color,
isFirst = false,
isLast = false,
arrowLocation,
}: {
isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean;
isLast?: boolean;
arrowLocation?: number;
}) => {
return (
<div
className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-20': !isActive,
'rounded-l-lg': isFirst,
'rounded-r-lg': isLast,
})}
>
{isActive && (
<div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
style={{ left: `${arrowLocation}%` }}
>
<ArrowDown strokeWidth={2} />
</div>
)}
</div>
);
};
export const AnalysisLevelBarSkeleton = () => {
return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
<Level color="gray-200" />
</div>
);
};
const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level,
results,
}: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
results: AnalysisResultForDisplay;
}) => {
const { norm_lower: lower, norm_upper: upper, response_value: value } = results;
const arrowLocation = useMemo(() => {
if (value < lower!) {
return 0;
}
if (normLowerIncluded || normUpperIncluded) {
return 50;
}
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
if (calculated > 100) {
return 100;
}
return calculated;
}, [value, upper, lower]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [
level === AnalysisResultLevel.VERY_LOW,
level === AnalysisResultLevel.LOW,
level === AnalysisResultLevel.HIGH,
level === AnalysisResultLevel.VERY_HIGH,
], [level, value, upper, lower]);
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
{normLowerIncluded && (
<>
<Level
isActive={isVeryLow}
color="destructive"
isFirst
/>
<Level isActive={isLow} color="warning" />
</>
)}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel ? { color: "warning", isActive: false } : { color: "success", isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level
isActive={isHigh}
color="warning"
/>
<Level
isActive={isVeryHigh}
color="destructive"
isLast
/>
</>
)}
</div>
);
};
export default AnalysisLevelBar;

View File

@@ -0,0 +1,149 @@
'use client';
import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
import { format } from 'date-fns';
import { Info } from 'lucide-react';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { AnalysisElement } from '~/lib/services/analysis-element.service';
import AnalysisLevelBar, {
AnalysisLevelBarSkeleton,
AnalysisResultLevel,
} from './analysis-level-bar';
export type AnalysisResultForDisplay = Pick<
UserAnalysisElement,
| 'norm_status'
| 'response_value'
| 'unit'
| 'norm_lower_included'
| 'norm_upper_included'
| 'norm_lower'
| 'norm_upper'
| 'response_time'
>;
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
const Analysis = ({
analysisElement,
results,
startIcon,
endIcon,
isCancelled,
}: {
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
results?: AnalysisResultForDisplay;
isCancelled?: boolean;
startIcon?: ReactElement | null;
endIcon?: ReactNode | null;
}) => {
const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status || AnalysisStatus.NORMAL;
const value = results?.response_value || 0;
const unit = results?.unit || '';
const normLowerIncluded = results?.norm_lower_included || false;
const normUpperIncluded = results?.norm_upper_included || false;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => {
if (!results) {
return null;
}
const isUnderNorm = value < normLower;
if (isUnderNorm) {
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.LOW;
default:
return AnalysisResultLevel.VERY_LOW;
}
}
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.HIGH;
case AnalysisStatus.HIGH:
return AnalysisResultLevel.VERY_HIGH;
default:
return AnalysisResultLevel.NORMAL;
}
}, [results, value, normLower]);
return (
<div className="border-border rounded-lg border px-5">
<div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
<div className="flex items-center gap-2 font-semibold">
{startIcon || <div className="w-4" />}
{name}
{results?.response_time && (
<div
className="group/tooltip relative"
onClick={(e) => {
e.stopPropagation();
setShowTooltip(!showTooltip);
}}
onMouseLeave={() => setShowTooltip(false)}
>
<Info className="hover" />{' '}
<div
className={cn(
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block',
{ block: showTooltip },
)}
>
<Trans i18nKey="analysis-results:analysisDate" />
{': '}
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
</div>
</div>
)}
</div>
{results ? (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div>
</div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normLower} - {normUpper}
<div>
<Trans i18nKey="analysis-results:results.range.normal" />
</div>
</div>
<AnalysisLevelBar
results={results}
normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded}
level={analysisResultLevel!}
/>
{endIcon || <div className="mx-2 w-4" />}
</>
) : (isCancelled ? null : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
))}
</div>
</div>
);
};
export default Analysis;

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { MontonioCheckoutCallback } from '../../../../_components/cart/montonio-checkout-callback';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { Trans } from '@kit/ui/trans';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('cart:montonioCallback.title'),
};
}
export default async function MontonioCheckoutCallbackPage() {
return (
<div className={'flex h-full flex-1 flex-col'}>
<PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} />
<PageBody>
<MontonioCheckoutCallback />
</PageBody>
</div>
);
}

View File

@@ -0,0 +1,153 @@
'use server';
import jwt from 'jsonwebtoken';
import { z } from "zod";
import { MontonioOrderToken } from "@/app/home/(user)/_components/cart/types";
import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-account";
import { listProductTypes } from "@lib/data/products";
import { placeOrder, retrieveCart } from "@lib/data/cart";
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
import { createOrder } from '~/lib/services/order.service';
import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
import { createNotificationsApi } from '@kit/notifications/api';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { AccountWithParams } from '@kit/accounts/api';
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
const MONTONIO_PAID_STATUS = 'PAID';
const env = () => z
.object({
emailSender: z
.string({
error: 'EMAIL_SENDER is required',
})
.min(1),
siteUrl: z
.string({
error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
})
.parse({
emailSender: process.env.EMAIL_SENDER,
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
});
const sendEmail = async ({
account,
email,
analysisPackageName,
personName,
partnerLocationName,
language,
}: {
account: AccountWithParams,
email: string,
analysisPackageName: string,
personName: string,
partnerLocationName: string,
language: string,
}) => {
const client = getSupabaseServerAdminClient();
try {
const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderSynlabAnalysisPackageEmail({
analysisPackageName,
personName,
partnerLocationName,
language,
});
await mailer
.sendEmail({
from: env().emailSender,
to: email,
subject,
html,
})
.catch((error) => {
throw new Error(`Failed to send email, message=${error}`);
});
await createNotificationsApi(client)
.createNotification({
account_id: account.id,
body: html,
});
} catch (error) {
throw new Error(`Failed to send email, message=${error}`);
}
}
export async function processMontonioCallback(orderToken: string) {
const { language } = await createI18nServerInstance();
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
const decoded = jwt.verify(orderToken, secretKey, {
algorithms: ['HS256'],
}) as MontonioOrderToken;
if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) {
throw new Error("Payment not successful");
}
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error("Account not found in context");
}
try {
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
if (!cartId) {
throw new Error("Cart ID not found");
}
const cart = await retrieveCart(cartId);
if (!cart) {
throw new Error("Cart not found");
}
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
const orderResult = {
medusaOrderId: medusaOrder.id,
email: medusaOrder.email,
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
analysisPackageName: analysisPackageOrderItem?.title ?? '',
orderedAnalysisElements,
};
const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult;
const personName = account.name;
if (email && analysisPackageName) {
try {
await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language });
} catch (error) {
console.error("Failed to send email", error);
}
} else {
// @TODO send email for separate analyses
console.error("Missing email or analysisPackageName", orderResult);
}
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
return { success: true, orderId };
} catch (error) {
console.error("Failed to place order", error);
throw new Error(`Failed to place order, message=${error}`);
}
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { processMontonioCallback } from './actions';
export default function MontonioCallbackClient({ orderToken, error }: {
orderToken?: string;
error?: string;
}) {
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false);
const [hasProcessed, setHasProcessed] = useState(false);
useEffect(() => {
if (error) {
console.error(error);
router.push('/home/cart/montonio-callback/error');
return;
}
if (!orderToken || hasProcessed || isProcessing) {
return;
}
const processOrder = async () => {
setIsProcessing(true);
setHasProcessed(true);
try {
const { orderId } = await processMontonioCallback(orderToken);
router.push(`/home/order/${orderId}/confirmed`);
} catch (error) {
console.error("Failed to place order", error);
router.push('/home/cart/montonio-callback/error');
} finally {
setIsProcessing(false);
}
};
processOrder();
}, [orderToken, error, router, hasProcessed, isProcessing]);
return (
<div className="flex mt-10 justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { Trans } from '@kit/ui/trans';
import { Alert, AlertDescription } from '@kit/ui/shadcn/alert';
import { AlertTitle } from '@kit/ui/shadcn/alert';
import { Button } from '@kit/ui/button';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('cart:montonioCallback.title'),
};
}
export default async function MontonioCheckoutCallbackErrorPage() {
return (
<div className={'flex h-full flex-1 flex-col'}>
<PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} />
<PageBody>
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'checkout.error.title'} />
</AlertTitle>
<AlertDescription>
<p>
<Trans i18nKey={'checkout.error.description'} />
</p>
</AlertDescription>
</Alert>
<div className={'flex'}>
<Button asChild>
<Link href={'/home'}>
<Trans i18nKey={'checkout.goToDashboard'} />
</Link>
</Button>
</div>
</div>
</PageBody>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import MontonioCallbackClient from './client-component';
export default async function MontonioCallbackPage({ searchParams }: {
searchParams: Promise<{
'order-token'?: string;
}>;
}) {
const orderToken = (await searchParams)['order-token'];
if (!orderToken) {
return <MontonioCallbackClient error="Order token is missing" />;
}
return <MontonioCallbackClient orderToken={orderToken} />;
}

View File

@@ -3,9 +3,9 @@ import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { notFound } from 'next/navigation';
import { retrieveCart } from '~/medusa/lib/data/cart';
import { retrieveCart } from '@lib/data/cart';
import Cart from '../../_components/cart';
import { listCollections } from '@lib/data';
import { listProductTypes } from '@lib/data/products';
import CartTimer from '../../_components/cart/cart-timer';
import { Trans } from '@kit/ui/trans';
@@ -23,25 +23,30 @@ export default async function CartPage() {
return notFound();
});
const { collections } = await listCollections({
limit: "100",
});
const analysisPackagesCollection = collections.find(({ handle }) => handle === 'analysis-packages');
const analysisPackages = analysisPackagesCollection && cart?.items
? cart.items.filter((item) => item.product?.collection_id === analysisPackagesCollection.id)
const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
const synlabAnalysisType = productTypes.find(({ metadata }) => metadata?.handle === 'synlab-analysis');
const synlabAnalyses = analysisPackagesType && synlabAnalysisType && cart?.items
? cart.items.filter((item) => {
const productTypeId = item.product?.type_id;
if (!productTypeId) {
return false;
}
return [analysisPackagesType.id, synlabAnalysisType.id].includes(productTypeId);
})
: [];
const otherItems = cart?.items?.filter((item) => item.product?.collection_id !== analysisPackagesCollection?.id) ?? [];
const ttoServiceItems = cart?.items?.filter((item) => !synlabAnalyses.some((analysis) => analysis.id === item.id)) ?? [];
const otherItemsSorted = otherItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
const item = otherItemsSorted[0];
const isTimerShown = ttoServiceItems.length > 0 && !!item && !!item.updated_at;
return (
<PageBody>
<PageHeader title={<Trans i18nKey="cart:title" />}>
{item && item.updated_at && <CartTimer cartItem={item} />}
{isTimerShown && <CartTimer cartItem={item} />}
</PageHeader>
<Cart cart={cart} analysisPackages={analysisPackages} otherItems={otherItems} />
<Cart cart={cart} synlabAnalyses={synlabAnalyses} ttoServiceItems={ttoServiceItems} />
</PageBody>
);
}

View File

@@ -1,15 +1,14 @@
import { use } from 'react';
import { cookies } from 'next/headers';
import { z } from 'zod';
import { retrieveCart } from '@lib/data/cart';
import { StoreCart } from '@medusajs/types';
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
import { AppLogo } from '@kit/shared/components/app-logo';
import { pathsConfig } from '@kit/shared/config';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AppLogo } from '~/components/app-logo';
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
import { withI18n } from '~/lib/i18n/with-i18n';
// home imports
@@ -17,43 +16,13 @@ import { HomeMenuNavigation } from '../_components/home-menu-navigation';
import { HomeMobileNavigation } from '../_components/home-mobile-navigation';
import { HomeSidebar } from '../_components/home-sidebar';
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
import { retrieveCart } from '@lib/data';
function UserHomeLayout({ children }: React.PropsWithChildren) {
const state = use(getLayoutState());
if (state.style === 'sidebar') {
return <SidebarLayout>{children}</SidebarLayout>;
}
return <HeaderLayout>{children}</HeaderLayout>;
}
export default withI18n(UserHomeLayout);
function SidebarLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const state = use(getLayoutState());
return (
<UserWorkspaceContextProvider value={workspace}>
<SidebarProvider defaultOpen={state.open}>
<Page style={'sidebar'}>
<PageNavigation>
<HomeSidebar />
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
</PageMobileNavigation>
{children}
</Page>
</SidebarProvider>
</UserWorkspaceContextProvider>
);
}
function HeaderLayout({ children }: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace());
const cart = use(retrieveCart());
@@ -66,7 +35,7 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<MobileNavigation workspace={workspace} />
<MobileNavigation workspace={workspace} cart={cart} />
</PageMobileNavigation>
<SidebarProvider defaultOpen>
@@ -84,38 +53,16 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
function MobileNavigation({
workspace,
cart,
}: {
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
cart: StoreCart | null;
}) {
return (
<>
<AppLogo />
<AppLogo href={pathsConfig.app.home} />
<HomeMobileNavigation workspace={workspace} />
<HomeMobileNavigation workspace={workspace} cart={cart} />
</>
);
}
async function getLayoutState() {
const cookieStore = await cookies();
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
const layoutStyleCookie = cookieStore.get('layout-style');
const sidebarOpenCookie = cookieStore.get('sidebar:state');
const sidebarOpen = sidebarOpenCookie
? sidebarOpenCookie.value === 'false'
: !personalAccountNavigationConfig.sidebarCollapsed;
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
const style = parsedStyle.success
? parsedStyle.data
: personalAccountNavigationConfig.style;
return {
open: sidebarOpen,
style,
};
}

View File

@@ -2,8 +2,9 @@ import { Scale } from 'lucide-react';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Button } from '@kit/ui/button';
import SelectAnalysisPackages from '@/components/select-analysis-packages';
import SelectAnalysisPackages from '@kit/shared/components/select-analysis-packages';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
@@ -20,7 +21,7 @@ export const generateMetadata = async () => {
};
async function OrderAnalysisPackagePage() {
const { analysisPackages, countryCode } = await loadAnalysisPackages();
const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages();
return (
<PageBody>
@@ -30,6 +31,7 @@ async function OrderAnalysisPackagePage() {
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
analysisPackageElements={analysisPackageElements}
triggerElement={
<Button variant="secondary" className="gap-2">
<Trans i18nKey={'marketing:comparePackages'} />

View File

@@ -0,0 +1,46 @@
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import { loadAnalyses } from '../../_lib/server/load-analyses';
import OrderAnalysesCards from '../../_components/order-analyses-cards';
import { createPageViewLog, PageViewAction } from '~/lib/services/audit/pageView.service';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
return {
title: t('order-analysis:title'),
};
};
async function OrderAnalysisPage() {
const account = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
const { analyses, countryCode } = await loadAnalyses();
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_ORDER_ANALYSIS,
});
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'order-analysis:title'} />}
description={<Trans i18nKey={'order-analysis:description'} />}
/>
<PageBody>
<OrderAnalysesCards analyses={analyses} countryCode={countryCode} />
</PageBody>
</>
);
}
export default withI18n(OrderAnalysisPage);

View File

@@ -0,0 +1,29 @@
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
export const generateMetadata = async () => {
const { t } = await createI18nServerInstance();
return {
title: t('order-health-analysis:title'),
};
};
async function OrderHealthAnalysisPage() {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'order-health-analysis:title'} />}
description={<Trans i18nKey={'order-health-analysis:description'} />}
/>
<PageBody>
</PageBody>
</>
);
}
export default withI18n(OrderHealthAnalysisPage);

View File

@@ -1,12 +1,18 @@
import { notFound } from 'next/navigation';
import { redirect } from 'next/navigation';
import { PageBody, PageHeader } from '@kit/ui/page';
import { retrieveOrder } from '~/medusa/lib/data/orders';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import OrderCompleted from '@/app/home/(user)/_components/order/order-completed';
import { withI18n } from '~/lib/i18n/with-i18n';
type Props = {
params: Promise<{ orderId: string }>;
};
import { getOrder } from '~/lib/services/order.service';
import { retrieveOrder } from '@lib/data/orders';
import { pathsConfig } from '@kit/shared/config';
import Divider from "@modules/common/components/divider"
import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderItems from '@/app/home/(user)/_components/order/order-items';
import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import { Trans } from '@kit/ui/trans';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -16,13 +22,33 @@ export async function generateMetadata() {
};
}
export default async function OrderConfirmedPage(props: Props) {
async function OrderConfirmedPage(props: {
params: Promise<{ orderId: string }>;
}) {
const params = await props.params;
const order = await retrieveOrder(params.orderId).catch(() => null);
const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null);
if (!order) {
return notFound();
redirect(pathsConfig.app.myOrders);
}
return <OrderCompleted order={order} />;
const medusaOrder = await retrieveOrder(order.medusa_order_id).catch(() => null);
if (!medusaOrder) {
redirect(pathsConfig.app.myOrders);
}
return (
<PageBody>
<PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} />
<Divider />
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40 lg:px-4 gap-y-6">
<OrderDetails order={order} />
<Divider />
<OrderItems medusaOrder={medusaOrder} />
<CartTotals medusaOrder={medusaOrder} />
</div>
</PageBody>
);
}
export default withI18n(OrderConfirmedPage);

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