From f7514c698eec3c7f1dca76f7067712802692462c Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 3 Sep 2025 10:04:00 +0300 Subject: [PATCH 01/30] feat: implement booking feature with service and time slot selection --- .../(dashboard)/booking/[handle]/page.tsx | 13 ++- .../_components/booking/booking-container.tsx | 31 ++++++ .../_components/booking/booking.context.ts | 18 ++++ .../_components/booking/booking.provider.tsx | 47 +++++++++ .../_components/booking/location-selector.tsx | 9 ++ .../_components/booking/service-selector.tsx | 83 ++++++++++++++++ .../(user)/_components/booking/time-slots.tsx | 96 +++++++++++++++++++ .../(user)/_components/service-categories.tsx | 6 +- app/home/(user)/_lib/server/load-category.ts | 1 + lib/services/connected-online.service.ts | 12 +-- packages/ui/src/shadcn/radio-group.tsx | 4 +- 11 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 app/home/(user)/_components/booking/booking-container.tsx create mode 100644 app/home/(user)/_components/booking/booking.context.ts create mode 100644 app/home/(user)/_components/booking/booking.provider.tsx create mode 100644 app/home/(user)/_components/booking/location-selector.tsx create mode 100644 app/home/(user)/_components/booking/service-selector.tsx create mode 100644 app/home/(user)/_components/booking/time-slots.tsx diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index ebce187..a125346 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -2,12 +2,13 @@ import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-he 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'; +import BookingContainer from '../../../_components/booking/booking-container'; + export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); const title = i18n.t('booking:title'); @@ -18,9 +19,13 @@ export const generateMetadata = async () => { }; async function BookingHandlePage({ params }: { params: { handle: string } }) { - const handle = await params.handle; + const { handle } = await params; const { category } = await loadCategory({ handle }); + if (!category) { + return
Category not found
; + } + return ( <> } - description={} + description="" /> - + ); } diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx new file mode 100644 index 0000000..fb760fa --- /dev/null +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React from 'react'; + +import { Calendar } from '@kit/ui/shadcn/calendar'; +import { Card } from '@kit/ui/shadcn/card'; + +import { ServiceCategory } from '../service-categories'; +import { BookingProvider } from './booking.provider'; +import LocationSelector from './location-selector'; +import ServiceSelector from './service-selector'; +import TimeSlots from './time-slots'; + +const BookingContainer = ({ category }: { category: ServiceCategory }) => { + return ( + +
+
+ + + + + {/* */} +
+ +
+
+ ); +}; + +export default BookingContainer; diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts new file mode 100644 index 0000000..36d9035 --- /dev/null +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +import { StoreProduct } from '@medusajs/types'; +import { noop } from 'lodash'; + +const BookingContext = createContext<{ + timeSlots: string[]; + selectedService: StoreProduct | null; + setSelectedService: (selectedService: any) => void; + updateTimeSlots: (serviceId: number) => Promise; +}>({ + timeSlots: [], + selectedService: null, + setSelectedService: (_) => _, + updateTimeSlots: async (_) => noop(), +}); + +export { BookingContext }; diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx new file mode 100644 index 0000000..700ceee --- /dev/null +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; + +import { StoreProduct } from '@medusajs/types'; + +import { getAvailableAppointmentsForService } from '~/lib/services/connected-online.service'; + +import { ServiceCategory } from '../service-categories'; +import { BookingContext } from './booking.context'; + +export function useBooking() { + const context = React.useContext(BookingContext); + + if (!context) { + throw new Error('useBooking must be used within a BookingProvider.'); + } + + return context; +} + +export const BookingProvider: React.FC<{ + children: React.ReactElement; + category: ServiceCategory; +}> = ({ children, category }) => { + const [selectedService, setSelectedService] = useState( + category.products[0] || null, + ); + const [timeSlots, setTimeSlots] = useState([]); + + const updateTimeSlots = async (serviceId: number) => { + const response = await getAvailableAppointmentsForService(serviceId); + console.log('updateTimeSlots response', response); + // Fetch time slots based on the selected service ID + }; + + return ( + + {children} + + ); +}; diff --git a/app/home/(user)/_components/booking/location-selector.tsx b/app/home/(user)/_components/booking/location-selector.tsx new file mode 100644 index 0000000..e2e7de1 --- /dev/null +++ b/app/home/(user)/_components/booking/location-selector.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { Card } from '@kit/ui/shadcn/card'; + +const LocationSelector = () => { + return LocationSelector; +}; + +export default LocationSelector; diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx new file mode 100644 index 0000000..557048e --- /dev/null +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; + +import { StoreProduct } from '@medusajs/types'; +import { ArrowUp, ChevronDown } from 'lucide-react'; + +import { Button } from '@kit/ui/shadcn/button'; +import { Card } from '@kit/ui/shadcn/card'; +import { Label } from '@kit/ui/shadcn/label'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@kit/ui/shadcn/popover'; +import { RadioGroup, RadioGroupItem } from '@kit/ui/shadcn/radio-group'; + +import { useBooking } from './booking.provider'; + +const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { + const { selectedService, setSelectedService, updateTimeSlots } = useBooking(); + const [collapsed, setCollapsed] = React.useState(false); + const [firstFourProducts, setFirstFourProducts] = useState( + products.slice(0, 4), + ); + + const onServiceSelect = async (productId: StoreProduct['id']) => { + const product = products.find((p) => p.id === productId); + setSelectedService(product); + setCollapsed(false); + await updateTimeSlots((product!.metadata!.serviceId as number) || 0); + }; + + console.log('selectedService', selectedService); + return ( + +
Teenused
+ +
+ + {firstFourProducts.map((product) => ( +
+ + +
+ ))} +
+ +
setCollapsed((_) => !_)} + className="flex cursor-pointer items-center justify-between border-t py-1" + > + Kuva kõik + +
+
+
+ + + {products.map((product) => ( +
+ + +
+ ))} +
+
+
+
+ ); +}; + +export default ServiceSelector; diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx new file mode 100644 index 0000000..5860e80 --- /dev/null +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +import { formatCurrency } from '@/packages/shared/src/utils'; +import { format } from 'date-fns'; + +import { Button } from '@kit/ui/shadcn/button'; +import { Card } from '@kit/ui/shadcn/card'; +import { Trans } from '@kit/ui/trans'; + +import { AvailableAppointmentsResponse } from '~/lib/types/connected-online'; + +const dummyData: AvailableAppointmentsResponse['Data']['T_Booking'] = [ + { + ServiceID: 1, + StartTime: new Date('2024-10-10T10:00:00Z'), + EndTime: new Date('2024-10-10T11:00:00Z'), + HKServiceID: 0, + ClinicID: '', + LocationID: 0, + UserID: 0, + SyncUserID: 0, + PayorCode: '', + }, + { + ServiceID: 1, + StartTime: new Date('2024-10-10T11:00:00Z'), + EndTime: new Date('2024-10-10T12:00:00Z'), + HKServiceID: 0, + ClinicID: '', + LocationID: 0, + UserID: 0, + SyncUserID: 0, + PayorCode: '', + }, + { + ServiceID: 2, + StartTime: new Date('2024-10-10T12:00:00Z'), + EndTime: new Date('2024-10-10T13:00:00Z'), + HKServiceID: 0, + ClinicID: '', + LocationID: 0, + UserID: 0, + SyncUserID: 0, + PayorCode: '', + }, +]; + +const TimeSlots = () => { + return ( +
+ {dummyData.map((data) => ( + +
+ {format(data.StartTime.toString(), 'HH:mm')} +
+
+ Dr. Jüri Mardikas +
+ Kardioloog + Tervisekassa aeg +
+
+ + Ülemiste Tervisemaja 2 + + + Ülemiste füsioteraapiakliinik + + + Sepapaja 2/1 + + Tallinn +
+
+
+ + {formatCurrency({ + currencyCode: 'EUR', + locale: 'et-EE', + value: 20, + })} + + +
+
+ ))} +
+ ); +}; + +export default TimeSlots; diff --git a/app/home/(user)/_components/service-categories.tsx b/app/home/(user)/_components/service-categories.tsx index 9ef3e25..4e0aa20 100644 --- a/app/home/(user)/_components/service-categories.tsx +++ b/app/home/(user)/_components/service-categories.tsx @@ -4,17 +4,19 @@ import React from 'react'; import { redirect } from 'next/navigation'; -import { createPath, pathsConfig } from '@/packages/shared/src/config'; +import { pathsConfig } from '@/packages/shared/src/config'; +import { StoreProduct } from '@medusajs/types'; import { ComponentInstanceIcon } from '@radix-ui/react-icons'; import { cn } from '@kit/ui/shadcn'; -import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card'; +import { Card, CardDescription } from '@kit/ui/shadcn/card'; export interface ServiceCategory { name: string; handle: string; color: string; description: string; + products: StoreProduct[]; } const ServiceCategories = ({ diff --git a/app/home/(user)/_lib/server/load-category.ts b/app/home/(user)/_lib/server/load-category.ts index 2c0479c..1a2514c 100644 --- a/app/home/(user)/_lib/server/load-category.ts +++ b/app/home/(user)/_lib/server/load-category.ts @@ -25,6 +25,7 @@ async function categoryLoader({ description: category?.description || '', handle: category?.handle || '', name: category?.name || '', + products: category?.products || [], }, }; } diff --git a/lib/services/connected-online.service.ts b/lib/services/connected-online.service.ts index b0ad1f6..e0628cc 100644 --- a/lib/services/connected-online.service.ts +++ b/lib/services/connected-online.service.ts @@ -51,12 +51,12 @@ export async function getAvailableAppointmentsForService( : `No booking times present in appointment availability response, service id: ${serviceId}, start time: ${startTime}`; } - await logRequestResult( - ExternalApi.ConnectedOnline, - ConnectedOnlineMethodName.GetAvailabilities, - RequestStatus.Fail, - comment, - ); + // await logRequestResult( + // ExternalApi.ConnectedOnline, + // ConnectedOnlineMethodName.GetAvailabilities, + // RequestStatus.Fail, + // comment, + // ); return null; } diff --git a/packages/ui/src/shadcn/radio-group.tsx b/packages/ui/src/shadcn/radio-group.tsx index 7779e73..f98a019 100644 --- a/packages/ui/src/shadcn/radio-group.tsx +++ b/packages/ui/src/shadcn/radio-group.tsx @@ -25,12 +25,12 @@ const RadioGroupItem: React.FC< return ( - + From 22f7fa134b5aba980071031e708b59d536204803 Mon Sep 17 00:00:00 2001 From: Helena Date: Wed, 17 Sep 2025 18:11:13 +0300 Subject: [PATCH 02/30] MED-103: add booking functionality --- app/api/job/handler/sync-connected-online.ts | 220 ++++++++-- .../(dashboard)/booking/[handle]/page.tsx | 6 +- .../cart/montonio-callback/actions.ts | 234 ++++++---- .../montonio-callback/client-component.tsx | 9 +- .../cart/montonio-callback/error/page.tsx | 32 +- app/home/(user)/(dashboard)/cart/page.tsx | 73 +++- app/home/(user)/(dashboard)/order/page.tsx | 61 ++- .../_components/booking/booking-container.tsx | 45 +- .../_components/booking/booking.context.ts | 65 ++- .../_components/booking/booking.provider.tsx | 51 ++- .../_components/booking/location-selector.tsx | 55 ++- .../_components/booking/service-selector.tsx | 24 +- .../(user)/_components/booking/time-slots.tsx | 319 ++++++++++---- .../_components/order-analyses-cards.tsx | 1 - .../(user)/_components/orders/order-block.tsx | 15 +- .../_components/orders/order-items-table.tsx | 37 +- .../(user)/_components/service-categories.tsx | 1 + app/home/(user)/_lib/server/actions.ts | 65 +++ app/home/(user)/_lib/server/load-analyses.ts | 4 - app/home/(user)/_lib/server/load-category.ts | 37 +- .../(user)/_lib/server/load-tto-services.ts | 22 +- lib/services/audit.service.ts | 39 +- lib/services/connected-online.service.ts | 410 ++++++++++++++---- lib/services/medusaCart.service.ts | 14 +- lib/services/order.service.ts | 26 ++ lib/types/connected-online.ts | 25 +- lib/utils.ts | 11 +- .../src/emails/book-time-failed.email.tsx | 61 +++ packages/email-templates/src/index.ts | 1 + .../medusa-storefront/src/lib/data/cart.ts | 23 +- .../src/lib/data/categories.ts | 2 - .../supabase/src/clients/server-client.ts | 2 +- packages/supabase/src/database.types.ts | 232 ++++++++-- packages/ui/src/shadcn/calendar.tsx | 2 +- public/locales/en/booking.json | 4 +- public/locales/et/booking.json | 12 +- public/locales/et/cart.json | 4 +- public/locales/et/common.json | 3 +- public/locales/et/orders.json | 22 +- public/locales/ru/booking.json | 5 +- .../20250908092531_add_clinic_key.sql | 11 + ...onnected_online_service_provider_table.sql | 60 +++ ...fields_to_connected_online_reservation.sql | 14 + ...6_add_connected_online_locations_table.sql | 43 ++ 44 files changed, 1923 insertions(+), 479 deletions(-) create mode 100644 app/home/(user)/_lib/server/actions.ts create mode 100644 packages/email-templates/src/emails/book-time-failed.email.tsx create mode 100644 supabase/migrations/20250908092531_add_clinic_key.sql create mode 100644 supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql create mode 100644 supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql create mode 100644 supabase/migrations/20250915101146_add_connected_online_locations_table.sql diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 39a5fb3..2cc0447 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -1,8 +1,42 @@ import axios from 'axios'; +import { Tables } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { ISearchLoadResponse } from '~/lib/types/connected-online'; +import { logSyncResult } from '~/lib/services/audit.service'; +import { SyncStatus } from '~/lib/types/audit'; +import type { + ISearchLoadResponse, + P_JobTitleTranslation, +} from '~/lib/types/connected-online'; + +function createTranslationMap(translations: P_JobTitleTranslation[]) { + const result: Map< + number, + Map + > = new Map(); + + for (const translation of translations) { + const { ClinicID, TextET, TextEN, TextRU, SyncID } = translation; + + if (!result.has(ClinicID)) { + result.set(ClinicID, new Map()); + } + + result.get(ClinicID)!.set(SyncID, { + textET: TextET, + textEN: TextEN, + textRU: TextRU, + }); + } + + return result; +} + +function getSpokenLanguages(spokenLanguages?: string) { + if (!spokenLanguages || !spokenLanguages.length) return []; + return spokenLanguages.split(','); +} export default async function syncConnectedOnline() { const isProd = process.env.NODE_ENV === 'production'; @@ -16,14 +50,19 @@ export default async function syncConnectedOnline() { const supabase = getSupabaseServerAdminClient(); try { - const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', + const searchLoadResponse = 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 }, - param: "{'Value':'|et|-1'}", // get all available services in Estonian - }); + ); - const responseData: ISearchLoadResponse = JSON.parse(response.data.d); + const responseData: ISearchLoadResponse = JSON.parse( + searchLoadResponse.data.d, + ); if (responseData?.ErrorCode !== 0) { throw new Error('Failed to get Connected Online data'); @@ -43,16 +82,35 @@ export default async function syncConnectedOnline() { let clinics; let services; + let serviceProviders; + let jobTitleTranslations; // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment + const isDemoClinic = (clinicId: number) => clinicId === 2; if (isProd) { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID !== 2); + clinics = responseData.Data.T_Lic.filter(({ ID }) => !isDemoClinic(ID)); services = responseData.Data.T_Service.filter( - (service) => service.ClinicID !== 2, + ({ ClinicID }) => !isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter( + ({ ClinicID }) => !isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter( + ({ ClinicID }) => !isDemoClinic(ClinicID), + ), ); } else { - clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID === 2); - services = responseData.Data.T_Service.filter( - (service) => service.ClinicID === 2, + clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); + services = responseData.Data.T_Service.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ), ); } @@ -64,6 +122,8 @@ export default async function syncConnectedOnline() { name: clinic.Name, personal_code_required: !!clinic.PersonalCodeRequired, phone_number: clinic.Phone || null, + key: clinic.Key, + address: clinic.Address, }; }); @@ -87,45 +147,133 @@ export default async function syncConnectedOnline() { }; }); + const mappedServiceProviders = serviceProviders.map((serviceProvider) => { + const jobTitleTranslation = serviceProvider.JobTitleID + ? jobTitleTranslations + .get(serviceProvider.ClinicID) + ?.get(serviceProvider.JobTitleID) + : null; + return { + id: serviceProvider.ID, + prefix: serviceProvider.Prefix, + name: serviceProvider.Name, + spoken_languages: getSpokenLanguages(serviceProvider.SpokenLanguages), + job_title_et: jobTitleTranslation?.textET, + job_title_en: jobTitleTranslation?.textEN, + job_title_ru: jobTitleTranslation?.textRU, + job_title_id: serviceProvider.JobTitleID, + is_deleted: !!serviceProvider.Deleted, + clinic_id: serviceProvider.ClinicID, + }; + }); + const { error: providersError } = await supabase .schema('medreport') .from('connected_online_providers') .upsert(mappedClinics); + if (providersError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving connected online providers: ' + + JSON.stringify(providersError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); + } + const { error: servicesError } = await supabase .schema('medreport') .from('connected_online_services') - .upsert(mappedServices, { onConflict: 'id', ignoreDuplicates: false }); + .upsert(mappedServices, { + onConflict: 'id', + ignoreDuplicates: false, + }); - if (providersError || servicesError) { - return supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'CONNECTED_ONLINE_SYNC', - comment: providersError - ? 'Error saving providers: ' + JSON.stringify(providersError) - : 'Error saving services: ' + JSON.stringify(servicesError), - status: 'FAIL', - changed_by_role: 'service_role', - }); + if (servicesError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving connected online services: ' + + JSON.stringify(servicesError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); } - await supabase.schema('audit').from('sync_entries').insert({ + const { error: serviceProvidersError } = await supabase + .schema('medreport') + .from('connected_online_service_providers') + .upsert(mappedServiceProviders, { + onConflict: 'id', + ignoreDuplicates: false, + }); + + if (serviceProvidersError) { + return logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + comment: + 'Error saving service providers: ' + + JSON.stringify(serviceProvidersError), + status: SyncStatus.Fail, + changed_by_role: 'service_role', + }); + } + + for (const mappedClinic of mappedClinics) { + const defaultLoadResponse = await axios.post<{ d: string }>( + `${baseUrl}/Default_Load`, + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + param: `{'Value':'${mappedClinic.key}|et'}`, + }, + ); + + const defaultLoadResponseData = JSON.parse(defaultLoadResponse.data.d); + + if (defaultLoadResponseData?.ErrorCode !== 0) { + throw new Error('Failed to get Connected Online location data'); + } + + const clinicLocations: { + SyncID: number; + Address: string; + Name: string; + }[] = defaultLoadResponseData.Data.T_SelectableLocation; + + if (clinicLocations?.length) { + const mappedLocations = clinicLocations.map( + ({ SyncID, Address, Name }) => ({ + address: Address, + clinic_id: mappedClinic.id, + sync_id: SyncID, + name: Name, + }), + ); + + await supabase + .schema('medreport') + .from('connected_online_locations') + .insert(mappedLocations) + .throwOnError(); + } + } + + await logSyncResult({ operation: 'CONNECTED_ONLINE_SYNC', - status: 'SUCCESS', + status: SyncStatus.Success, changed_by_role: 'service_role', }); } catch (e) { - await supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'CONNECTED_ONLINE_SYNC', - status: 'FAIL', - comment: JSON.stringify(e), - changed_by_role: 'service_role', - }); + await logSyncResult({ + operation: 'CONNECTED_ONLINE_SYNC', + status: SyncStatus.Fail, + comment: JSON.stringify(e), + changed_by_role: 'service_role', + }); throw new Error( `Failed to sync Connected Online data, error: ${JSON.stringify(e)}`, ); diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index a125346..3d9c29e 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -18,7 +18,11 @@ export const generateMetadata = async () => { }; }; -async function BookingHandlePage({ params }: { params: { handle: string } }) { +async function BookingHandlePage({ + params, +}: { + params: Promise<{ handle: string }>; +}) { const { handle } = await params; const { category } = await loadCategory({ handle }); diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index f705399..222be00 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -1,45 +1,61 @@ 'use server'; +import { MontonioOrderToken } from '@/app/home/(user)/_components/cart/types'; +import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; +import { placeOrder, retrieveCart } from '@lib/data/cart'; +import { listProductTypes } from '@lib/data/products'; +import type { StoreOrder } from '@medusajs/types'; 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 { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; -import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; +import { z } from 'zod'; + +import type { AccountWithParams } from '@kit/accounts/api'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import type { AccountWithParams } from '@kit/accounts/api'; -import type { StoreOrder } from '@medusajs/types'; + +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { + bookAppointment, + getOrderedTtoServices, +} from '~/lib/services/connected-online.service'; +import { + getOrderedAnalysisIds, + sendOrderToMedipost, +} from '~/lib/services/medipost.service'; +import { + createAnalysisOrder, + getAnalysisOrder, +} from '~/lib/services/order.service'; + +import { FailureReason } from '../../../../../../lib/types/connected-online'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; +const TTO_SERVICE_TYPE_HANDLE = 'tto-service'; 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), - isEnabledDispatchOnMontonioCallback: z - .boolean({ +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), + isEnabledDispatchOnMontonioCallback: z.boolean({ error: 'MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK is required', }), - }) - .parse({ - emailSender: process.env.EMAIL_SENDER, - siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, - isEnabledDispatchOnMontonioCallback: process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', - }); + }) + .parse({ + emailSender: process.env.EMAIL_SENDER, + siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, + isEnabledDispatchOnMontonioCallback: + process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', + }); const sendEmail = async ({ account, @@ -48,15 +64,17 @@ const sendEmail = async ({ partnerLocationName, language, }: { - account: Pick, - email: string, - analysisPackageName: string, - partnerLocationName: string, - language: string, + account: Pick; + email: string; + analysisPackageName: string; + partnerLocationName: string; + language: string; }) => { const client = getSupabaseServerAdminClient(); try { - const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates'); + const { renderSynlabAnalysisPackageEmail } = await import( + '@kit/email-templates' + ); const { getMailer } = await import('@kit/mailers'); const mailer = await getMailer(); @@ -78,15 +96,14 @@ const sendEmail = async ({ .catch((error) => { throw new Error(`Failed to send email, message=${error}`); }); - await createNotificationsApi(client) - .createNotification({ - account_id: account.id, - body: html, - }); + await createNotificationsApi(client).createNotification({ + account_id: account.id, + body: html, + }); } catch (error) { throw new Error(`Failed to send email, message=${error}`); } -} +}; async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; @@ -96,7 +113,7 @@ async function decodeOrderToken(orderToken: string) { }) as MontonioOrderToken; if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) { - throw new Error("Payment not successful"); + throw new Error('Payment not successful'); } return decoded; @@ -105,38 +122,49 @@ async function decodeOrderToken(orderToken: string) { async function getCartByOrderToken(decoded: MontonioOrderToken) { const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); if (!cartId) { - throw new Error("Cart ID not found"); + throw new Error('Cart ID not found'); } const cart = await retrieveCart(cartId); if (!cart) { - throw new Error("Cart not found"); + throw new Error('Cart not found'); } return cart; } async function getOrderResultParameters(medusaOrder: StoreOrder) { const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); - const analysisType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE); + const analysisPackagesType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, + ); + const analysisType = productTypes.find( + ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, + ); - const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); - const analysisItems = medusaOrder.items?.filter(({ product_type_id }) => product_type_id === analysisType?.id); + const analysisPackageOrderItem = medusaOrder.items?.find( + ({ product_type_id }) => product_type_id === analysisPackagesType?.id, + ); + const analysisItems = medusaOrder.items?.filter( + ({ product_type_id }) => product_type_id === analysisType?.id, + ); return { medusaOrderId: medusaOrder.id, email: medusaOrder.email, analysisPackageOrder: analysisPackageOrderItem ? { - partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - } - : null, - analysisItemsOrder: Array.isArray(analysisItems) && analysisItems.length > 0 - ? analysisItems.map(({ product }) => ({ - analysisName: product?.title ?? '', - analysisId: product?.metadata?.analysisIdOriginal as string ?? '', - })) + partnerLocationName: + (analysisPackageOrderItem?.metadata + ?.partner_location_name as string) ?? '', + analysisPackageName: analysisPackageOrderItem?.title ?? '', + } : null, + analysisItemsOrder: + Array.isArray(analysisItems) && analysisItems.length > 0 + ? analysisItems.map(({ product }) => ({ + analysisName: product?.title ?? '', + analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', + })) + : null, }; } @@ -145,12 +173,12 @@ async function sendAnalysisPackageOrderEmail({ email, analysisPackageOrder, }: { - account: AccountWithParams, - email: string, + account: AccountWithParams; + email: string; analysisPackageOrder: { - partnerLocationName: string, - analysisPackageName: string, - }, + partnerLocationName: string; + analysisPackageName: string; + }; }) { const { language } = await createI18nServerInstance(); const { analysisPackageName, partnerLocationName } = analysisPackageOrder; @@ -163,60 +191,114 @@ async function sendAnalysisPackageOrderEmail({ language, }); } catch (error) { - console.error("Failed to send email", error); + console.error('Failed to send email', error); } } export async function processMontonioCallback(orderToken: string) { const { account } = await loadCurrentUserAccount(); if (!account) { - throw new Error("Account not found in context"); + throw new Error('Account not found in context'); } try { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); + const medusaOrder = await placeOrder(cart.id, { + revalidateCacheTags: false, + }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ + medusaOrder, + }); + + const orderContainsSynlabItems = !!orderedAnalysisElements?.length; try { - const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id }); - console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`); + const existingAnalysisOrder = await getAnalysisOrder({ + medusaOrderId: medusaOrder.id, + }); + console.info( + `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, + ); return { success: true, orderId: existingAnalysisOrder.id }; } catch { // ignored } - const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements }); + let orderId; + if (orderContainsSynlabItems) { + orderId = await createAnalysisOrder({ + medusaOrder, + orderedAnalysisElements, + }); + } + const orderResult = await getOrderResultParameters(medusaOrder); - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = + orderResult; + + const orderedTtoServices = await getOrderedTtoServices({ medusaOrder }); + let bookServiceResults: { + success: boolean; + reason?: FailureReason; + serviceId?: number; + }[] = []; + if (orderedTtoServices?.length) { + const bookingPromises = orderedTtoServices.map((service) => + bookAppointment( + service.service_id, + service.clinic_id, + service.service_user_id, + service.sync_user_id, + service.start_time, + ), + ); + bookServiceResults = await Promise.all(bookingPromises); + } if (email) { if (analysisPackageOrder) { - await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder }); + await sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, + }); } else { console.info(`Order has no analysis package, skipping email.`); } if (analysisItemsOrder) { // @TODO send email for separate analyses - console.warn(`Order has analysis items, but no email template exists yet`); + console.warn( + `Order has analysis items, but no email template exists yet`, + ); } else { console.info(`Order has no analysis items, skipping email.`); } } else { - console.error("Missing email to send order result email", orderResult); + console.error('Missing email to send order result email', orderResult); } - if (env().isEnabledDispatchOnMontonioCallback) { + if (env().isEnabledDispatchOnMontonioCallback && orderContainsSynlabItems) { await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); } + if (bookServiceResults.some(({ success }) => success === false)) { + const failedServiceBookings = bookServiceResults.filter( + ({ success }) => success === false, + ); + return { + success: false, + failedServiceBookings, + orderId, + }; + } + return { success: true, orderId }; } catch (error) { - console.error("Failed to place order", error); + console.error('Failed to place order', error); throw new Error(`Failed to place order, message=${error}`); } } diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx index c388a6d..af495ae 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/client-component.tsx @@ -29,8 +29,13 @@ export default function MontonioCallbackClient({ orderToken, error }: { setHasProcessed(true); try { - const { orderId } = await processMontonioCallback(orderToken); - router.push(`/home/order/${orderId}/confirmed`); + const result = await processMontonioCallback(orderToken); + if (result.success) { + return router.push(`/home/order/${result.orderId}/confirmed`); + } + if (result.failedServiceBookings?.length){ + router.push(`/home/cart/montonio-callback/error?reasonFailed=${result.failedServiceBookings.map(({reason}) => reason).join(',')}`); + } } catch (error) { console.error("Failed to place order", error); router.push('/home/cart/montonio-callback/error'); diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx index cdb64ff..3fac68c 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx @@ -1,11 +1,17 @@ +import { use } from 'react'; + 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 { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; + +import { Button } from '@kit/ui/button'; import { Alert, AlertDescription } from '@kit/ui/shadcn/alert'; import { AlertTitle } from '@kit/ui/shadcn/alert'; -import { Button } from '@kit/ui/button'; +import { Trans } from '@kit/ui/trans'; + +import { FailureReason } from '~/lib/types/connected-online'; +import { toArray } from '~/lib/utils'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -15,7 +21,15 @@ export async function generateMetadata() { }; } -export default async function MontonioCheckoutCallbackErrorPage() { +export default async function MontonioCheckoutCallbackErrorPage({ + searchParams, +}: { + searchParams: Promise<{ reasonFailed: string }>; +}) { + const params = await searchParams; + + const failedBookingData: string[] = toArray(params.reasonFailed?.split(',')); + return (
} /> @@ -27,9 +41,15 @@ export default async function MontonioCheckoutCallbackErrorPage() { -

+ {failedBookingData.length ? ( + failedBookingData.map((failureReason, index) => ( +

+ +

+ )) + ) : ( -

+ )}
diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 1bb2675..20efdce 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -1,15 +1,18 @@ -import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; -import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; - import { notFound } from 'next/navigation'; +import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; +import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; import { retrieveCart } from '@lib/data/cart'; -import Cart from '../../_components/cart'; import { listProductTypes } from '@lib/data/products'; -import CartTimer from '../../_components/cart/cart-timer'; + import { Trans } from '@kit/ui/trans'; + import { withI18n } from '~/lib/i18n/with-i18n'; +import { findProductTypeIdByHandle } from '~/lib/utils'; +import Cart from '../../_components/cart'; +import CartTimer from '../../_components/cart/cart-timer'; + export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -20,34 +23,62 @@ export async function generateMetadata() { async function CartPage() { const cart = await retrieveCart().catch((error) => { - console.error("Failed to retrieve cart", error); + console.error('Failed to retrieve cart', error); return notFound(); }); 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 ttoServiceItems = cart?.items?.filter((item) => !synlabAnalyses.some((analysis) => analysis.id === item.id)) ?? []; - const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1); + const synlabAnalysisTypeId = findProductTypeIdByHandle( + productTypes, + 'synlab-analysis', + ); + const analysisPackagesTypeId = findProductTypeIdByHandle( + productTypes, + 'analysis-packages', + ); + const ttoServiceTypeId = findProductTypeIdByHandle( + productTypes, + 'tto-service', + ); + + const synlabAnalyses = + analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items + ? cart.items.filter((item) => { + const productTypeId = item.product?.type_id; + if (!productTypeId) { + return false; + } + return [analysisPackagesTypeId, synlabAnalysisTypeId].includes( + productTypeId, + ); + }) + : []; + const ttoServiceItems = + ttoServiceTypeId && cart?.items + ? cart?.items?.filter((item) => { + const productTypeId = item.product?.type_id; + return productTypeId && productTypeId === ttoServiceTypeId; + }) + : []; + + 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; + const isTimerShown = + ttoServiceItems.length > 0 && !!item && !!item.updated_at; return ( }> {isTimerShown && } - + ); } diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index 48faae3..2e9fe68 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -1,18 +1,22 @@ +import React from 'react'; + import { redirect } from 'next/navigation'; -import { listOrders } from '~/medusa/lib/data/orders'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { listProductTypes } from '@lib/data/products'; -import { PageBody } from '@kit/ui/makerkit/page'; -import { pathsConfig } from '@kit/shared/config'; - -import { Trans } from '@kit/ui/trans'; -import { HomeLayoutPageHeader } from '../../_components/home-page-header'; -import { getAnalysisOrders } from '~/lib/services/order.service'; -import OrderBlock from '../../_components/orders/order-block'; -import React from 'react'; import { Divider } from '@medusajs/ui'; + +import { pathsConfig } from '@kit/shared/config'; +import { PageBody } from '@kit/ui/makerkit/page'; +import { Trans } from '@kit/ui/trans'; + import { withI18n } from '~/lib/i18n/with-i18n'; +import { getAnalysisOrders, getTtoOrders } from '~/lib/services/order.service'; +import { findProductTypeIdByHandle } from '~/lib/utils'; +import { listOrders } from '~/medusa/lib/data/orders'; + +import { HomeLayoutPageHeader } from '../../_components/home-page-header'; +import OrderBlock from '../../_components/orders/order-block'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -25,13 +29,21 @@ export async function generateMetadata() { async function OrdersPage() { const medusaOrders = await listOrders(); const analysisOrders = await getAnalysisOrders(); + const ttoOrders = await getTtoOrders(); const { productTypes } = await listProductTypes(); - if (!medusaOrders || !productTypes) { + if (!medusaOrders || !productTypes || !ttoOrders) { redirect(pathsConfig.auth.signIn); } - const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages')!; + const analysisPackagesTypeId = findProductTypeIdByHandle( + productTypes, + 'analysis-package', + ); + const ttoServiceTypeId = findProductTypeIdByHandle( + productTypes, + 'tto-service', + ); return ( <> @@ -40,26 +52,41 @@ async function OrdersPage() { description={} /> - {analysisOrders.map((analysisOrder) => { - const medusaOrder = medusaOrders.find(({ id }) => id === analysisOrder.medusa_order_id); + {medusaOrders.map((medusaOrder) => { + const analysisOrder = analysisOrders.find( + ({ medusa_order_id }) => medusa_order_id === medusaOrder.id, + ); if (!medusaOrder) { return null; } const medusaOrderItems = medusaOrder.items || []; - const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter((item) => item.product_type_id === analysisPackagesType?.id); - const medusaOrderItemsOther = medusaOrderItems.filter((item) => item.product_type_id !== analysisPackagesType?.id); + const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter( + (item) => item.product_type_id === analysisPackagesTypeId, + ); + const medusaOrderItemsTtoServices = medusaOrderItems.filter( + (item) => item.product_type_id === ttoServiceTypeId, + ); + const medusaOrderItemsOther = medusaOrderItems.filter( + (item) => + !item.product_type_id || + ![analysisPackagesTypeId, ttoServiceTypeId].includes( + item.product_type_id, + ), + ); return ( - + - ) + ); })} {analysisOrders.length === 0 && (
diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx index fb760fa..d7ca8cc 100644 --- a/app/home/(user)/_components/booking/booking-container.tsx +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -1,28 +1,57 @@ 'use client'; -import React from 'react'; +import { isBefore, isSameDay } from 'date-fns'; +import { uniq } from 'lodash'; import { Calendar } from '@kit/ui/shadcn/calendar'; import { Card } from '@kit/ui/shadcn/card'; import { ServiceCategory } from '../service-categories'; -import { BookingProvider } from './booking.provider'; +import { BookingProvider, useBooking } from './booking.provider'; import LocationSelector from './location-selector'; import ServiceSelector from './service-selector'; import TimeSlots from './time-slots'; +const BookingCalendar = () => { + const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } = + useBooking(); + const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime)); + + return ( + + { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return ( + isBefore(date, today) || + !availableDates.some((dateWithBooking) => + isSameDay(date, dateWithBooking), + ) + ); + }} + className="rounded-md border" + {...(isLoadingTimeSlots && { + className: 'rounded-md border opacity-50 pointer-events-none', + })} + /> + + ); +}; + const BookingContainer = ({ category }: { category: ServiceCategory }) => { return ( -
+
- - - - {/* */} + +
- +
); diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts index 36d9035..a8b47a4 100644 --- a/app/home/(user)/_components/booking/booking.context.ts +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -3,16 +3,75 @@ import { createContext } from 'react'; import { StoreProduct } from '@medusajs/types'; import { noop } from 'lodash'; +import { Tables } from '@kit/supabase/database'; + +export type Location = Tables< + { schema: 'medreport' }, + 'connected_online_locations' +>; + +export type TimeSlotResponse = { + timeSlots: TimeSlot[]; + locations: Location[]; +}; + +export type TimeSlot = { + ClinicID: number; + LocationID: number; + UserID: number; + SyncUserID: number; + ServiceID: number; + HKServiceID: number; + StartTime: Date; + EndTime: Date; + PayorCode: string; + serviceProvider?: ServiceProvider; + syncedService?: SyncedService; +} & { location?: Location }; + +export type ServiceProvider = { + name: string; + id: number; + jobTitleEn: string | null; + jobTitleEt: string | null; + jobTitleRu: string | null; + clinicId: number; +}; + +export type SyncedService = Tables< + { schema: 'medreport' }, + 'connected_online_services' +> & { + providerClinic: ProviderClinic; +}; + +export type ProviderClinic = Tables< + { schema: 'medreport' }, + 'connected_online_providers' +> & { locations: Location[] }; + const BookingContext = createContext<{ - timeSlots: string[]; + timeSlots: TimeSlot[] | null; selectedService: StoreProduct | null; - setSelectedService: (selectedService: any) => void; + locations: Location[] | null; + selectedLocationId: number | null; + selectedDate?: Date; + isLoadingTimeSlots?: boolean; + setSelectedService: (selectedService?: StoreProduct) => void; + setSelectedLocationId: (selectedLocationId: number | null) => void; updateTimeSlots: (serviceId: number) => Promise; + setSelectedDate: (selectedDate?: Date) => void; }>({ - timeSlots: [], + timeSlots: null, selectedService: null, + locations: null, + selectedLocationId: null, + selectedDate: new Date(), + isLoadingTimeSlots: false, setSelectedService: (_) => _, + setSelectedLocationId: (_) => _, updateTimeSlots: async (_) => noop(), + setSelectedDate: (_) => _, }); export { BookingContext }; diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 700ceee..bf6e1f7 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -1,14 +1,14 @@ -import React, { useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { StoreProduct } from '@medusajs/types'; -import { getAvailableAppointmentsForService } from '~/lib/services/connected-online.service'; +import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service'; import { ServiceCategory } from '../service-categories'; -import { BookingContext } from './booking.context'; +import { BookingContext, Location, TimeSlot } from './booking.context'; export function useBooking() { - const context = React.useContext(BookingContext); + const context = useContext(BookingContext); if (!context) { throw new Error('useBooking must be used within a BookingProvider.'); @@ -24,21 +24,54 @@ export const BookingProvider: React.FC<{ const [selectedService, setSelectedService] = useState( category.products[0] || null, ); - const [timeSlots, setTimeSlots] = useState([]); + const [selectedLocationId, setSelectedLocationId] = useState( + null, + ); + const [selectedDate, setSelectedDate] = useState(); + const [timeSlots, setTimeSlots] = useState(null); + const [locations, setLocations] = useState(null); + const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); - const updateTimeSlots = async (serviceId: number) => { - const response = await getAvailableAppointmentsForService(serviceId); - console.log('updateTimeSlots response', response); - // Fetch time slots based on the selected service ID + useEffect(() => { + let metadataServiceIds = []; + try { + metadataServiceIds = JSON.parse( + selectedService?.metadata?.serviceIds as string, + ); + } catch (e) { + return; + } + if (metadataServiceIds.length) { + updateTimeSlots(metadataServiceIds); + } + }, [selectedService?.metadata?.serviceIds, selectedLocationId]); + + const updateTimeSlots = async (serviceIds: number[]) => { + setIsLoadingTimeSlots(true); + try { + const response = await getAvailableTimeSlotsForDisplay(serviceIds, selectedLocationId); + setTimeSlots(response.timeSlots); + setLocations(response.locations) + } catch (error) { + setTimeSlots(null); + } finally { + setIsLoadingTimeSlots(false); + } }; return ( {children} diff --git a/app/home/(user)/_components/booking/location-selector.tsx b/app/home/(user)/_components/booking/location-selector.tsx index e2e7de1..f792fa5 100644 --- a/app/home/(user)/_components/booking/location-selector.tsx +++ b/app/home/(user)/_components/booking/location-selector.tsx @@ -1,9 +1,60 @@ -import React from 'react'; +import { Label } from '@medusajs/ui'; +import { useTranslation } from 'react-i18next'; +import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group'; import { Card } from '@kit/ui/shadcn/card'; +import { Trans } from '@kit/ui/trans'; + +import { useBooking } from './booking.provider'; const LocationSelector = () => { - return LocationSelector; + const { t } = useTranslation(); + const { + selectedService, + selectedLocationId, + setSelectedLocationId, + locations, + } = useBooking(); + + const onLocationSelect = (locationId: number | string | null) => { + if (locationId === 'all') return setSelectedLocationId(null); + setSelectedLocationId(Number(locationId)); + }; + + return ( + +
+ +
+
+ onLocationSelect(val)} + > +
+ + +
+ {locations?.map((location) => ( +
+ + +
+ ))} +
+
+
+ ); }; export default LocationSelector; diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx index 557048e..0c60a3e 100644 --- a/app/home/(user)/_components/booking/service-selector.tsx +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -1,9 +1,8 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { StoreProduct } from '@medusajs/types'; -import { ArrowUp, ChevronDown } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; -import { Button } from '@kit/ui/shadcn/button'; import { Card } from '@kit/ui/shadcn/card'; import { Label } from '@kit/ui/shadcn/label'; import { @@ -12,27 +11,26 @@ import { PopoverTrigger, } from '@kit/ui/shadcn/popover'; import { RadioGroup, RadioGroupItem } from '@kit/ui/shadcn/radio-group'; +import { Trans } from '@kit/ui/trans'; import { useBooking } from './booking.provider'; const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { - const { selectedService, setSelectedService, updateTimeSlots } = useBooking(); - const [collapsed, setCollapsed] = React.useState(false); - const [firstFourProducts, setFirstFourProducts] = useState( - products.slice(0, 4), - ); + const { selectedService, setSelectedService } = useBooking(); + const [collapsed, setCollapsed] = useState(false); + const [firstFourProducts] = useState(products.slice(0, 4)); const onServiceSelect = async (productId: StoreProduct['id']) => { const product = products.find((p) => p.id === productId); setSelectedService(product); setCollapsed(false); - await updateTimeSlots((product!.metadata!.serviceId as number) || 0); }; - console.log('selectedService', selectedService); return ( -
Teenused
+
+ +
{ onClick={() => setCollapsed((_) => !_)} className="flex cursor-pointer items-center justify-between border-t py-1" > - Kuva kõik + + +
diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx index 5860e80..d74befc 100644 --- a/app/home/(user)/_components/booking/time-slots.tsx +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -1,94 +1,257 @@ -import React from 'react'; +import { useMemo, useState } from 'react'; + +import { useRouter } from 'next/navigation'; import { formatCurrency } from '@/packages/shared/src/utils'; -import { format } from 'date-fns'; +import { addHours, isAfter, isSameDay } from 'date-fns'; +import { orderBy } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { pathsConfig } from '@kit/shared/config'; +import { formatDateAndTime } from '@kit/shared/utils'; import { Button } from '@kit/ui/shadcn/button'; import { Card } from '@kit/ui/shadcn/card'; +import { toast } from '@kit/ui/sonner'; import { Trans } from '@kit/ui/trans'; +import { cn } from '@kit/ui/utils'; -import { AvailableAppointmentsResponse } from '~/lib/types/connected-online'; +import { createInitialReservationAction } from '../../_lib/server/actions'; +import { ServiceProvider, TimeSlot } from './booking.context'; +import { useBooking } from './booking.provider'; -const dummyData: AvailableAppointmentsResponse['Data']['T_Booking'] = [ - { - ServiceID: 1, - StartTime: new Date('2024-10-10T10:00:00Z'), - EndTime: new Date('2024-10-10T11:00:00Z'), - HKServiceID: 0, - ClinicID: '', - LocationID: 0, - UserID: 0, - SyncUserID: 0, - PayorCode: '', - }, - { - ServiceID: 1, - StartTime: new Date('2024-10-10T11:00:00Z'), - EndTime: new Date('2024-10-10T12:00:00Z'), - HKServiceID: 0, - ClinicID: '', - LocationID: 0, - UserID: 0, - SyncUserID: 0, - PayorCode: '', - }, - { - ServiceID: 2, - StartTime: new Date('2024-10-10T12:00:00Z'), - EndTime: new Date('2024-10-10T13:00:00Z'), - HKServiceID: 0, - ClinicID: '', - LocationID: 0, - UserID: 0, - SyncUserID: 0, - PayorCode: '', - }, -]; +const getServiceProviderTitle = ( + currentLocale: string, + serviceProvider?: ServiceProvider, +) => { + if (!serviceProvider) return null; + if (currentLocale === 'en') return serviceProvider.jobTitleEn; + if (currentLocale === 'ru') return serviceProvider.jobTitleRu; + + return serviceProvider.jobTitleEt; +}; + +const PAGE_SIZE = 7; + +const TimeSlots = ({ countryCode }: { countryCode: string }) => { + const [currentPage, setCurrentPage] = useState(1); + + const { + t, + i18n: { language: currentLocale }, + } = useTranslation(); + + const booking = useBooking(); + + const router = useRouter(); + + const selectedDate = booking.selectedDate ?? new Date(); + + const filteredBookings = useMemo( + () => + orderBy( + booking?.timeSlots?.filter(({ StartTime }) => { + const firstAvailableTimeToSelect = isSameDay(selectedDate, new Date()) + ? addHours(new Date(), 0.5) + : selectedDate; + return isAfter(StartTime, firstAvailableTimeToSelect); + }) ?? [], + 'StartTime', + 'asc', + ), + [booking.timeSlots, selectedDate], + ); + + const totalPages = Math.ceil(filteredBookings.length / PAGE_SIZE); + + const paginatedBookings = useMemo(() => { + const startIndex = (currentPage - 1) * PAGE_SIZE; + const endIndex = startIndex + PAGE_SIZE; + return filteredBookings.slice(startIndex, endIndex); + }, [filteredBookings, currentPage, PAGE_SIZE]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const generatePageNumbers = () => { + const pages = []; + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + if (currentPage <= 3) { + for (let i = 1; i <= 4; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } else if (currentPage >= totalPages - 2) { + pages.push(1); + pages.push('...'); + for (let i = totalPages - 3; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + pages.push('...'); + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } + } + + return pages; + }; + + if (!booking?.timeSlots?.length) { + return null; + } + + const handleBookTime = async (timeSlot: TimeSlot, comments?: string) => { + const selectedService = booking.selectedService; + + const selectedVariant = selectedService?.variants?.[0]; + + const syncedService = timeSlot.syncedService; + if (!syncedService || !selectedVariant) { + return toast.error(t('booking:serviceNotFound')); + } + + const bookTimePromise = createInitialReservationAction( + selectedVariant, + countryCode, + Number(syncedService.id), + syncedService?.clinic_id, + timeSlot.UserID, + timeSlot.SyncUserID, + timeSlot.StartTime, + booking.selectedLocationId ? booking.selectedLocationId : null, + comments, + ).then(() => { + router.push(pathsConfig.app.cart); + }); + + toast.promise(() => bookTimePromise, { + success: , + error: , + loading: , + }); + }; -const TimeSlots = () => { return ( -
- {dummyData.map((data) => ( - -
- {format(data.StartTime.toString(), 'HH:mm')} -
-
- Dr. Jüri Mardikas -
- Kardioloog - Tervisekassa aeg -
-
- - Ülemiste Tervisemaja 2 - - - Ülemiste füsioteraapiakliinik - - - Sepapaja 2/1 - - Tallinn -
+
+
+ {paginatedBookings.map((timeSlot, index) => { + const isEHIF = timeSlot.HKServiceID > 0; + const serviceProviderTitle = getServiceProviderTitle( + currentLocale, + timeSlot.serviceProvider, + ); + const price = + booking.selectedService?.variants?.[0]?.calculated_price + ?.calculated_amount; + return ( + +
+ {formatDateAndTime(timeSlot.StartTime.toString())} +
+
+ {timeSlot.serviceProvider?.name} +
+ {serviceProviderTitle && ( + + {serviceProviderTitle} + + )} + {isEHIF && {t('booking:ehifBooking')}} +
+
+ {timeSlot.location?.address} +
+
+
+ + {formatCurrency({ + currencyCode: 'EUR', + locale: 'et-EE', + value: price ?? '', + })} + + +
+
+ ); + })} + + {!paginatedBookings.length && ( +
+

{t('booking:noResults')}

-
- - {formatCurrency({ - currencyCode: 'EUR', - locale: 'et-EE', - value: 20, - })} - -
+ + {totalPages > 1 && ( +
+
+ {t('common:pageOfPages', { + page: currentPage, + total: totalPages, + })} +
+ +
+ + + {generatePageNumbers().map((page, index) => ( + + ))} + +
- - ))} +
+ )}
); }; diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx index bd7f8a7..bb6c36f 100644 --- a/app/home/(user)/_components/order-analyses-cards.tsx +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -101,7 +101,6 @@ export default function OrderAnalysesCards({ {title} {description && ( <> - {' '} diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx index 077d761..6833b0a 100644 --- a/app/home/(user)/_components/orders/order-block.tsx +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -5,17 +5,19 @@ import OrderItemsTable from "./order-items-table"; import Link from "next/link"; import { Eye } from "lucide-react"; -export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsOther }: { - analysisOrder: AnalysisOrder, +export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsTtoService, itemsOther, medusaOrderId }: { + analysisOrder?: AnalysisOrder, itemsAnalysisPackage: StoreOrderLineItem[], + itemsTtoService: StoreOrderLineItem[], itemsOther: StoreOrderLineItem[], + medusaOrderId: string, }) { return (

- +

-
+ {analysisOrder &&
@@ -26,9 +28,10 @@ export default function OrderBlock({ analysisOrder, itemsAnalysisPackage, itemsO -
+
}
- + {analysisOrder && } + {itemsTtoService && }
diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index b1e4852..6032bb5 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'next/navigation'; import { StoreOrderLineItem } from '@medusajs/types'; import { formatDate } from 'date-fns'; -import { Eye } from 'lucide-react'; import { pathsConfig } from '@kit/shared/config'; import { Button } from '@kit/ui/button'; @@ -22,14 +21,18 @@ import { AnalysisOrder } from '~/lib/services/order.service'; import { logAnalysisResultsNavigateAction } from './actions'; +export type OrderItemType = 'analysisOrder' | 'ttoService'; + export default function OrderItemsTable({ items, title, analysisOrder, + type = 'analysisOrder', }: { items: StoreOrderLineItem[]; title: string; - analysisOrder: AnalysisOrder; + analysisOrder?: AnalysisOrder; + type?: OrderItemType; }) { const router = useRouter(); @@ -37,9 +40,13 @@ export default function OrderItemsTable({ return null; } + const isAnalysisOrder = type === 'analysisOrder'; + const openAnalysisResults = async () => { - await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); - router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + if (analysisOrder) { + await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); + router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + } }; return ( @@ -52,10 +59,10 @@ export default function OrderItemsTable({ - + - + {isAnalysisOrder && } @@ -65,7 +72,7 @@ export default function OrderItemsTable({ ) .map((orderItem) => ( - +

{orderItem.product_title}

@@ -76,14 +83,18 @@ export default function OrderItemsTable({
- + - - - + {isAnalysisOrder && ( + + + + )}
))}
diff --git a/app/home/(user)/_components/service-categories.tsx b/app/home/(user)/_components/service-categories.tsx index 4e0aa20..6998b78 100644 --- a/app/home/(user)/_components/service-categories.tsx +++ b/app/home/(user)/_components/service-categories.tsx @@ -17,6 +17,7 @@ export interface ServiceCategory { color: string; description: string; products: StoreProduct[]; + countryCode: string; } const ServiceCategories = ({ diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts new file mode 100644 index 0000000..7543d34 --- /dev/null +++ b/app/home/(user)/_lib/server/actions.ts @@ -0,0 +1,65 @@ +'use server'; + +import { StoreProductVariant } from '@medusajs/types'; + +import { + bookAppointment, + createInitialReservation, +} from '~/lib/services/connected-online.service'; +import { handleAddToCart } from '~/lib/services/medusaCart.service'; + +import { updateLineItem } from '../../../../../packages/features/medusa-storefront/src/lib/data'; + +export async function bookTimeAction( + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserId: number, + startTime: Date, + comments?: string, +) { + return bookAppointment( + serviceId, + clinicId, + appointmentUserId, + syncUserId, + startTime, + comments, + ); +} + +export async function createInitialReservationAction( + selectedVariant: Pick, + countryCode: string, + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserId: number, + startTime: Date, + locationId: number | null, + comments?: string, +) { + const { addedItem } = await handleAddToCart({ + selectedVariant, + countryCode, + }); + + if (addedItem) { + const reservation = await createInitialReservation( + serviceId, + clinicId, + appointmentUserId, + syncUserId, + startTime, + addedItem.id, + locationId, + comments, + ); + + await updateLineItem({ + lineId: addedItem.id, + quantity: addedItem.quantity, + metadata: { connectedOnlineReservationId: reservation.id }, + }); + } +} diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts index cd16e61..c2e26e5 100644 --- a/app/home/(user)/_lib/server/load-analyses.ts +++ b/app/home/(user)/_lib/server/load-analyses.ts @@ -45,10 +45,6 @@ async function analysesLoader() { }) : null; - const serviceCategories = productCategories.filter( - ({ parent_category }) => parent_category?.handle === 'tto-categories', - ); - return { analyses: categoryProducts?.response.products diff --git a/app/home/(user)/_lib/server/load-category.ts b/app/home/(user)/_lib/server/load-category.ts index 1a2514c..72e22d4 100644 --- a/app/home/(user)/_lib/server/load-category.ts +++ b/app/home/(user)/_lib/server/load-category.ts @@ -1,20 +1,30 @@ import { cache } from 'react'; -import { getProductCategories } from '@lib/data'; +import { getProductCategories, listProducts } from '@lib/data'; -import { ServiceCategory } from '../../_components/service-categories'; - -async function categoryLoader({ - handle, -}: { - handle: string; -}): Promise<{ category: ServiceCategory | null }> { - const response = await getProductCategories({ - handle, - fields: '*products, is_active, metadata', - }); +import { loadCountryCodes } from './load-analyses'; +async function categoryLoader({ handle }: { handle: string }) { + const [response, countryCodes] = await Promise.all([ + getProductCategories({ + handle, + limit: 1, + }), + loadCountryCodes(), + ]); const category = response.product_categories[0]; + const countryCode = countryCodes[0]!; + + if (!response.product_categories?.[0]?.id) { + return { category: null }; + } + + const { + response: { products: categoryProducts }, + } = await listProducts({ + countryCode, + queryParams: { limit: 100, category_id: response.product_categories[0].id }, + }); return { category: { @@ -25,7 +35,8 @@ async function categoryLoader({ description: category?.description || '', handle: category?.handle || '', name: category?.name || '', - products: category?.products || [], + countryCode, + products: categoryProducts, }, }; } diff --git a/app/home/(user)/_lib/server/load-tto-services.ts b/app/home/(user)/_lib/server/load-tto-services.ts index 3bbc4e5..530f959 100644 --- a/app/home/(user)/_lib/server/load-tto-services.ts +++ b/app/home/(user)/_lib/server/load-tto-services.ts @@ -10,38 +10,36 @@ async function ttoServicesLoader() { }); const heroCategories = response.product_categories?.filter( - ({ parent_category, is_active, metadata }) => - parent_category?.handle === 'tto-categories' && - is_active && - metadata?.isHero, + ({ parent_category, metadata }) => + parent_category?.handle === 'tto-categories' && metadata?.isHero, ); const ttoCategories = response.product_categories?.filter( - ({ parent_category, is_active, metadata }) => - parent_category?.handle === 'tto-categories' && - is_active && - !metadata?.isHero, + ({ parent_category, metadata }) => + parent_category?.handle === 'tto-categories' && !metadata?.isHero, ); return { heroCategories: - heroCategories.map( - ({ name, handle, metadata, description }) => ({ + heroCategories.map>( + ({ name, handle, metadata, description, products }) => ({ name, handle, color: typeof metadata?.color === 'string' ? metadata.color : 'primary', description, + products: products ?? [], }), ) ?? [], ttoCategories: - ttoCategories.map( - ({ name, handle, metadata, description }) => ({ + ttoCategories.map>( + ({ name, handle, metadata, description, products }) => ({ name, handle, color: typeof metadata?.color === 'string' ? metadata.color : 'primary', description, + products: products ?? [], }), ) ?? [], }; diff --git a/lib/services/audit.service.ts b/lib/services/audit.service.ts index 880c037..ebab1f0 100644 --- a/lib/services/audit.service.ts +++ b/lib/services/audit.service.ts @@ -1,14 +1,14 @@ - 'use server' - -import { RequestStatus } from '@/lib/types/audit'; +'use server'; + +import { RequestStatus, SyncStatus } from '@/lib/types/audit'; import { ConnectedOnlineMethodName } from '@/lib/types/connected-online'; import { ExternalApi } from '@/lib/types/external'; import { MedipostAction } from '@/lib/types/medipost'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; -import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; + export default async function logRequestResult( - /* personalCode: string, */ requestApi: keyof typeof ExternalApi, + requestApi: keyof typeof ExternalApi, requestApiMethod: `${ConnectedOnlineMethodName}` | `${MedipostAction}`, status: RequestStatus, comment?: string, @@ -16,11 +16,10 @@ export default async function logRequestResult( serviceId?: number, serviceProviderId?: number, ) { - const { error } = await getSupabaseServerClient() + const { error } = await getSupabaseServerAdminClient() .schema('audit') .from('request_entries') .insert({ - /* personal_code: personalCode, */ request_api: requestApi, request_api_method: requestApiMethod, requested_start_date: startTime, @@ -69,3 +68,29 @@ export async function getMedipostDispatchTries(medusaOrderId: string) { return data; } + +export async function logSyncResult({ + operation, + comment, + status, + changed_by_role, +}: { + operation: string; + comment?: string; + status: SyncStatus; + changed_by_role: string; +}) { + const { error } = await getSupabaseServerAdminClient() + .schema('audit') + .from('sync_entries') + .insert({ + operation, + comment, + status, + changed_by_role, + }); + + if (error) { + throw new Error('Failed to insert log entry, error: ' + error.message); + } +} diff --git a/lib/services/connected-online.service.ts b/lib/services/connected-online.service.ts index e0628cc..e3cdc5d 100644 --- a/lib/services/connected-online.service.ts +++ b/lib/services/connected-online.service.ts @@ -7,19 +7,30 @@ import { BookTimeResponse, ConfirmedLoadResponse, ConnectedOnlineMethodName, + FailureReason, } from '@/lib/types/connected-online'; import { ExternalApi } from '@/lib/types/external'; import { Tables } from '@/packages/supabase/src/database.types'; -import { createClient } from '@/utils/supabase/server'; +import { StoreOrder } from '@medusajs/types'; import axios from 'axios'; +import { uniq, uniqBy } from 'lodash'; + +import { renderBookTimeFailedEmail } from '@kit/email-templates'; +import { getLogger } from '@kit/shared/logger'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { TimeSlotResponse } from '../../app/home/(user)/_components/booking/booking.context'; +import { sendEmailFromTemplate } from './mailer.service'; +import { handleDeleteCartItem } from './medusaCart.service'; export async function getAvailableAppointmentsForService( serviceId: number, + key: string, + locationId: number | null, startTime?: Date, ) { try { - const showTimesFrom = startTime ? { StartTime: startTime } : {}; - + const start = startTime ? { StartTime: startTime } : {}; const response = await axios.post( `${process.env.CONNECTED_ONLINE_URL!}/${ConnectedOnlineMethodName.GetAvailabilities}`, { @@ -28,9 +39,11 @@ export async function getAvailableAppointmentsForService( }, param: JSON.stringify({ ServiceID: serviceId, - Key: '7T624nlu', + Key: key, Lang: 'et', - ...showTimesFrom, + MaxDays: 120, + LocationId: locationId ?? -1, + ...start, }), }, ); @@ -51,12 +64,12 @@ export async function getAvailableAppointmentsForService( : `No booking times present in appointment availability response, service id: ${serviceId}, start time: ${startTime}`; } - // await logRequestResult( - // ExternalApi.ConnectedOnline, - // ConnectedOnlineMethodName.GetAvailabilities, - // RequestStatus.Fail, - // comment, - // ); + await logRequestResult( + ExternalApi.ConnectedOnline, + ConnectedOnlineMethodName.GetAvailabilities, + RequestStatus.Fail, + comment, + ); return null; } @@ -80,157 +93,210 @@ export async function getAvailableAppointmentsForService( } export async function bookAppointment( - serviceSyncId: number, + serviceId: number, clinicId: number, appointmentUserId: number, syncUserID: number, startTime: string, - locationId = 0, comments = '', - isEarlierTimeRequested = false, - earlierTimeRequestComment = '', ) { - const supabase = await createClient(); + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + let reason = FailureReason.BOOKING_FAILED; try { const { data: { user }, } = await supabase.auth.getUser(); + logger.info( + `Booking time slot ${JSON.stringify({ serviceId, clinicId, startTime, userId: user?.id })}`, + ); + if (!user?.id) { throw new Error('User not authenticated'); } + const formattedStartTime = startTime.replace('T', ' '); + const [ { data: dbClinic, error: clinicError }, { data: dbService, error: serviceError }, + { data: account, error: accountError }, + { data: dbReservation, error: dbReservationError }, ] = await Promise.all([ supabase .schema('medreport') .from('connected_online_providers') .select('*') .eq('id', clinicId) - .limit(1), + .single(), supabase .schema('medreport') .from('connected_online_services') .select('*') - .eq('sync_id', serviceSyncId) + .eq('id', serviceId) .eq('clinic_id', clinicId) - .limit(1), + .single(), + supabase + .schema('medreport') + .from('accounts') + .select('name, last_name, personal_code, phone, email') + .eq('is_personal_account', true) + .eq('primary_owner_user_id', user.id) + .single(), + supabase + .schema('medreport') + .from('connected_online_reservation') + .select('id') + .eq('clinic_id', clinicId) + .eq('service_id', serviceId) + .eq('start_time', formattedStartTime) + .eq('user_id', user.id) + .eq('status', 'PENDING') + .single(), ]); - if (!dbClinic?.length || !dbService?.length) { - return logRequestResult( - ExternalApi.ConnectedOnline, - ConnectedOnlineMethodName.BookTime, - RequestStatus.Fail, - dbClinic?.length - ? `Could not find clinic with id ${clinicId}, error: ${JSON.stringify(clinicError)}` - : `Could not find service with sync id ${serviceSyncId} and clinic id ${clinicId}, error: ${JSON.stringify(serviceError)}`, - startTime, - serviceSyncId, - clinicId, - ); + if (!dbClinic || !dbService) { + const errorMessage = dbClinic + ? `Could not find clinic with id ${clinicId}, error: ${JSON.stringify(clinicError)}` + : `Could not find service with sync id ${serviceId} and clinic id ${clinicId}, error: ${JSON.stringify(serviceError)}`; + logger.error(errorMessage); + + throw new Error(errorMessage); + } + + if (clinicError || serviceError || accountError) { + const stringifiedErrors = JSON.stringify({ + clinicError, + serviceError, + accountError, + }); + const errorMessage = `Failed to book time, error: ${stringifiedErrors}`; + logger.error(errorMessage); + + throw new Error(errorMessage); + } + + if (!dbReservation) { + const errorMessage = `No reservation found in db with data ${JSON.stringify({ clinicId, serviceId, startTime, userId: user.id })}, got error ${JSON.stringify(dbReservationError)}`; + logger.error(errorMessage); + + throw new Error(errorMessage); } const clinic: Tables< { schema: 'medreport' }, 'connected_online_providers' - > = dbClinic![0]; + > = dbClinic; const service: Tables< { schema: 'medreport' }, 'connected_online_services' - > = dbService![0]; + > = dbService; - // TODO the dummy data needs to be replaced with real values once they're present on the user/account - const response = await axios.post( + const connectedOnlineBookingResponse = await axios.post( `${process.env.CONNECTED_ONLINE_URL!}/${ConnectedOnlineMethodName.BookTime}`, { headers: { 'Content-Type': 'application/json; charset=utf-8', }, param: JSON.stringify({ - EarlierTime: isEarlierTimeRequested, // once we have the e-shop functionality we can let the user select if she would like to be offered earlier time slots if they become available - EarlierTimeComment: earlierTimeRequestComment, ClinicID: clinic.id, ServiceID: service.id, ClinicServiceID: service.sync_id, UserID: appointmentUserId, SyncUserID: syncUserID, StartTime: startTime, - FirstName: 'Test', - LastName: 'User', - PersonalCode: '4', - Email: user.email, - Phone: 'phone', + FirstName: account.name, + LastName: account.last_name, + PersonalCode: account.personal_code, + Email: account.email ?? user.email, + Phone: account.phone, Comments: comments, - Location: locationId, - FreeCode: '', AddToBasket: false, - Key: '7T624nlu', - Lang: 'et', // update when integrated into app, if needed + Key: dbClinic.key, + Lang: 'et', }), }, ); - const responseData: BookTimeResponse = JSON.parse(response.data.d); + const connectedOnlineBookingResponseData: BookTimeResponse = JSON.parse( + connectedOnlineBookingResponse.data.d, + ); - if (responseData?.ErrorCode !== 0 || !responseData.Value) { - return logRequestResult( - ExternalApi.ConnectedOnline, - ConnectedOnlineMethodName.BookTime, - RequestStatus.Fail, - JSON.stringify(responseData), - startTime, - service.id, - clinicId, + const errorCode = connectedOnlineBookingResponseData?.ErrorCode; + if (errorCode !== 0 || !connectedOnlineBookingResponseData.Value) { + const errorMessage = `Received invalid result from external api, error: ${JSON.stringify(connectedOnlineBookingResponseData)}`; + logger.error(errorMessage); + if (process.env.SUPPORT_EMAIL) { + await sendEmailFromTemplate( + renderBookTimeFailedEmail, + { reservationId: dbReservation.id, error: errorMessage }, + process.env.SUPPORT_EMAIL, + ); + } + await supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + status: 'REJECTED', + }) + .eq('id', dbReservation.id) + .throwOnError(); + + if (errorCode === 1) { + reason = FailureReason.TIME_SLOT_UNAVAILABLE; + } + + throw new Error(errorMessage); + } + + const responseParts = connectedOnlineBookingResponseData.Value.split(','); + + const { data: updatedReservation, error } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + booking_code: responseParts[1], + requires_payment: !!responseParts[0], + status: 'CONFIRMED', + }) + .eq('id', dbReservation.id) + .select('id') + .single(); + + if (error) { + throw new Error( + JSON.stringify({ connectedOnlineBookingResponseData, error }), ); } - const responseParts = responseData.Value.split(','); - - const { error } = await supabase - .schema('medreport') - .from('connected_online_reservation') - .insert({ - booking_code: responseParts[1], - clinic_id: clinic.id, - comments, - lang: 'et', // change later, if needed - service_id: service.id, - service_user_id: appointmentUserId, - start_time: startTime, - sync_user_id: syncUserID, - requires_payment: !!responseParts[0], - user_id: user.id, - }); + logger.info( + 'Booked time, updated reservation with id ' + updatedReservation?.id, + ); await logRequestResult( ExternalApi.ConnectedOnline, ConnectedOnlineMethodName.BookTime, RequestStatus.Success, - JSON.stringify(responseData), - startTime, + JSON.stringify(connectedOnlineBookingResponseData), + startTime.toString(), service.id, clinicId, ); - - if (error) { - throw new Error(error.message); - } - - return responseData.Value; + return { success: true }; } catch (error) { - return logRequestResult( + logger.error(`Failed to book time, error: ${JSON.stringify(error)}`); + await logRequestResult( ExternalApi.ConnectedOnline, ConnectedOnlineMethodName.BookTime, RequestStatus.Fail, JSON.stringify(error), - startTime, - serviceSyncId, + startTime.toString(), + serviceId, clinicId, ); + return { success: false, reason }; } } @@ -270,8 +336,182 @@ export async function getConfirmedService(reservationCode: string) { ExternalApi.ConnectedOnline, ConnectedOnlineMethodName.ConfirmedLoad, RequestStatus.Fail, - JSON.stringify(error), + error?.toString(), ); return null; } } + +export async function getAvailableTimeSlotsForDisplay( + serviceIds: number[], + locationId: number | null, + date?: Date, +): Promise { + const supabase = getSupabaseServerClient(); + + const { data: syncedServices } = await supabase + .schema('medreport') + .from('connected_online_services') + .select( + '*, providerClinic:clinic_id(*,locations:connected_online_locations(*))', + ) + .in('id', serviceIds) + .throwOnError(); + + const timeSlotPromises = []; + for (const syncedService of syncedServices) { + const timeSlotsPromise = getAvailableAppointmentsForService( + syncedService.id, + syncedService.providerClinic.key, + locationId, + date, + ); + timeSlotPromises.push(timeSlotsPromise); + } + + const timeSlots = await Promise.all(timeSlotPromises); + + const mappedTimeSlots = []; + for (const timeSlotGroup of timeSlots) { + const { data: serviceProviders } = await supabase + .schema('medreport') + .from('connected_online_service_providers') + .select( + 'name, id, jobTitleEn: job_title_en, jobTitleEt: job_title_et, jobTitleRu: job_title_ru, clinicId: clinic_id', + ) + .in( + 'clinic_id', + uniq(timeSlotGroup?.T_Booking.map(({ ClinicID }) => ClinicID)), + ) + .throwOnError(); + + const timeSlots = + timeSlotGroup?.T_Booking?.map((item) => { + return { + ...item, + serviceProvider: serviceProviders.find( + ({ id }) => id === item.UserID, + ), + syncedService: syncedServices.find( + (syncedService) => syncedService.sync_id === item.ServiceID, + ), + location: syncedServices + .find( + ({ providerClinic }) => + providerClinic.id === Number(item.ClinicID), + ) + ?.providerClinic?.locations?.find( + (location) => location.sync_id === item.LocationID, + ), + }; + }) ?? []; + mappedTimeSlots.push(...timeSlots); + } + + return { + timeSlots: mappedTimeSlots, + locations: uniqBy( + syncedServices.flatMap(({ providerClinic }) => providerClinic.locations), + 'id', + ), + }; +} + +export async function createInitialReservation( + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserID: number, + startTime: Date, + medusaLineItemId: string, + locationId?: number | null, + comments = '', +) { + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const userId = user?.id; + + if (!userId) { + throw new Error('User not authenticated'); + } + + logger.info( + 'Creating reservation' + + JSON.stringify({ serviceId, clinicId, startTime, userId }), + ); + + try { + const { data: createdReservation } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .insert({ + clinic_id: clinicId, + comments, + lang: 'et', + service_id: serviceId, + service_user_id: appointmentUserId, + start_time: startTime.toString(), + sync_user_id: syncUserID, + user_id: userId, + status: 'PENDING', + medusa_cart_line_item_id: medusaLineItemId, + location_sync_id: locationId, + }) + .select('id') + .single() + .throwOnError(); + + logger.info( + `Created reservation ${JSON.stringify({ createdReservation, userId })}`, + ); + + return createdReservation; + } catch (e) { + logger.error( + `Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`, + ); + await handleDeleteCartItem({ lineId: medusaLineItemId }); + throw e; + } +} + +export async function cancelReservation(medusaLineItemId: string) { + const supabase = getSupabaseServerClient(); + + return supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + status: 'CANCELLED', + }) + .eq('medusa_cart_line_item_id', medusaLineItemId) + .throwOnError(); +} + +export async function getOrderedTtoServices({ + medusaOrder, +}: { + medusaOrder: StoreOrder; +}) { + const supabase = getSupabaseServerClient(); + + const ttoReservationIds: number[] = + medusaOrder.items + ?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId) + .map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ?? + []; + + const { data: orderedTtoServices } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .select('*') + .in('id', ttoReservationIds) + .throwOnError(); + + return orderedTtoServices; +} diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index c33ed0d..fcb4b62 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { requireUserInServerComponent } from '../server/require-user-in-server-component'; +import { cancelReservation } from './connected-online.service'; const env = () => z @@ -26,8 +27,10 @@ const env = () => .min(1), }) .parse({ - medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, - siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, + medusaBackendPublicUrl: + 'http://weebhook.site:3000' /* process.env.MEDUSA_BACKEND_PUBLIC_URL! */, + siteUrl: + 'http://weebhook.site:3000' /* process.env.NEXT_PUBLIC_SITE_URL! */, }); export async function handleAddToCart({ @@ -44,7 +47,7 @@ export async function handleAddToCart({ } const quantity = 1; - const cart = await addToCart({ + const { newCart, addedItem } = await addToCart({ variantId: selectedVariant.id, quantity, countryCode, @@ -54,18 +57,19 @@ export async function handleAddToCart({ variant_id: selectedVariant.id, operation: 'ADD_TO_CART', account_id: account.id, - cart_id: cart.id, + cart_id: newCart.id, changed_by: user.id, }); if (error) { throw new Error('Error logging cart entry: ' + error.message); } - return cart; + return { cart: newCart, addedItem }; } export async function handleDeleteCartItem({ lineId }: { lineId: string }) { await deleteLineItem(lineId); + await cancelReservation(lineId); const supabase = getSupabaseServerClient(); const cartId = await getCartId(); diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index eced8f9..0fa715c 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -152,3 +152,29 @@ export async function getAnalysisOrdersAdmin({ const orders = await query.order('created_at', { ascending: false }).throwOnError(); return orders.data; } + +export async function getTtoOrders({ + orderStatus, +}: { + orderStatus?: Tables<{ schema: 'medreport' }, 'connected_online_reservation'>['status']; +} = {}) { + const client = getSupabaseServerClient(); + + const { + data: { user }, + } = await client.auth.getUser(); + if (!user) { + throw new Error('Unauthorized'); + } + + const query = client + .schema('medreport') + .from('connected_online_reservation') + .select('*') + .eq("user_id", user.id) + if (orderStatus) { + query.eq('status', orderStatus); + } + const orders = await query.order('created_at', { ascending: false }).throwOnError(); + return orders.data; +} \ No newline at end of file diff --git a/lib/types/connected-online.ts b/lib/types/connected-online.ts index 57e95d4..41ddcc2 100644 --- a/lib/types/connected-online.ts +++ b/lib/types/connected-online.ts @@ -10,13 +10,15 @@ export type BookTimeResponse = z.infer; export enum ConnectedOnlineMethodName { SearchLoad = 'Search_Load', + DefaultLoad = 'Default_Load', + ConfirmedCancel = 'Confirmed_Cancel', GetAvailabilities = 'GetAvailabilities', BookTime = 'BookTime', ConfirmedLoad = 'Confirmed_Load', } export const AvailableAppointmentTBookingSchema = z.object({ - ClinicID: z.string(), + ClinicID: z.number(), LocationID: z.number(), UserID: z.number(), SyncUserID: z.number(), @@ -225,6 +227,18 @@ export const ConfirmedLoadResponseSchema = z.object({ }); export type ConfirmedLoadResponse = z.infer; +export type P_JobTitleTranslation = { + ID: number; + SyncID: number; + TextEN: string; + TextET: string; + TextFI: string; + TextRU: string; + TextLT: string; + ClinicID: number; + Deleted: number; +}; + export interface ISearchLoadResponse { Value: string; Data: { @@ -232,9 +246,11 @@ export interface ISearchLoadResponse { ID: number; Name: string; OnlineCanSelectWorker: boolean; + Address: string; Email: string | null; PersonalCodeRequired: boolean; Phone: string | null; + Key: string; }[]; T_Service: { ID: number; @@ -253,7 +269,14 @@ export interface ISearchLoadResponse { RequiresPayment: boolean; SyncID: string; }[]; + T_Doctor: TDoctor[]; + P_JobTitleTranslations: P_JobTitleTranslation[]; }; ErrorCode: number; ErrorMessage: string; } + +export enum FailureReason { + BOOKING_FAILED = 'BOOKING_FAILED', + TIME_SLOT_UNAVAILABLE = 'TIME_SLOT_UNAVAILABLE', +} diff --git a/lib/utils.ts b/lib/utils.ts index 233042a..f03f520 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -20,7 +20,7 @@ export function toTitleCase(str?: string) { ?.toLowerCase() .replace(/[^-'’\s]+/g, (match) => match.replace(/^./, (first) => first.toUpperCase()), - ) ?? "" + ) ?? '' ); } @@ -145,6 +145,13 @@ export default class PersonalCode { gender, dob: parsed.getBirthday(), age: parsed.getAge(), - } + }; } } + +export const findProductTypeIdByHandle = ( + productTypes: { metadata?: Record | null; id: string }[], + handle: string, +) => { + return productTypes.find(({ metadata }) => metadata?.handle === handle)?.id; +}; diff --git a/packages/email-templates/src/emails/book-time-failed.email.tsx b/packages/email-templates/src/emails/book-time-failed.email.tsx new file mode 100644 index 0000000..7c4cb93 --- /dev/null +++ b/packages/email-templates/src/emails/book-time-failed.email.tsx @@ -0,0 +1,61 @@ +import { + Body, + Head, + Html, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; + +export async function renderBookTimeFailedEmail({ + reservationId, + error, +}: { + reservationId: number; + error: string; +}) { + const subject = 'Aja broneerimine ei õnnestunud'; + + const html = await render( + + + + + + {subject} + + + + + + + {subject} + + + Tere + + + + Broneeringu {reservationId} Connected Online'i saatmine ei + õnnestunud, kliendile tuleb teha tagasimakse. + + Saadud error: {error} + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts index cae4d3f..227390a 100644 --- a/packages/email-templates/src/index.ts +++ b/packages/email-templates/src/index.ts @@ -10,3 +10,4 @@ export * from './emails/all-results-received.email'; export * from './emails/order-processing.email'; export * from './emails/patient-first-results-received.email'; export * from './emails/patient-full-results-received.email'; +export * from './emails/book-time-failed.email' diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index 0bb1cfd..5a0cd84 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -15,6 +15,7 @@ import { import { getRegion } from "./regions"; import { sdk } from "@lib/config"; import { retrieveOrder } from "./orders"; +import { completeTtoCart } from "../../../../../../lib/services/connected-online.service"; /** * Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies. @@ -89,7 +90,10 @@ export async function getOrSetCart(countryCode: string) { export async function updateCart( { id, ...data }: HttpTypes.StoreUpdateCart & { id?: string }, - { onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} }, + { onSuccess, onError }: { onSuccess: () => void; onError: () => void } = { + onSuccess: () => {}, + onError: () => {}, + } ) { const cartId = id || (await getCartId()); @@ -163,7 +167,12 @@ export async function addToCart({ }) .catch(medusaError); - return cart; + const newCart = await getOrSetCart(countryCode); + const addedItem = newCart.items?.filter( + (item) => !cart.items?.some((oldCartItem) => oldCartItem.id === item.id) + )?.[0]; + + return { newCart, addedItem }; } export async function updateLineItem({ @@ -268,7 +277,10 @@ export async function initiatePaymentSession( export async function applyPromotions( codes: string[], - { onSuccess, onError }: { onSuccess: () => void, onError: () => void } = { onSuccess: () => {}, onError: () => {} }, + { onSuccess, onError }: { onSuccess: () => void; onError: () => void } = { + onSuccess: () => {}, + onError: () => {}, + } ) { const cartId = await getCartId(); @@ -410,7 +422,10 @@ export async function setAddresses(currentState: unknown, formData: FormData) { * @param cartId - optional - The ID of the cart to place an order for. * @returns The cart object if the order was successful, or null if not. */ -export async function placeOrder(cartId?: string, options: { revalidateCacheTags: boolean } = { revalidateCacheTags: true }) { +export async function placeOrder( + cartId?: string, + options: { revalidateCacheTags: boolean } = { revalidateCacheTags: true } +) { const id = cartId || (await getCartId()); if (!id) { diff --git a/packages/features/medusa-storefront/src/lib/data/categories.ts b/packages/features/medusa-storefront/src/lib/data/categories.ts index b4db69d..8e561fa 100644 --- a/packages/features/medusa-storefront/src/lib/data/categories.ts +++ b/packages/features/medusa-storefront/src/lib/data/categories.ts @@ -20,7 +20,6 @@ export const listCategories = async (query?: Record) => { ...query, }, next, - cache: "force-cache", } ) .then(({ product_categories }) => product_categories); @@ -56,7 +55,6 @@ export const getProductCategories = async ({ limit, }, next, - //cache: "force-cache", } ); }; diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts index c9b8d7e..821ebd2 100644 --- a/packages/supabase/src/clients/server-client.ts +++ b/packages/supabase/src/clients/server-client.ts @@ -4,8 +4,8 @@ import { cookies } from 'next/headers'; import { createServerClient } from '@supabase/ssr'; -import { Database } from '../database.types'; import { getSupabaseClientKeys } from '../get-supabase-client-keys'; +import { Database } from '../database.types'; /** * @name getSupabaseServerClient diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index a09d6b8..64fa0c2 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -198,6 +198,7 @@ export type Database = { action: string changed_by: string created_at: string + extra_data: Json | null id: number } Insert: { @@ -205,6 +206,7 @@ export type Database = { action: string changed_by: string created_at?: string + extra_data?: Json | null id?: number } Update: { @@ -212,6 +214,7 @@ export type Database = { action?: string changed_by?: string created_at?: string + extra_data?: Json | null id?: number } Relationships: [] @@ -221,7 +224,6 @@ export type Database = { comment: string | null created_at: string id: number - personal_code: number | null request_api: string request_api_method: string requested_end_date: string | null @@ -229,12 +231,12 @@ export type Database = { service_id: number | null service_provider_id: number | null status: Database["audit"]["Enums"]["request_status"] + user_id: string | null } Insert: { comment?: string | null created_at?: string id?: number - personal_code?: number | null request_api: string request_api_method: string requested_end_date?: string | null @@ -242,12 +244,12 @@ export type Database = { service_id?: number | null service_provider_id?: number | null status: Database["audit"]["Enums"]["request_status"] + user_id?: string | null } Update: { comment?: string | null created_at?: string id?: number - personal_code?: number | null request_api?: string request_api_method?: string requested_end_date?: string | null @@ -255,6 +257,7 @@ export type Database = { service_id?: number | null service_provider_id?: number | null status?: Database["audit"]["Enums"]["request_status"] + user_id?: string | null } Relationships: [] } @@ -990,32 +993,76 @@ export type Database = { } Relationships: [] } + connected_online_locations: { + Row: { + address: string | null + clinic_id: number + created_at: string + id: number + name: string + sync_id: number + updated_at: string | null + } + Insert: { + address?: string | null + clinic_id: number + created_at?: string + id?: number + name: string + sync_id: number + updated_at?: string | null + } + Update: { + address?: string | null + clinic_id?: number + created_at?: string + id?: number + name?: string + sync_id?: number + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "connected_online_locations_clinic_id_fkey" + columns: ["clinic_id"] + isOneToOne: false + referencedRelation: "connected_online_providers" + referencedColumns: ["id"] + }, + ] + } connected_online_providers: { Row: { + address: string can_select_worker: boolean created_at: string email: string | null id: number + key: string name: string personal_code_required: boolean phone_number: string | null updated_at: string | null } Insert: { + address?: string can_select_worker: boolean created_at?: string email?: string | null id: number + key: string name: string personal_code_required: boolean phone_number?: string | null updated_at?: string | null } Update: { + address?: string can_select_worker?: boolean created_at?: string email?: string | null id?: number + key?: string name?: string personal_code_required?: boolean phone_number?: string | null @@ -1025,55 +1072,117 @@ export type Database = { } connected_online_reservation: { Row: { - booking_code: string + booking_code: string | null clinic_id: number comments: string | null created_at: string discount_code: string | null id: number lang: string - requires_payment: boolean + location_sync_id: number | null + medusa_cart_line_item_id: string | null + requires_payment: boolean | null service_id: number - service_user_id: number | null + service_user_id: number start_time: string + status: Database["medreport"]["Enums"]["connected_online_order_status"] sync_user_id: number updated_at: string | null user_id: string } Insert: { - booking_code: string + booking_code?: string | null clinic_id: number comments?: string | null created_at?: string discount_code?: string | null id?: number lang: string - requires_payment: boolean + location_sync_id?: number | null + medusa_cart_line_item_id?: string | null + requires_payment?: boolean | null service_id: number - service_user_id?: number | null + service_user_id: number start_time: string + status: Database["medreport"]["Enums"]["connected_online_order_status"] sync_user_id: number updated_at?: string | null user_id: string } Update: { - booking_code?: string + booking_code?: string | null clinic_id?: number comments?: string | null created_at?: string discount_code?: string | null id?: number lang?: string - requires_payment?: boolean + location_sync_id?: number | null + medusa_cart_line_item_id?: string | null + requires_payment?: boolean | null service_id?: number - service_user_id?: number | null + service_user_id?: number start_time?: string + status?: Database["medreport"]["Enums"]["connected_online_order_status"] sync_user_id?: number updated_at?: string | null user_id?: string } Relationships: [] } + connected_online_service_providers: { + Row: { + clinic_id: number + created_at: string + id: number + is_deleted: boolean | null + job_title_en: string | null + job_title_et: string | null + job_title_id: number | null + job_title_ru: string | null + name: string + prefix: string | null + spoken_languages: string[] | null + updated_at: string | null + } + Insert: { + clinic_id: number + created_at?: string + id: number + is_deleted?: boolean | null + job_title_en?: string | null + job_title_et?: string | null + job_title_id?: number | null + job_title_ru?: string | null + name: string + prefix?: string | null + spoken_languages?: string[] | null + updated_at?: string | null + } + Update: { + clinic_id?: number + created_at?: string + id?: number + is_deleted?: boolean | null + job_title_en?: string | null + job_title_et?: string | null + job_title_id?: number | null + job_title_ru?: string | null + name?: string + prefix?: string | null + spoken_languages?: string[] | null + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "connected_online_service_providers_clinic_id_fkey" + columns: ["clinic_id"] + isOneToOne: false + referencedRelation: "connected_online_providers" + referencedColumns: ["id"] + }, + ] + } connected_online_services: { Row: { clinic_id: number @@ -1091,7 +1200,7 @@ export type Database = { price: number price_periods: string | null requires_payment: boolean - sync_id: string + sync_id: number updated_at: string | null } Insert: { @@ -1110,7 +1219,7 @@ export type Database = { price: number price_periods?: string | null requires_payment: boolean - sync_id: string + sync_id: number updated_at?: string | null } Update: { @@ -1129,7 +1238,7 @@ export type Database = { price?: number price_periods?: string | null requires_payment?: boolean - sync_id?: string + sync_id?: number updated_at?: string | null } Relationships: [ @@ -1150,7 +1259,7 @@ export type Database = { doctor_user_id: string | null id: number status: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at: string + updated_at: string | null updated_by: string | null user_id: string value: string | null @@ -1162,7 +1271,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id: string value?: string | null @@ -1174,7 +1283,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id?: string value?: string | null @@ -1259,23 +1368,36 @@ export type Database = { } medipost_actions: { Row: { - created_at: string - id: number action: string - xml: string + created_at: string | null has_analysis_results: boolean - medusa_order_id: string - response_xml: string has_error: boolean + id: string + medusa_order_id: string | null + response_xml: string | null + xml: string | null } Insert: { action: string - xml: string - has_analysis_results: boolean - medusa_order_id: string - response_xml: string - has_error: boolean + created_at?: string | null + has_analysis_results?: boolean + has_error?: boolean + id?: string + medusa_order_id?: string | null + response_xml?: string | null + xml?: string | null } + Update: { + action?: string + created_at?: string | null + has_analysis_results?: boolean + has_error?: boolean + id?: string + medusa_order_id?: string | null + response_xml?: string | null + xml?: string | null + } + Relationships: [] } medreport_product_groups: { Row: { @@ -1944,6 +2066,13 @@ export type Database = { personal_code: string }[] } + get_latest_medipost_dispatch_state_for_order: { + Args: { medusa_order_id: string } + Returns: { + action_date: string + has_success: boolean + }[] + } get_medipost_dispatch_tries: { Args: { p_medusa_order_id: string } Returns: number @@ -2036,9 +2165,9 @@ export type Database = { Args: { account_id: string; user_id: string } Returns: boolean } - medipost_retry_dispatch: { - Args: { order_id: string } - Returns: Json + order_has_medipost_dispatch_error: { + Args: { medusa_order_id: string } + Returns: boolean } revoke_nonce: { Args: { p_id: string; p_reason?: string } @@ -2065,16 +2194,26 @@ export type Database = { Returns: undefined } update_account: { - Args: { - p_city: string - p_has_consent_personal_data: boolean - p_last_name: string - p_name: string - p_personal_code: string - p_phone: string - p_uid: string - p_email: string - } + Args: + | { + p_city: string + p_email: string + p_has_consent_personal_data: boolean + p_last_name: string + p_name: string + p_personal_code: string + p_phone: string + p_uid: string + } + | { + p_city: string + p_has_consent_personal_data: boolean + p_last_name: string + p_name: string + p_personal_code: string + p_phone: string + p_uid: string + } Returns: undefined } update_analysis_order_status: { @@ -2182,6 +2321,11 @@ export type Database = { | "invites.manage" application_role: "user" | "doctor" | "super_admin" billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio" + connected_online_order_status: + | "PENDING" + | "CONFIRMED" + | "REJECTED" + | "CANCELLED" locale: "en" | "et" | "ru" notification_channel: "in_app" | "email" notification_type: "info" | "warning" | "error" @@ -8099,6 +8243,12 @@ export const Constants = { ], application_role: ["user", "doctor", "super_admin"], billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"], + connected_online_order_status: [ + "PENDING", + "CONFIRMED", + "REJECTED", + "CANCELLED", + ], locale: ["en", "et", "ru"], notification_channel: ["in_app", "email"], notification_type: ["info", "warning", "error"], diff --git a/packages/ui/src/shadcn/calendar.tsx b/packages/ui/src/shadcn/calendar.tsx index 030dd5a..138278d 100644 --- a/packages/ui/src/shadcn/calendar.tsx +++ b/packages/ui/src/shadcn/calendar.tsx @@ -38,7 +38,7 @@ function Calendar({ head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]', row: 'flex w-full mt-2', - cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20', + cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-md focus-within:relative focus-within:z-20', day: cn( buttonVariants({ variant: 'ghost' }), 'h-9 w-9 p-0 font-normal aria-selected:opacity-100', diff --git a/public/locales/en/booking.json b/public/locales/en/booking.json index f4ef7c2..8a38d9f 100644 --- a/public/locales/en/booking.json +++ b/public/locales/en/booking.json @@ -4,5 +4,7 @@ "analysisPackages": { "title": "Analysis packages", "description": "Get to know the personal analysis packages and order" - } + }, + "noCategories": "List of services not found, try again later", + "noResults": "No availabilities found for selected dates" } \ No newline at end of file diff --git a/public/locales/et/booking.json b/public/locales/et/booking.json index 3410d59..bc2d2cd 100644 --- a/public/locales/et/booking.json +++ b/public/locales/et/booking.json @@ -5,5 +5,13 @@ "title": "Analüüside paketid", "description": "Tutvu personaalsete analüüsi pakettidega ja telli" }, - "noCategories": "Teenuste loetelu ei leitud, proovi hiljem uuesti" -} + "noCategories": "Teenuste loetelu ei leitud, proovi hiljem uuesti", + "noResults": "Valitud kuupäevadel ei ole vabu aegu", + "services": "Teenused", + "locations": "Asutused", + "showAll": "Kuva kõik", + "showAllLocations": "Näita kõiki asutusi", + "bookTimeSuccess": "Aeg valitud", + "bookTimeError": "Aega ei õnnestunud valida", + "bookTimeLoading": "Aega valitakse..." +} \ No newline at end of file diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index 36b69d0..2b2bfa5 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -14,7 +14,9 @@ "goToDashboard": "Jätkan", "error": { "title": "Midagi läks valesti", - "description": "Palun proovi hiljem uuesti." + "description": "Palun proovi hiljem uuesti.", + "BOOKING_FAILED": "Teenuse tõrge, proovi hiljem uuesti.", + "TIME_SLOT_UNAVAILABLE": "Valitud aeg ei ole saadaval." }, "timeLeft": "Aega jäänud {{timeLeft}}", "timeoutTitle": "Broneering aegus", diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 96f3572..7611586 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -147,5 +147,6 @@ "language": "Keel", "yes": "Jah", "no": "Ei", - "preferNotToAnswer": "Eelistan mitte vastata" + "preferNotToAnswer": "Eelistan mitte vastata", + "book": "Broneeri" } \ No newline at end of file diff --git a/public/locales/et/orders.json b/public/locales/et/orders.json index 822c6b1..e266935 100644 --- a/public/locales/et/orders.json +++ b/public/locales/et/orders.json @@ -4,17 +4,33 @@ "noOrders": "Tellimusi ei leitud", "table": { "analysisPackage": "Analüüsi pakett", + "ttoService": "Broneering", "otherOrders": "Tellimus", "createdAt": "Tellitud", "status": "Olek" }, "status": { "QUEUED": "Esitatud", - "PROCESSING": "Synlabile edastatud", + "PROCESSING": "Edastatud", "PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused", - "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes, ootab arsti kokkuvõtet", + "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes", "COMPLETED": "Kinnitatud", "REJECTED": "Tagastatud", - "CANCELLED": "Tühistatud" + "CANCELLED": "Tühistatud", + "analysisOrder": { + "QUEUED": "Esitatud", + "PROCESSING": "Synlabile edastatud", + "PARTIAL_ANALYSIS_RESPONSE": "Osalised tulemused", + "FULL_ANALYSIS_RESPONSE": "Kõik tulemused käes, ootab arsti kokkuvõtet", + "COMPLETED": "Kinnitatud", + "REJECTED": "Tagastatud", + "CANCELLED": "Tühistatud" + }, + "ttoService": { + "PENDING": "Alustatud", + "CONFIRMED": "Kinnitatud", + "REJECTED": "Tagasi lükatud", + "CANCELLED": "Tühistatud" + } } } \ No newline at end of file diff --git a/public/locales/ru/booking.json b/public/locales/ru/booking.json index 042a4b6..df29269 100644 --- a/public/locales/ru/booking.json +++ b/public/locales/ru/booking.json @@ -5,5 +5,6 @@ "title": "Пакеты анализов", "description": "Ознакомьтесь с персональными пакетами анализов и закажите" }, - "noCategories": "Список услуг не найден, попробуйте позже" -} + "noCategories": "Список услуг не найден, попробуйте позже", + "noResults": "Для выбранных дат доступных вариантов не найдено" +} \ No newline at end of file diff --git a/supabase/migrations/20250908092531_add_clinic_key.sql b/supabase/migrations/20250908092531_add_clinic_key.sql new file mode 100644 index 0000000..fa01010 --- /dev/null +++ b/supabase/migrations/20250908092531_add_clinic_key.sql @@ -0,0 +1,11 @@ +ALTER TABLE medreport.connected_online_providers ADD COLUMN key text not null DEFAULT ''; -- avoid conflict with already existing data, will be filled on next sync +ALTER TABLE medreport.connected_online_providers +ALTER key DROP DEFAULT; + +ALTER TABLE medreport.connected_online_providers ADD COLUMN address text not null DEFAULT ''; +ALTER TABLE medreport.connected_online_providers +ALTER key DROP DEFAULT; + +ALTER TABLE medreport.connected_online_services +ALTER COLUMN sync_id TYPE bigint +USING sync_id::bigint; diff --git a/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql new file mode 100644 index 0000000..85dd813 --- /dev/null +++ b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql @@ -0,0 +1,60 @@ +create table "medreport"."connected_online_service_providers" ( + "id" bigint not null primary key, + "name" text not null, + "spoken_languages" text[], + "prefix" text, + "job_title_et" text, + "job_title_en" text, + "job_title_ru" text, + "clinic_id" bigint not null REFERENCES medreport.connected_online_providers(id), + "job_title_id" bigint, + "is_deleted" boolean, + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp without time zone default now() +); + +ALTER TABLE audit.request_entries +DROP COLUMN personal_code; + +ALTER TABLE audit.request_entries ADD COLUMN user_id uuid default auth.uid() references auth.users(id); + +create policy "insert_own" +on "audit"."request_entries" +as permissive +for insert +to authenticated +with check ((( SELECT auth.uid() AS uid) = user_id)); + +alter table "medreport"."connected_online_service_providers" enable row level security; + +create policy "service_role_all" +on "medreport"."connected_online_service_providers" +as permissive +for all +to service_role +using (true); + + +grant delete on table "medreport"."connected_online_service_providers" to "service_role"; + +grant insert on table "medreport"."connected_online_service_providers" to "service_role"; + +grant references on table "medreport"."connected_online_service_providers" to "service_role"; + +grant select on table "medreport"."connected_online_service_providers" to "service_role"; + +grant trigger on table "medreport"."connected_online_service_providers" to "service_role"; + +grant truncate on table "medreport"."connected_online_service_providers" to "service_role"; + +grant update on table "medreport"."connected_online_service_providers" to "service_role"; + +grant select on table "medreport"."connected_online_service_providers" to "authenticated"; + + +create policy "authenticated_select" +on "medreport"."connected_online_service_providers" +as permissive +for select +to authenticated +using (true); diff --git a/supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql b/supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql new file mode 100644 index 0000000..ebb59b7 --- /dev/null +++ b/supabase/migrations/20250915091038_add_order_fields_to_connected_online_reservation.sql @@ -0,0 +1,14 @@ +ALTER TABLE medreport.connected_online_reservation +ADD COLUMN medusa_cart_line_item_id TEXT references public.cart_line_item(id); + +ALTER TABLE medreport.connected_online_reservation +ADD COLUMN location_sync_id bigint; + +create type medreport.connected_online_order_status as enum ('PENDING', 'CONFIRMED', 'REJECTED', 'CANCELLED'); + +ALTER TABLE medreport.connected_online_reservation +ADD COLUMN status medreport.connected_online_order_status not null; + +ALTER TABLE medreport.connected_online_reservation ALTER COLUMN booking_code DROP NOT NULL; +ALTER TABLE medreport.connected_online_reservation ALTER COLUMN requires_payment DROP NOT NULL; +ALTER TABLE medreport.connected_online_reservation ALTER COLUMN service_user_id SET NOT NULL; diff --git a/supabase/migrations/20250915101146_add_connected_online_locations_table.sql b/supabase/migrations/20250915101146_add_connected_online_locations_table.sql new file mode 100644 index 0000000..fa8d0dc --- /dev/null +++ b/supabase/migrations/20250915101146_add_connected_online_locations_table.sql @@ -0,0 +1,43 @@ +create table "medreport"."connected_online_locations" ( + "id" bigint generated by default as identity not null, + "sync_id" bigint not null, + "name" text not null, + "address" text, + "clinic_id" bigint not null REFERENCES medreport.connected_online_providers(id), + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp without time zone default now() +); + +alter table "medreport"."connected_online_locations" enable row level security; + +create policy "service_role_all" +on "medreport"."connected_online_locations" +as permissive +for all +to service_role +using (true); + + +grant delete on table "medreport"."connected_online_locations" to "service_role"; + +grant insert on table "medreport"."connected_online_locations" to "service_role"; + +grant references on table "medreport"."connected_online_locations" to "service_role"; + +grant select on table "medreport"."connected_online_locations" to "service_role"; + +grant trigger on table "medreport"."connected_online_locations" to "service_role"; + +grant truncate on table "medreport"."connected_online_locations" to "service_role"; + +grant update on table "medreport"."connected_online_locations" to "service_role"; + +grant select on table "medreport"."connected_online_locations" to "authenticated"; + + +create policy "authenticated_select" +on "medreport"."connected_online_locations" +as permissive +for select +to authenticated +using (true); From 4bd88f1b4eaf3aa13be542d994d22ff9238d6fe9 Mon Sep 17 00:00:00 2001 From: Helena Date: Wed, 17 Sep 2025 18:23:25 +0300 Subject: [PATCH 03/30] clean up --- .../(dashboard)/booking/[handle]/page.tsx | 2 +- .../_components/booking/booking.context.ts | 8 +++---- .../_components/booking/booking.provider.tsx | 9 ++++--- app/home/(user)/_lib/server/actions.ts | 24 ++----------------- .../medusa-storefront/src/lib/data/cart.ts | 1 - 5 files changed, 13 insertions(+), 31 deletions(-) diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index 3d9c29e..395b590 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -7,7 +7,7 @@ import { Trans } from '@kit/ui/trans'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; -import BookingContainer from '../../../_components/booking/booking-container'; +import BookingContainer from '~/home/(user)/_components/booking/booking-container'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts index a8b47a4..ebff73d 100644 --- a/app/home/(user)/_components/booking/booking.context.ts +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -55,12 +55,12 @@ const BookingContext = createContext<{ selectedService: StoreProduct | null; locations: Location[] | null; selectedLocationId: number | null; - selectedDate?: Date; + selectedDate?: Date | null; isLoadingTimeSlots?: boolean; - setSelectedService: (selectedService?: StoreProduct) => void; + setSelectedService: (selectedService: StoreProduct | null) => void; setSelectedLocationId: (selectedLocationId: number | null) => void; - updateTimeSlots: (serviceId: number) => Promise; - setSelectedDate: (selectedDate?: Date) => void; + updateTimeSlots: (serviceIds: number[]) => Promise; + setSelectedDate: (selectedDate: Date | null) => void; }>({ timeSlots: null, selectedService: null, diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index bf6e1f7..0bf823b 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -27,7 +27,7 @@ export const BookingProvider: React.FC<{ const [selectedLocationId, setSelectedLocationId] = useState( null, ); - const [selectedDate, setSelectedDate] = useState(); + const [selectedDate, setSelectedDate] = useState(); const [timeSlots, setTimeSlots] = useState(null); const [locations, setLocations] = useState(null); const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); @@ -49,9 +49,12 @@ export const BookingProvider: React.FC<{ const updateTimeSlots = async (serviceIds: number[]) => { setIsLoadingTimeSlots(true); try { - const response = await getAvailableTimeSlotsForDisplay(serviceIds, selectedLocationId); + const response = await getAvailableTimeSlotsForDisplay( + serviceIds, + selectedLocationId, + ); setTimeSlots(response.timeSlots); - setLocations(response.locations) + setLocations(response.locations); } catch (error) { setTimeSlots(null); } finally { diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts index 7543d34..dfb6f88 100644 --- a/app/home/(user)/_lib/server/actions.ts +++ b/app/home/(user)/_lib/server/actions.ts @@ -2,32 +2,12 @@ import { StoreProductVariant } from '@medusajs/types'; +import { updateLineItem } from "@lib/data/cart"; import { - bookAppointment, - createInitialReservation, + createInitialReservation } from '~/lib/services/connected-online.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service'; -import { updateLineItem } from '../../../../../packages/features/medusa-storefront/src/lib/data'; - -export async function bookTimeAction( - serviceId: number, - clinicId: number, - appointmentUserId: number, - syncUserId: number, - startTime: Date, - comments?: string, -) { - return bookAppointment( - serviceId, - clinicId, - appointmentUserId, - syncUserId, - startTime, - comments, - ); -} - export async function createInitialReservationAction( selectedVariant: Pick, countryCode: string, diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index 5a0cd84..b56ba48 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -15,7 +15,6 @@ import { import { getRegion } from "./regions"; import { sdk } from "@lib/config"; import { retrieveOrder } from "./orders"; -import { completeTtoCart } from "../../../../../../lib/services/connected-online.service"; /** * Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies. From db2ccd0f578f887ee4e32e77fdd3b2b3ceccc0bb Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 09:45:09 +0300 Subject: [PATCH 04/30] log page views --- .../(dashboard)/booking/[handle]/page.tsx | 21 +++++++++++++++++-- lib/services/audit/pageView.service.ts | 2 ++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx index 395b590..fa25357 100644 --- a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -1,13 +1,17 @@ +import { redirect } from 'next/navigation'; + import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header'; import { loadCategory } from '@/app/home/(user)/_lib/server/load-category'; +import { pathsConfig } from '@kit/shared/config'; import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs'; import { Trans } from '@kit/ui/trans'; +import BookingContainer from '~/home/(user)/_components/booking/booking-container'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; - -import BookingContainer from '~/home/(user)/_components/booking/booking-container'; +import { PageViewAction, createPageViewLog } from '~/lib/services/audit/pageView.service'; +import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); @@ -25,11 +29,24 @@ async function BookingHandlePage({ }) { const { handle } = await params; const { category } = await loadCategory({ handle }); + const { account } = await loadCurrentUserAccount(); if (!category) { return
Category not found
; } + if (!account) { + return redirect(pathsConfig.auth.signIn); + } + + await createPageViewLog({ + accountId: account.id, + action: PageViewAction.VIEW_TTO_SERVICE_BOOKING, + extraData: { + handle, + }, + }); + return ( <> Date: Thu, 18 Sep 2025 09:48:26 +0300 Subject: [PATCH 05/30] fix broken import --- app/home/(user)/(dashboard)/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index de4d375..d7de100 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -1,7 +1,6 @@ import { redirect } from 'next/navigation'; import { toTitleCase } from '@/lib/utils'; -import { createUserAnalysesApi } from '@kit/user-analyses/api'; import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; import { PageBody, PageHeader } from '@kit/ui/page'; @@ -13,6 +12,7 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import Dashboard from '../_components/dashboard'; import DashboardCards from '../_components/dashboard-cards'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; +import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); From 3f3fbad5561b35af110b4225b941154dac19c36f Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 10:17:07 +0300 Subject: [PATCH 06/30] mobile improvements --- .../_components/booking/booking-container.tsx | 41 ++----------------- .../_components/booking/booking.context.ts | 4 +- .../_components/booking/booking.provider.tsx | 2 +- .../_components/booking/location-selector.tsx | 7 +--- .../_components/booking/service-selector.tsx | 2 +- .../(user)/_components/booking/time-slots.tsx | 4 +- packages/ui/src/shadcn/calendar.tsx | 4 +- 7 files changed, 12 insertions(+), 52 deletions(-) diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx index d7ca8cc..77caa97 100644 --- a/app/home/(user)/_components/booking/booking-container.tsx +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -1,51 +1,16 @@ 'use client'; -import { isBefore, isSameDay } from 'date-fns'; -import { uniq } from 'lodash'; - -import { Calendar } from '@kit/ui/shadcn/calendar'; -import { Card } from '@kit/ui/shadcn/card'; - import { ServiceCategory } from '../service-categories'; -import { BookingProvider, useBooking } from './booking.provider'; +import BookingCalendar from './booking-calendar'; +import { BookingProvider } from './booking.provider'; import LocationSelector from './location-selector'; import ServiceSelector from './service-selector'; import TimeSlots from './time-slots'; -const BookingCalendar = () => { - const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } = - useBooking(); - const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime)); - - return ( - - { - const today = new Date(); - today.setHours(0, 0, 0, 0); - return ( - isBefore(date, today) || - !availableDates.some((dateWithBooking) => - isSameDay(date, dateWithBooking), - ) - ); - }} - className="rounded-md border" - {...(isLoadingTimeSlots && { - className: 'rounded-md border opacity-50 pointer-events-none', - })} - /> - - ); -}; - const BookingContainer = ({ category }: { category: ServiceCategory }) => { return ( -
+
diff --git a/app/home/(user)/_components/booking/booking.context.ts b/app/home/(user)/_components/booking/booking.context.ts index ebff73d..ce59dba 100644 --- a/app/home/(user)/_components/booking/booking.context.ts +++ b/app/home/(user)/_components/booking/booking.context.ts @@ -55,12 +55,12 @@ const BookingContext = createContext<{ selectedService: StoreProduct | null; locations: Location[] | null; selectedLocationId: number | null; - selectedDate?: Date | null; + selectedDate?: Date; isLoadingTimeSlots?: boolean; setSelectedService: (selectedService: StoreProduct | null) => void; setSelectedLocationId: (selectedLocationId: number | null) => void; updateTimeSlots: (serviceIds: number[]) => Promise; - setSelectedDate: (selectedDate: Date | null) => void; + setSelectedDate: (selectedDate?: Date) => void; }>({ timeSlots: null, selectedService: null, diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 0bf823b..fcde360 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -27,7 +27,7 @@ export const BookingProvider: React.FC<{ const [selectedLocationId, setSelectedLocationId] = useState( null, ); - const [selectedDate, setSelectedDate] = useState(); + const [selectedDate, setSelectedDate] = useState(); const [timeSlots, setTimeSlots] = useState(null); const [locations, setLocations] = useState(null); const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); diff --git a/app/home/(user)/_components/booking/location-selector.tsx b/app/home/(user)/_components/booking/location-selector.tsx index f792fa5..4cb02aa 100644 --- a/app/home/(user)/_components/booking/location-selector.tsx +++ b/app/home/(user)/_components/booking/location-selector.tsx @@ -9,12 +9,7 @@ import { useBooking } from './booking.provider'; const LocationSelector = () => { const { t } = useTranslation(); - const { - selectedService, - selectedLocationId, - setSelectedLocationId, - locations, - } = useBooking(); + const { selectedLocationId, setSelectedLocationId, locations } = useBooking(); const onLocationSelect = (locationId: number | string | null) => { if (locationId === 'all') return setSelectedLocationId(null); diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx index 0c60a3e..42540d1 100644 --- a/app/home/(user)/_components/booking/service-selector.tsx +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -22,7 +22,7 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { const onServiceSelect = async (productId: StoreProduct['id']) => { const product = products.find((p) => p.id === productId); - setSelectedService(product); + setSelectedService(product ?? null); setCollapsed(false); }; diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx index d74befc..587ee95 100644 --- a/app/home/(user)/_components/booking/time-slots.tsx +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -156,7 +156,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { booking.selectedService?.variants?.[0]?.calculated_price ?.calculated_amount; return ( - +
{formatDateAndTime(timeSlot.StartTime.toString())}
@@ -181,7 +181,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { {timeSlot.location?.address}
-
+
{formatCurrency({ currencyCode: 'EUR', diff --git a/packages/ui/src/shadcn/calendar.tsx b/packages/ui/src/shadcn/calendar.tsx index 138278d..967afa2 100644 --- a/packages/ui/src/shadcn/calendar.tsx +++ b/packages/ui/src/shadcn/calendar.tsx @@ -34,10 +34,10 @@ function Calendar({ nav_button_previous: 'absolute left-1', nav_button_next: 'absolute right-1', table: 'w-full border-collapse space-y-1', - head_row: 'flex', + head_row: 'flex justify-evenly', head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]', - row: 'flex w-full mt-2', + row: 'flex w-full mt-2 justify-evenly', cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-md focus-within:relative focus-within:z-20', day: cn( buttonVariants({ variant: 'ghost' }), From 71f3aed875ede9e3cbe430007b0cc57208f47b0a Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 10:17:24 +0300 Subject: [PATCH 07/30] move booking calendar to separate file --- .../_components/booking/booking-calendar.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 app/home/(user)/_components/booking/booking-calendar.tsx diff --git a/app/home/(user)/_components/booking/booking-calendar.tsx b/app/home/(user)/_components/booking/booking-calendar.tsx new file mode 100644 index 0000000..300add5 --- /dev/null +++ b/app/home/(user)/_components/booking/booking-calendar.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { isBefore, isSameDay } from 'date-fns'; +import { uniq } from 'lodash'; + +import { Calendar } from '@kit/ui/shadcn/calendar'; +import { Card } from '@kit/ui/shadcn/card'; + +import { useBooking } from './booking.provider'; + +export default function BookingCalendar() { + const { selectedDate, setSelectedDate, isLoadingTimeSlots, timeSlots } = + useBooking(); + const availableDates = uniq(timeSlots?.map((timeSlot) => timeSlot.StartTime)); + + return ( + + { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return ( + isBefore(date, today) || + !availableDates.some((dateWithBooking) => + isSameDay(date, dateWithBooking), + ) + ); + }} + className="rounded-md border" + {...(isLoadingTimeSlots && { + className: 'rounded-md border opacity-50 pointer-events-none', + })} + /> + + ); +} From 2f470d3531633b202805bc80c7427e5071941a74 Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 10:24:47 +0300 Subject: [PATCH 08/30] revert some whitespace changes for better readability --- .../cart/montonio-callback/actions.ts | 66 ++++++------------- 1 file changed, 20 insertions(+), 46 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index c7f67c4..ff97af4 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -124,38 +124,27 @@ async function getCartByOrderToken(decoded: MontonioOrderToken) { async function getOrderResultParameters(medusaOrder: StoreOrder) { const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE, - ); - const analysisType = productTypes.find( - ({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE, - ); + const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); + const analysisType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE); - const analysisPackageOrderItem = medusaOrder.items?.find( - ({ product_type_id }) => product_type_id === analysisPackagesType?.id, - ); - const analysisItems = medusaOrder.items?.filter( - ({ product_type_id }) => product_type_id === analysisType?.id, - ); + const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); + const analysisItems = medusaOrder.items?.filter(({ product_type_id }) => product_type_id === analysisType?.id); return { medusaOrderId: medusaOrder.id, email: medusaOrder.email, analysisPackageOrder: analysisPackageOrderItem ? { - partnerLocationName: - (analysisPackageOrderItem?.metadata - ?.partner_location_name as string) ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - } + partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', + analysisPackageName: analysisPackageOrderItem?.title ?? '', + } + : null, + analysisItemsOrder: Array.isArray(analysisItems) && analysisItems.length > 0 + ? analysisItems.map(({ product }) => ({ + analysisName: product?.title ?? '', + analysisId: product?.metadata?.analysisIdOriginal as string ?? '', + })) : null, - analysisItemsOrder: - Array.isArray(analysisItems) && analysisItems.length > 0 - ? analysisItems.map(({ product }) => ({ - analysisName: product?.title ?? '', - analysisId: (product?.metadata?.analysisIdOriginal as string) ?? '', - })) - : null, }; } @@ -196,22 +185,14 @@ export async function processMontonioCallback(orderToken: string) { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - const medusaOrder = await placeOrder(cart.id, { - revalidateCacheTags: false, - }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ - medusaOrder, - }); + const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); const orderContainsSynlabItems = !!orderedAnalysisElements?.length; try { - const existingAnalysisOrder = await getAnalysisOrder({ - medusaOrderId: medusaOrder.id, - }); - console.info( - `Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`, - ); + const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id }); + console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`); return { success: true, orderId: existingAnalysisOrder.id }; } catch { // ignored @@ -227,8 +208,7 @@ export async function processMontonioCallback(orderToken: string) { const orderResult = await getOrderResultParameters(medusaOrder); - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = - orderResult; + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; const orderedTtoServices = await getOrderedTtoServices({ medusaOrder }); let bookServiceResults: { @@ -251,20 +231,14 @@ export async function processMontonioCallback(orderToken: string) { if (email) { if (analysisPackageOrder) { - await sendAnalysisPackageOrderEmail({ - account, - email, - analysisPackageOrder, - }); + await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder }); } else { console.info(`Order has no analysis package, skipping email.`); } if (analysisItemsOrder) { // @TODO send email for separate analyses - console.warn( - `Order has analysis items, but no email template exists yet`, - ); + console.warn(`Order has analysis items, but no email template exists yet`); } else { console.info(`Order has no analysis items, skipping email.`); } From f0fbca832d8225203003d018b076521bb639b6a1 Mon Sep 17 00:00:00 2001 From: Helena Date: Thu, 18 Sep 2025 16:40:25 +0300 Subject: [PATCH 09/30] use cn --- app/home/(user)/_components/booking/booking-calendar.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/home/(user)/_components/booking/booking-calendar.tsx b/app/home/(user)/_components/booking/booking-calendar.tsx index 300add5..f8afb0b 100644 --- a/app/home/(user)/_components/booking/booking-calendar.tsx +++ b/app/home/(user)/_components/booking/booking-calendar.tsx @@ -5,6 +5,7 @@ import { uniq } from 'lodash'; import { Calendar } from '@kit/ui/shadcn/calendar'; import { Card } from '@kit/ui/shadcn/card'; +import { cn } from '@kit/ui/utils'; import { useBooking } from './booking.provider'; @@ -29,9 +30,9 @@ export default function BookingCalendar() { ) ); }} - className="rounded-md border" - {...(isLoadingTimeSlots && { - className: 'rounded-md border opacity-50 pointer-events-none', + className={cn('rounded-md border', { + 'pointer-events-none rounded-md border opacity-50': + isLoadingTimeSlots, })} /> From 3c272505d6ad906689f2074c156cf268840ca622 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 10:18:18 +0300 Subject: [PATCH 10/30] various minor changes --- app/home/(user)/_components/booking/booking.provider.tsx | 6 +++--- app/home/(user)/_components/orders/order-items-table.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index fcde360..3f3cb18 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -27,10 +27,10 @@ export const BookingProvider: React.FC<{ const [selectedLocationId, setSelectedLocationId] = useState( null, ); - const [selectedDate, setSelectedDate] = useState(); + const [selectedDate, setSelectedDate] = useState(); const [timeSlots, setTimeSlots] = useState(null); const [locations, setLocations] = useState(null); - const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); + const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); useEffect(() => { let metadataServiceIds = []; @@ -39,7 +39,7 @@ export const BookingProvider: React.FC<{ selectedService?.metadata?.serviceIds as string, ); } catch (e) { - return; + console.error(e); } if (metadataServiceIds.length) { updateTimeSlots(metadataServiceIds); diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index 6d621e6..39ec1de 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -59,7 +59,7 @@ export default function OrderItemsTable({ - + {isAnalysisOrder && } From b59148630ad6320dc4fe116f91215b4ba1cf0329 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 16:23:19 +0300 Subject: [PATCH 11/30] add cart functionality for tto services --- app/api/job/handler/sync-connected-online.ts | 3 +- .../cart/montonio-callback/actions.ts | 2 +- app/home/(user)/(dashboard)/cart/page.tsx | 19 +- .../_components/booking/booking-container.tsx | 34 +- .../_components/booking/booking.provider.tsx | 9 +- .../_components/booking/service-selector.tsx | 30 +- .../(user)/_components/booking/time-slots.tsx | 74 +++- .../_components/cart/cart-service-item.tsx | 141 ++++++++ .../_components/cart/cart-service-items.tsx | 72 ++++ app/home/(user)/_components/cart/index.tsx | 124 ++++--- app/home/(user)/_components/cart/types.ts | 24 +- app/home/(user)/_lib/server/actions.ts | 6 +- lib/services/connected-online.service.ts | 109 +----- lib/services/medusaCart.service.ts | 46 ++- lib/services/order.service.ts | 8 + lib/services/reservation.service.ts | 333 ++++++++++++++++++ lib/types/reservation.ts | 35 ++ packages/supabase/src/database.types.ts | 17 +- public/locales/en/booking.json | 6 +- public/locales/en/cart.json | 8 +- public/locales/et/booking.json | 5 +- public/locales/et/cart.json | 8 +- public/locales/et/common.json | 3 +- public/locales/ru/booking.json | 6 +- public/locales/ru/cart.json | 8 +- ...9121028_add_references_to_reservations.sql | 12 + 26 files changed, 921 insertions(+), 221 deletions(-) create mode 100644 app/home/(user)/_components/cart/cart-service-item.tsx create mode 100644 app/home/(user)/_components/cart/cart-service-items.tsx create mode 100644 lib/services/reservation.service.ts create mode 100644 lib/types/reservation.ts create mode 100644 supabase/migrations/20250919121028_add_references_to_reservations.sql diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 2cc0447..07b7435 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -1,6 +1,5 @@ import axios from 'axios'; -import { Tables } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { logSyncResult } from '~/lib/services/audit.service'; @@ -131,7 +130,7 @@ export default async function syncConnectedOnline() { return { id: service.ID, clinic_id: service.ClinicID, - sync_id: service.SyncID, + sync_id: Number(service.SyncID), name: service.Name, description: service.Description || null, price: service.Price, diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index ff97af4..806588e 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -12,13 +12,13 @@ import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client' import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { bookAppointment, - getOrderedTtoServices, } from '~/lib/services/connected-online.service'; import { FailureReason } from '~/lib/types/connected-online'; import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; import { AccountWithParams } from '@kit/accounts/types/accounts'; +import { getOrderedTtoServices } from '~/lib/services/reservation.service'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 20efdce..3016fe8 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -8,10 +8,12 @@ import { listProductTypes } from '@lib/data/products'; import { Trans } from '@kit/ui/trans'; import { withI18n } from '~/lib/i18n/with-i18n'; - import { findProductTypeIdByHandle } from '~/lib/utils'; + +import { getCartReservations } from '~/lib/services/reservation.service'; import Cart from '../../_components/cart'; import CartTimer from '../../_components/cart/cart-timer'; +import { EnrichedCartItem } from '../../_components/cart/types'; export async function generateMetadata() { const { t } = await createI18nServerInstance(); @@ -37,10 +39,6 @@ async function CartPage() { productTypes, 'analysis-packages', ); - const ttoServiceTypeId = findProductTypeIdByHandle( - productTypes, - 'tto-service', - ); const synlabAnalyses = analysisPackagesTypeId && synlabAnalysisTypeId && cart?.items @@ -54,14 +52,11 @@ async function CartPage() { ); }) : []; - const ttoServiceItems = - ttoServiceTypeId && cart?.items - ? cart?.items?.filter((item) => { - const productTypeId = item.product?.type_id; - return productTypeId && productTypeId === ttoServiceTypeId; - }) - : []; + let ttoServiceItems: EnrichedCartItem[] = []; + if (cart?.items?.length) { + ttoServiceItems = await getCartReservations(cart); + } const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? '') > (b.updated_at ?? '') ? -1 : 1, ); diff --git a/app/home/(user)/_components/booking/booking-container.tsx b/app/home/(user)/_components/booking/booking-container.tsx index 77caa97..9d3b6e8 100644 --- a/app/home/(user)/_components/booking/booking-container.tsx +++ b/app/home/(user)/_components/booking/booking-container.tsx @@ -1,22 +1,46 @@ 'use client'; -import { ServiceCategory } from '../service-categories'; +import { StoreProduct } from '@medusajs/types'; + +import { Trans } from '@kit/ui/trans'; + +import { EnrichedCartItem } from '../cart/types'; import BookingCalendar from './booking-calendar'; import { BookingProvider } from './booking.provider'; import LocationSelector from './location-selector'; import ServiceSelector from './service-selector'; import TimeSlots from './time-slots'; -const BookingContainer = ({ category }: { category: ServiceCategory }) => { +const BookingContainer = ({ + category, + cartItem, + onComplete, +}: { + category: { products: StoreProduct[]; countryCode: string }; + cartItem?: EnrichedCartItem; + onComplete?: () => void; +}) => { + const products = cartItem?.product ? [cartItem.product] : category.products; + + if (!cartItem || !products?.length) { +

+ +

; + } + return ( - +
- +
- +
); diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 3f3cb18..2c23a9f 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -4,7 +4,6 @@ import { StoreProduct } from '@medusajs/types'; import { getAvailableTimeSlotsForDisplay } from '~/lib/services/connected-online.service'; -import { ServiceCategory } from '../service-categories'; import { BookingContext, Location, TimeSlot } from './booking.context'; export function useBooking() { @@ -19,10 +18,11 @@ export function useBooking() { export const BookingProvider: React.FC<{ children: React.ReactElement; - category: ServiceCategory; -}> = ({ children, category }) => { + category: { products: StoreProduct[] }; + service?: StoreProduct; +}> = ({ children, category, service }) => { const [selectedService, setSelectedService] = useState( - category.products[0] || null, + (service ?? category?.products?.[0]) || null, ); const [selectedLocationId, setSelectedLocationId] = useState( null, @@ -32,6 +32,7 @@ export const BookingProvider: React.FC<{ const [locations, setLocations] = useState(null); const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); + useEffect(() => { let metadataServiceIds = []; try { diff --git a/app/home/(user)/_components/booking/service-selector.tsx b/app/home/(user)/_components/booking/service-selector.tsx index 42540d1..8dd0907 100644 --- a/app/home/(user)/_components/booking/service-selector.tsx +++ b/app/home/(user)/_components/booking/service-selector.tsx @@ -18,7 +18,7 @@ import { useBooking } from './booking.provider'; const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { const { selectedService, setSelectedService } = useBooking(); const [collapsed, setCollapsed] = useState(false); - const [firstFourProducts] = useState(products.slice(0, 4)); + const [firstFourProducts] = useState(products?.slice(0, 4)); const onServiceSelect = async (productId: StoreProduct['id']) => { const product = products.find((p) => p.id === productId); @@ -38,7 +38,7 @@ const ServiceSelector = ({ products }: { products: StoreProduct[] }) => { className="mb-2 flex flex-col" onValueChange={onServiceSelect} > - {firstFourProducts.map((product) => ( + {firstFourProducts?.map((product) => (
{
))} - -
setCollapsed((_) => !_)} - className="flex cursor-pointer items-center justify-between border-t py-1" - > - - - - -
-
+ {products.length > 4 && ( + +
setCollapsed((_) => !_)} + className="flex cursor-pointer items-center justify-between border-t py-1" + > + + + + +
+
+ )}
- {products.map((product) => ( + {products?.map((product) => (
{ +const TimeSlots = ({ + countryCode, + cartItem, + onComplete, +}: { + countryCode: string; + cartItem?: EnrichedCartItem; + onComplete?: () => void; +}) => { const [currentPage, setCurrentPage] = useState(1); const { @@ -133,6 +144,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { booking.selectedLocationId ? booking.selectedLocationId : null, comments, ).then(() => { + if (onComplete) { + onComplete(); + } router.push(pathsConfig.app.cart); }); @@ -143,6 +157,49 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { }); }; + const handleChangeTime = async ( + timeSlot: TimeSlot, + reservationId: number, + cartId: string, + ) => { + const syncedService = timeSlot.syncedService; + if (!syncedService) { + return toast.error(t('booking:serviceNotFound')); + } + + const bookTimePromise = updateReservationTime( + reservationId, + timeSlot.StartTime, + Number(syncedService.id), + timeSlot.UserID, + timeSlot.SyncUserID, + booking.selectedLocationId ? booking.selectedLocationId : null, + cartId, + ); + + toast.promise(() => bookTimePromise, { + success: , + error: , + loading: , + }); + + if (onComplete) { + onComplete(); + } + }; + + const handleTimeSelect = async (timeSlot: TimeSlot) => { + if (cartItem?.reservation.id) { + return handleChangeTime( + timeSlot, + cartItem.reservation.id, + cartItem.cart_id, + ); + } + + return handleBookTime(timeSlot); + }; + return (
@@ -154,9 +211,12 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { ); const price = booking.selectedService?.variants?.[0]?.calculated_price - ?.calculated_amount; + ?.calculated_amount ?? cartItem?.unit_price; return ( - +
{formatDateAndTime(timeSlot.StartTime.toString())}
@@ -177,11 +237,9 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { )} {isEHIF && {t('booking:ehifBooking')}}
-
- {timeSlot.location?.address} -
+
{timeSlot.location?.address}
-
+
{formatCurrency({ currencyCode: 'EUR', @@ -189,7 +247,7 @@ const TimeSlots = ({ countryCode }: { countryCode: string }) => { value: price ?? '', })} -
diff --git a/app/home/(user)/_components/cart/cart-service-item.tsx b/app/home/(user)/_components/cart/cart-service-item.tsx new file mode 100644 index 0000000..a8d2bd7 --- /dev/null +++ b/app/home/(user)/_components/cart/cart-service-item.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useState } from 'react'; + +import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@kit/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@kit/ui/dialog'; +import { TableCell, TableRow } from '@kit/ui/table'; +import { Trans } from '@kit/ui/trans'; + +import BookingContainer from '../booking/booking-container'; +import CartItemDelete from './cart-item-delete'; +import { EnrichedCartItem } from './types'; + +const EditCartServiceItemModal = ({ + item, + onComplete, +}: { + item: EnrichedCartItem | null; + onComplete: () => void; +}) => { + if (!item) return null; + + return ( + + + + + + + + + + +
+ {item.product && item.reservation.countryCode ? ( + + ) : ( +

+ +

+ )} +
+
+
+ ); +}; + +export default function CartServiceItem({ + item, + currencyCode, + isUnavailable, +}: { + item: EnrichedCartItem; + currencyCode: string; + isUnavailable?: boolean; +}) { + const [editingItem, setEditingItem] = useState(null); + const { + i18n: { language }, + } = useTranslation(); + + return ( + <> + + +

+ {item.product_title} +

+
+ + + {formatDateAndTime(item.reservation.startTime.toString())} + + + + {item.reservation.location?.address ?? '-'} + + + {item.quantity} + + + {formatCurrency({ + value: item.unit_price, + currencyCode, + locale: language, + })} + + + + {formatCurrency({ + value: item.total, + currencyCode, + locale: language, + })} + + + + + + + + + + + + + +
+ {isUnavailable && + + + + } + setEditingItem(null)} + /> + + ); +} diff --git a/app/home/(user)/_components/cart/cart-service-items.tsx b/app/home/(user)/_components/cart/cart-service-items.tsx new file mode 100644 index 0000000..af39aa3 --- /dev/null +++ b/app/home/(user)/_components/cart/cart-service-items.tsx @@ -0,0 +1,72 @@ +import { StoreCart } from '@medusajs/types'; + +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from '@kit/ui/table'; +import { Trans } from '@kit/ui/trans'; + +import CartServiceItem from './cart-service-item'; +import { EnrichedCartItem } from './types'; + +export default function CartServiceItems({ + cart, + items, + productColumnLabelKey, + unavailableLineItemIds +}: { + cart: StoreCart; + items: EnrichedCartItem[]; + productColumnLabelKey: string; + unavailableLineItemIds?: string[] +}) { + if (!items || items.length === 0) { + return null; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + {items + .sort((a, b) => + (a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1, + ) + .map((item) => ( + + ))} + +
+ ); +} diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index 09064bd..5775906 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -1,22 +1,23 @@ -"use client"; +'use client'; + +import { useState } from 'react'; + +import { handleNavigateToPayment } from '@/lib/services/medusaCart.service'; +import { formatCurrency } from '@/packages/shared/src/utils'; +import { initiatePaymentSession } from '@lib/data/cart'; +import { StoreCart, StoreCartLineItem } from '@medusajs/types'; +import { Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; -import { useState } from "react"; -import { Loader2 } from "lucide-react"; -import { StoreCart, StoreCartLineItem } from "@medusajs/types" -import CartItems from "./cart-items" -import { Trans } from '@kit/ui/trans'; import { Button } from '@kit/ui/button'; -import { - Card, - CardContent, - CardHeader, -} from '@kit/ui/card'; -import DiscountCode from "./discount-code"; -import { initiatePaymentSession } from "@lib/data/cart"; -import { formatCurrency } from "@/packages/shared/src/utils"; -import { useTranslation } from "react-i18next"; -import { handleNavigateToPayment } from "@/lib/services/medusaCart.service"; -import AnalysisLocation from "./analysis-location"; +import { Card, CardContent, CardHeader } from '@kit/ui/card'; +import { Trans } from '@kit/ui/trans'; + +import AnalysisLocation from './analysis-location'; +import CartItems from './cart-items'; +import CartServiceItems from './cart-service-items'; +import DiscountCode from './discount-code'; +import { EnrichedCartItem } from './types'; const IS_DISCOUNT_SHOWN = true as boolean; @@ -25,13 +26,16 @@ export default function Cart({ synlabAnalyses, ttoServiceItems, }: { - cart: StoreCart | null + cart: StoreCart | null; synlabAnalyses: StoreCartLineItem[]; - ttoServiceItems: StoreCartLineItem[]; + ttoServiceItems: EnrichedCartItem[]; }) { - const { i18n: { language } } = useTranslation(); + const { + i18n: { language }, + } = useTranslation(); const [isInitiatingSession, setIsInitiatingSession] = useState(false); + const [unavailableLineItemIds, setUnavailableLineItemIds] = useState() const items = cart?.items ?? []; @@ -39,7 +43,10 @@ export default function Cart({ return (
-
+

@@ -60,8 +67,13 @@ export default function Cart({ if (response.payment_collection) { const { payment_sessions } = response.payment_collection; const paymentSessionId = payment_sessions![0]!.id; - const url = await handleNavigateToPayment({ language, paymentSessionId }); - window.location.href = url; + const result = await handleNavigateToPayment({ language, paymentSessionId }); + if (result.url) { + window.location.href = result.url; + } + if (result.unavailableLineItemIds) { + setUnavailableLineItemIds(result.unavailableLineItemIds) + } } else { setIsInitiatingSession(false); } @@ -71,21 +83,30 @@ export default function Cart({ const isLocationsShown = synlabAnalyses.length > 0; return ( -
-
- - +
+
+ +
{hasCartItems && ( <> -
-
-

+

+
+

-

+

{formatCurrency({ value: cart.subtotal, currencyCode: cart.currency_code, @@ -94,14 +115,14 @@ export default function Cart({

-
-
-

+

+
+

-

+

{formatCurrency({ value: cart.discount_total, currencyCode: cart.currency_code, @@ -110,14 +131,14 @@ export default function Cart({

-
-
-

+

+
+

-

+

{formatCurrency({ value: cart.total, currencyCode: cart.currency_code, @@ -129,11 +150,9 @@ export default function Cart({ )} -

+
{IS_DISCOUNT_SHOWN && ( - +
@@ -146,24 +165,31 @@ export default function Cart({ )} {isLocationsShown && ( - +
- +
)}
-
diff --git a/app/home/(user)/_components/cart/types.ts b/app/home/(user)/_components/cart/types.ts index 22385ce..c0154c9 100644 --- a/app/home/(user)/_components/cart/types.ts +++ b/app/home/(user)/_components/cart/types.ts @@ -1,15 +1,18 @@ +import { StoreCartLineItem } from "@medusajs/types"; +import { Reservation } from "~/lib/types/reservation"; + export interface MontonioOrderToken { uuid: string; accessKey: string; merchantReference: string; merchantReferenceDisplay: string; paymentStatus: - | 'PAID' - | 'FAILED' - | 'CANCELLED' - | 'PENDING' - | 'EXPIRED' - | 'REFUNDED'; + | 'PAID' + | 'FAILED' + | 'CANCELLED' + | 'PENDING' + | 'EXPIRED' + | 'REFUNDED'; paymentMethod: string; grandTotal: number; currency: string; @@ -19,4 +22,11 @@ export interface MontonioOrderToken { paymentLinkUuid: string; iat: number; exp: number; -} \ No newline at end of file +} + +export enum CartItemType { + analysisOrders = 'analysisOrders', + ttoServices = 'ttoServices', +} + +export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation }; diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts index dfb6f88..43d90b8 100644 --- a/app/home/(user)/_lib/server/actions.ts +++ b/app/home/(user)/_lib/server/actions.ts @@ -1,12 +1,10 @@ 'use server'; +import { updateLineItem } from '@lib/data/cart'; import { StoreProductVariant } from '@medusajs/types'; -import { updateLineItem } from "@lib/data/cart"; -import { - createInitialReservation -} from '~/lib/services/connected-online.service'; import { handleAddToCart } from '~/lib/services/medusaCart.service'; +import { createInitialReservation } from '~/lib/services/reservation.service'; export async function createInitialReservationAction( selectedVariant: Pick, diff --git a/lib/services/connected-online.service.ts b/lib/services/connected-online.service.ts index 34abc24..32da35f 100644 --- a/lib/services/connected-online.service.ts +++ b/lib/services/connected-online.service.ts @@ -11,7 +11,6 @@ import { } from '@/lib/types/connected-online'; import { ExternalApi } from '@/lib/types/external'; import { Tables } from '@/packages/supabase/src/database.types'; -import { StoreOrder } from '@medusajs/types'; import axios from 'axios'; import { uniq, uniqBy } from 'lodash'; @@ -20,14 +19,15 @@ import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { TimeSlotResponse } from '~/home/(user)/_components/booking/booking.context'; + import { sendEmailFromTemplate } from './mailer.service'; -import { handleDeleteCartItem } from './medusaCart.service'; export async function getAvailableAppointmentsForService( serviceId: number, key: string, locationId: number | null, startTime?: Date, + maxDays?: number ) { try { const start = startTime ? { StartTime: startTime } : {}; @@ -41,7 +41,7 @@ export async function getAvailableAppointmentsForService( ServiceID: serviceId, Key: key, Lang: 'et', - MaxDays: 120, + MaxDays: maxDays ?? 120, LocationId: locationId ?? -1, ...start, }), @@ -202,8 +202,8 @@ export async function bookAppointment( }, param: JSON.stringify({ ClinicID: clinic.id, - ServiceID: service.id, - ClinicServiceID: service.sync_id, + ServiceID: service.sync_id, + ClinicServiceID: service.id, UserID: appointmentUserId, SyncUserID: syncUserID, StartTime: startTime, @@ -416,102 +416,3 @@ export async function getAvailableTimeSlotsForDisplay( ), }; } - -export async function createInitialReservation( - serviceId: number, - clinicId: number, - appointmentUserId: number, - syncUserID: number, - startTime: Date, - medusaLineItemId: string, - locationId?: number | null, - comments = '', -) { - const logger = await getLogger(); - const supabase = getSupabaseServerClient(); - - const { - data: { user }, - } = await supabase.auth.getUser(); - - const userId = user?.id; - - if (!userId) { - throw new Error('User not authenticated'); - } - - logger.info( - 'Creating reservation' + - JSON.stringify({ serviceId, clinicId, startTime, userId }), - ); - - try { - const { data: createdReservation } = await supabase - .schema('medreport') - .from('connected_online_reservation') - .insert({ - clinic_id: clinicId, - comments, - lang: 'et', - service_id: serviceId, - service_user_id: appointmentUserId, - start_time: startTime.toString(), - sync_user_id: syncUserID, - user_id: userId, - status: 'PENDING', - medusa_cart_line_item_id: medusaLineItemId, - location_sync_id: locationId, - }) - .select('id') - .single() - .throwOnError(); - - logger.info( - `Created reservation ${JSON.stringify({ createdReservation, userId })}`, - ); - - return createdReservation; - } catch (e) { - logger.error( - `Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`, - ); - await handleDeleteCartItem({ lineId: medusaLineItemId }); - throw e; - } -} - -export async function cancelReservation(medusaLineItemId: string) { - const supabase = getSupabaseServerClient(); - - return supabase - .schema('medreport') - .from('connected_online_reservation') - .update({ - status: 'CANCELLED', - }) - .eq('medusa_cart_line_item_id', medusaLineItemId) - .throwOnError(); -} - -export async function getOrderedTtoServices({ - medusaOrder, -}: { - medusaOrder: StoreOrder; -}) { - const supabase = getSupabaseServerClient(); - - const ttoReservationIds: number[] = - medusaOrder.items - ?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId) - .map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ?? - []; - - const { data: orderedTtoServices } = await supabase - .schema('medreport') - .from('connected_online_reservation') - .select('*') - .in('id', ttoReservationIds) - .throwOnError(); - - return orderedTtoServices; -} diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index cb63b2a..1da98b3 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -6,8 +6,23 @@ import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; import { getCartId } from '@lib/data/cookies'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { z } from 'zod'; + + + import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { cancelReservation } from './connected-online.service'; + + + +import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; + + + +import { isSameMinute } from 'date-fns'; +import { getAvailableAppointmentsForService } from './connected-online.service'; + + + + const env = () => z @@ -102,6 +117,30 @@ export async function handleNavigateToPayment({ if (!cart) { throw new Error('No cart found'); } + const orderedTtoServices = await getOrderedTtoServices({ cart }); + + if (orderedTtoServices?.length) { + const unavailableLineItemIds: string[] = [] + for (const ttoService of orderedTtoServices) { + const availabilities = await getAvailableAppointmentsForService( + ttoService.service_id, + ttoService.provider.key, + ttoService.location_sync_id, + new Date(ttoService.start_time), + 1, + ); + const isAvailable = availabilities?.T_Booking?.length ? availabilities.T_Booking.find((timeSlot) => isSameMinute(ttoService.start_time, timeSlot.StartTime)) : false + + if (!isAvailable) { + unavailableLineItemIds.push(ttoService.medusa_cart_line_item_id!) + } + } + + if (unavailableLineItemIds.length) { + return { unavailableLineItemIds } + } + } + const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({ @@ -120,11 +159,12 @@ export async function handleNavigateToPayment({ cart_id: cart.id, changed_by: user.id, }); + if (error) { throw new Error('Error logging cart entry: ' + error.message); } - return paymentLink; + return { url: paymentLink }; } export async function handleLineItemTimeout({ @@ -149,4 +189,4 @@ export async function handleLineItemTimeout({ if (error) { throw new Error('Error logging cart entry: ' + error.message); } -} +} \ No newline at end of file diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 8010e5e..422dcac 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -154,8 +154,10 @@ export async function getAnalysisOrdersAdmin({ export async function getTtoOrders({ orderStatus, + lineItemIds }: { orderStatus?: Tables<{ schema: 'medreport' }, 'connected_online_reservation'>['status']; + lineItemIds?: string[] } = {}) { const client = getSupabaseServerClient(); @@ -171,9 +173,15 @@ export async function getTtoOrders({ .from('connected_online_reservation') .select('*') .eq("user_id", user.id) + if (orderStatus) { query.eq('status', orderStatus); } + + if (lineItemIds?.length) { + query.in('medusa_cart_line_item_id', lineItemIds) + } + const orders = await query.order('created_at', { ascending: false }).throwOnError(); return orders.data; } \ No newline at end of file diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts new file mode 100644 index 0000000..b3a95ac --- /dev/null +++ b/lib/services/reservation.service.ts @@ -0,0 +1,333 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { listRegions } from '@lib/data/regions'; +import { StoreCart, StoreOrder } from '@medusajs/types'; + +import { getLogger } from '@kit/shared/logger'; +import { Tables } from '@kit/supabase/database'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { EnrichedCartItem } from '../../app/home/(user)/_components/cart/types'; +import { loadCurrentUserAccount } from '../../app/home/(user)/_lib/server/load-user-account'; +import { handleDeleteCartItem } from './medusaCart.service'; + +type Locations = Tables<{ schema: 'medreport' }, 'connected_online_locations'>; +type Services = Tables<{ schema: 'medreport' }, 'connected_online_services'>; +type ServiceProviders = Tables< + { schema: 'medreport' }, + 'connected_online_service_providers' +>; + +export async function getCartReservations( + medusaCart: StoreCart, +): Promise { + const supabase = getSupabaseServerClient(); + + const cartLineItemIds = medusaCart.items?.map(({ id }) => id); + + if (!cartLineItemIds?.length) { + return []; + } + + const { data: reservations } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .select( + 'id, startTime:start_time, service:service_id, location:location_sync_id, serviceProvider:service_user_id, medusaCartLineItemId:medusa_cart_line_item_id', + ) + .in('medusa_cart_line_item_id', cartLineItemIds) + .throwOnError(); + + const locationSyncIds: number[] = + reservations + ?.filter((reservation) => !!reservation.location) + .map((reservation) => reservation.location!) ?? []; + const serviceIds = + reservations?.map((reservation) => reservation.service) ?? []; + const serviceProviderIds = + reservations.map((reservation) => reservation.serviceProvider) ?? []; + + let locations: + | { + syncId: Locations['sync_id']; + name: Locations['name']; + address: Locations['address']; + }[] + | null = null; + if (locationSyncIds.length) { + ({ data: locations } = await supabase + .schema('medreport') + .from('connected_online_locations') + .select('syncId:sync_id, name, address') + .in('sync_id', locationSyncIds) + .throwOnError()); + } + + let services: + | { + id: Services['id']; + name: Services['name']; + }[] + | null = null; + if (serviceIds.length) { + ({ data: services } = await supabase + .schema('medreport') + .from('connected_online_services') + .select('name, id') + .in('id', serviceIds) + .throwOnError()); + } + + let serviceProviders: + | { + id: ServiceProviders['id']; + name: ServiceProviders['name']; + jobTitleEt: ServiceProviders['job_title_et']; + jobTitleEn: ServiceProviders['job_title_en']; + jobTitleRu: ServiceProviders['job_title_ru']; + spokenLanguages: ServiceProviders['spoken_languages']; + }[] + | null = null; + if (serviceProviderIds.length) { + ({ data: serviceProviders } = await supabase + .schema('medreport') + .from('connected_online_service_providers') + .select( + 'id, name, jobTitleEt:job_title_et, jobTitleEn:job_title_en, jobTitleRu:job_title_ru, spokenLanguages:spoken_languages', + ) + .in('id', serviceProviderIds) + .throwOnError()); + } + + const results = []; + for (const reservation of reservations) { + if (reservation.medusaCartLineItemId === null) { + continue; + } + + const cartLineItem = medusaCart.items?.find( + (item) => item.id === reservation.medusaCartLineItemId, + ); + + if (!cartLineItem) { + continue; + } + + const location = locations?.find( + (location) => location.syncId === reservation.location, + ); + const service = services?.find( + (service) => service.id === reservation.service, + ); + const serviceProvider = serviceProviders?.find( + (serviceProvider) => serviceProvider.id === reservation.serviceProvider, + ); + + const countryCodes = await listRegions().then((regions) => + regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(), + ); + + const enrichedReservation = { + ...reservation, + location, + service, + serviceProvider, + }; + + results.push({ + ...cartLineItem, + reservation: { + ...enrichedReservation, + medusaCartLineItemId: reservation.medusaCartLineItemId!, + countryCode: countryCodes[0], + }, + }); + } + + return results; +} + +export async function createInitialReservation( + serviceId: number, + clinicId: number, + appointmentUserId: number, + syncUserID: number, + startTime: Date, + medusaLineItemId: string, + locationId?: number | null, + comments = '', +) { + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const userId = user?.id; + + if (!userId) { + throw new Error('User not authenticated'); + } + + logger.info( + 'Creating reservation' + + JSON.stringify({ serviceId, clinicId, startTime, userId }), + ); + + try { + const { data: createdReservation } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .insert({ + clinic_id: clinicId, + comments, + lang: 'et', + service_id: serviceId, + service_user_id: appointmentUserId, + start_time: startTime.toString(), + sync_user_id: syncUserID, + user_id: userId, + status: 'PENDING', + medusa_cart_line_item_id: medusaLineItemId, + location_sync_id: locationId, + }) + .select('id') + .single() + .throwOnError(); + + logger.info( + `Created reservation ${JSON.stringify({ createdReservation, userId })}`, + ); + + return createdReservation; + } catch (e) { + logger.error( + `Failed to create initial reservation ${JSON.stringify({ serviceId, clinicId, startTime })} ${e}`, + ); + await handleDeleteCartItem({ lineId: medusaLineItemId }); + throw e; + } +} + +export async function cancelReservation(medusaLineItemId: string) { + const supabase = getSupabaseServerClient(); + + return supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + status: 'CANCELLED', + }) + .eq('medusa_cart_line_item_id', medusaLineItemId) + .throwOnError(); +} + +export async function getOrderedTtoServices({ + cart, + medusaOrder, +}: { + cart?: StoreCart; + medusaOrder?: StoreOrder; +}) { + const supabase = getSupabaseServerClient(); + + if (!medusaOrder && !cart) { + throw new Error('No cart or medusa order provided'); + } + + const ttoReservationIds: number[] = + (medusaOrder?.items ?? cart?.items) + ?.filter(({ metadata }) => !!metadata?.connectedOnlineReservationId) + .map(({ metadata }) => Number(metadata!.connectedOnlineReservationId)) ?? + []; + + const { data: orderedTtoServices } = await supabase + .schema('medreport') + .from('connected_online_reservation') + .select('*, provider:connected_online_providers(key)') + .in('id', ttoReservationIds) + .throwOnError(); + + return orderedTtoServices; +} + +export async function updateReservationTime( + reservationId: number, + newStartTime: Date, + newServiceId: number, + newAppointmentUserId: number, + newSyncUserId: number, + newLocationId: number | null, // TODO stop allowing null when Connected starts returning the correct ids instead of -1 + cartId: string, +) { + const logger = await getLogger(); + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + const userId = user?.id; + const { account } = await loadCurrentUserAccount(); + + if (!userId || !account) { + throw new Error('User not authenticated'); + } + + const reservationData = JSON.stringify({ + reservationId, + newStartTime, + newServiceId, + newAppointmentUserId, + newSyncUserId, + newLocationId, + userId, + cartId, + }); + + logger.info('Updating reservation' + reservationData); + try { + await supabase + .schema('medreport') + .from('connected_online_reservation') + .update({ + service_id: newServiceId, + service_user_id: newAppointmentUserId, + sync_user_id: newSyncUserId, + start_time: newStartTime.toString(), + location_sync_id: newLocationId, + }) + .eq('id', reservationId) + .eq('user_id', user.id) + .throwOnError(); + + logger.info(`Successfully updated reservation ${reservationData}`); + await supabase + .schema('audit') + .from('cart_entries') + .insert({ + operation: 'CHANGE_RESERVATION', + account_id: account.id, + cart_id: cartId, + changed_by: user.id, + comment: `${reservationData}`, + }); + revalidatePath('/home/cart', 'layout'); + } catch (e) { + logger.error(`Failed to update reservation ${reservationData}`); + await supabase + .schema('audit') + .from('cart_entries') + .insert({ + operation: 'CHANGE_RESERVATION', + account_id: account.id, + cart_id: cartId, + changed_by: user.id, + comment: `${e}`, + }); + throw e; + } +} diff --git a/lib/types/reservation.ts b/lib/types/reservation.ts new file mode 100644 index 0000000..447e745 --- /dev/null +++ b/lib/types/reservation.ts @@ -0,0 +1,35 @@ +import z from 'zod'; + +export const LocationSchema = z.object({ + syncId: z.number(), + name: z.string(), + address: z.string().nullable(), +}); +export type Location = z.infer; + +export const ServiceSchema = z.object({ + name: z.string(), + id: z.number(), +}); +export type Service = z.infer; + +export const ServiceProviderSchema = z.object({ + id: z.number(), + name: z.string(), + jobTitleEt: z.string().nullable(), + jobTitleEn: z.string().nullable(), + jobTitleRu: z.string().nullable(), + spokenLanguages: z.array(z.string()).nullable(), +}); +export type ServiceProvider = z.infer; + +export const ReservationSchema = z.object({ + startTime: z.string(), + service: ServiceSchema.optional(), + location: LocationSchema.optional(), + serviceProvider: ServiceProviderSchema.optional(), + medusaCartLineItemId: z.string(), + id: z.number(), + countryCode: z.string().optional(), +}); +export type Reservation = z.infer; diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index e00a03d..4676e8d 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -1137,7 +1137,22 @@ export type Database = { updated_at?: string | null user_id?: string } - Relationships: [] + Relationships: [ + { + foreignKeyName: "fk_reservation_clinic" + columns: ["clinic_id"] + isOneToOne: false + referencedRelation: "connected_online_providers" + referencedColumns: ["id"] + }, + { + foreignKeyName: "fk_reservation_service" + columns: ["service_id"] + isOneToOne: false + referencedRelation: "connected_online_services" + referencedColumns: ["id"] + }, + ] } connected_online_service_providers: { Row: { diff --git a/public/locales/en/booking.json b/public/locales/en/booking.json index 8a38d9f..5f30e95 100644 --- a/public/locales/en/booking.json +++ b/public/locales/en/booking.json @@ -6,5 +6,9 @@ "description": "Get to know the personal analysis packages and order" }, "noCategories": "List of services not found, try again later", - "noResults": "No availabilities found for selected dates" + "noResults": "No availabilities found for selected dates", + "serviceNotFound": "Service not found", + "bookTimeLoading": "Selecting time...", + "noProducts": "No products found", + "timeSlotUnavailable": "Service availability has changed, please select a new time" } \ No newline at end of file diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 223d3f1..63dbf49 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -7,7 +7,9 @@ "item": "Item", "quantity": "Quantity", "price": "Price", - "total": "Total" + "total": "Total", + "time": "Time", + "location": "Location" }, "checkout": { "goToCheckout": "Go to checkout", @@ -82,5 +84,9 @@ "title": "Location for analysis", "description": "If you are unable to go to the lab to collect the sample, you can go to any other suitable collection point.", "locationSelect": "Select location" + }, + "editServiceItem": { + "title": "Edit booking", + "description": "Edit booking details" } } \ No newline at end of file diff --git a/public/locales/et/booking.json b/public/locales/et/booking.json index bc2d2cd..a60b8ec 100644 --- a/public/locales/et/booking.json +++ b/public/locales/et/booking.json @@ -13,5 +13,8 @@ "showAllLocations": "Näita kõiki asutusi", "bookTimeSuccess": "Aeg valitud", "bookTimeError": "Aega ei õnnestunud valida", - "bookTimeLoading": "Aega valitakse..." + "bookTimeLoading": "Aega valitakse...", + "serviceNotFound": "Teenust ei leitud", + "noProducts": "Tooteid ei leitud", + "timeSlotUnavailable": "Teenuse saadavus muutus, palun vali uus aeg" } \ No newline at end of file diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index 2b2bfa5..41a77ae 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -7,7 +7,9 @@ "item": "Toode", "quantity": "Kogus", "price": "Hind", - "total": "Summa" + "total": "Summa", + "time": "Aeg", + "location": "Asukoht" }, "checkout": { "goToCheckout": "Vormista ost", @@ -84,5 +86,9 @@ "title": "Asukoht analüüside andmiseks", "description": "Kui Teil ei ole võimalik valitud asukohta minna analüüse andma, siis võite minna Teile sobivasse verevõtupunkti.", "locationSelect": "Vali asukoht" + }, + "editServiceItem": { + "title": "Muuda broneeringut", + "description": "Muuda broneeringu andmeid" } } \ No newline at end of file diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 7611586..8405f64 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -148,5 +148,6 @@ "yes": "Jah", "no": "Ei", "preferNotToAnswer": "Eelistan mitte vastata", - "book": "Broneeri" + "book": "Broneeri", + "change": "Muuda" } \ No newline at end of file diff --git a/public/locales/ru/booking.json b/public/locales/ru/booking.json index df29269..4987043 100644 --- a/public/locales/ru/booking.json +++ b/public/locales/ru/booking.json @@ -6,5 +6,9 @@ "description": "Ознакомьтесь с персональными пакетами анализов и закажите" }, "noCategories": "Список услуг не найден, попробуйте позже", - "noResults": "Для выбранных дат доступных вариантов не найдено" + "noResults": "Для выбранных дат доступных вариантов не найдено", + "bookTimeLoading": "Выбор времени...", + "serviceNotFound": "Услуга не найдена", + "noProducts": "Товары не найдены", + "timeSlotUnavailable": "Доступность услуги изменилась, пожалуйста, выберите другое время" } \ No newline at end of file diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 289ff31..279f3d2 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -7,7 +7,9 @@ "item": "Товар", "quantity": "Количество", "price": "Цена", - "total": "Сумма" + "total": "Сумма", + "time": "Время", + "location": "Местоположение" }, "checkout": { "goToCheckout": "Оформить заказ", @@ -82,5 +84,9 @@ "title": "Местоположение для сдачи анализов", "description": "Если у вас нет возможности прийти в выбранное место для сдачи анализов, вы можете посетить любой удобный для вас пункт забора крови.", "locationSelect": "Выберите местоположение" + }, + "editServiceItem": { + "title": "Изменить бронирование", + "description": "Изменить данные бронирования" } } \ No newline at end of file diff --git a/supabase/migrations/20250919121028_add_references_to_reservations.sql b/supabase/migrations/20250919121028_add_references_to_reservations.sql new file mode 100644 index 0000000..30d9384 --- /dev/null +++ b/supabase/migrations/20250919121028_add_references_to_reservations.sql @@ -0,0 +1,12 @@ +ALTER TABLE medreport.connected_online_reservation +ADD CONSTRAINT fk_reservation_clinic +FOREIGN KEY (clinic_id) +REFERENCES medreport.connected_online_providers(id); + +ALTER TABLE medreport.connected_online_services +ADD CONSTRAINT constraint_name UNIQUE (sync_id); + +ALTER TABLE medreport.connected_online_reservation +ADD CONSTRAINT fk_reservation_service +FOREIGN KEY (service_id) +REFERENCES medreport.connected_online_services(id); \ No newline at end of file From 961f72652018163fd8d4fb19ced6d6ee0573bbe1 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 17:28:45 +0300 Subject: [PATCH 12/30] translations, remove random empty lines, refactor --- lib/services/audit/cartEntries.ts | 40 ++++++ lib/services/medusaCart.service.ts | 124 +++++++----------- lib/services/reservation.service.ts | 33 ++--- public/locales/en/booking.json | 12 +- public/locales/en/cart.json | 4 +- public/locales/en/common.json | 4 +- public/locales/en/orders.json | 27 +++- public/locales/ru/booking.json | 8 +- public/locales/ru/cart.json | 4 +- public/locales/ru/orders.json | 23 +++- ...onnected_online_service_provider_table.sql | 2 +- 11 files changed, 166 insertions(+), 115 deletions(-) create mode 100644 lib/services/audit/cartEntries.ts diff --git a/lib/services/audit/cartEntries.ts b/lib/services/audit/cartEntries.ts new file mode 100644 index 0000000..4b45b26 --- /dev/null +++ b/lib/services/audit/cartEntries.ts @@ -0,0 +1,40 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export const createCartEntriesLog = async ({ + operation, + accountId, + cartId, + variantId, + comment, +}: { + operation: string; + accountId: string; + cartId: string; + variantId?: string; + comment?: string; +}) => { + try { + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + console.error('No authenticated user found; skipping audit insert'); + return; + } + + return supabase.schema('audit').from('cart_entries').insert({ + operation, + account_id: accountId, + cart_id: cartId, + changed_by: user.id, + variant_id: variantId, + comment, + }); + } catch (error) { + console.error('Failed to insert doctor page view log', error); + } +}; diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index 1da98b3..46f2f08 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -5,25 +5,13 @@ import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src'; import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart'; import { getCartId } from '@lib/data/cookies'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; -import { z } from 'zod'; - - - -import { getSupabaseServerClient } from '@kit/supabase/server-client'; - - - -import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; - - - import { isSameMinute } from 'date-fns'; +import { z } from 'zod'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; +import { createCartEntriesLog } from './audit/cartEntries'; import { getAvailableAppointmentsForService } from './connected-online.service'; - - - - const env = () => z .object({ @@ -50,8 +38,7 @@ export async function handleAddToCart({ selectedVariant: Pick; countryCode: string; }) { - const supabase = getSupabaseServerClient(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } @@ -63,16 +50,12 @@ export async function handleAddToCart({ countryCode, }); - const { error } = await supabase.schema('audit').from('cart_entries').insert({ - variant_id: selectedVariant.id, + await createCartEntriesLog({ + variantId: selectedVariant.id, operation: 'ADD_TO_CART', - account_id: account.id, - cart_id: newCart.id, - changed_by: user.id, + accountId: account.id, + cartId: newCart.id, }); - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } return { cart: newCart, addedItem }; } @@ -81,23 +64,18 @@ export async function handleDeleteCartItem({ lineId }: { lineId: string }) { await deleteLineItem(lineId); await cancelReservation(lineId); - const supabase = getSupabaseServerClient(); const cartId = await getCartId(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } - const { error } = await supabase.schema('audit').from('cart_entries').insert({ - variant_id: lineId, + await createCartEntriesLog({ + variantId: lineId, operation: 'REMOVE_FROM_CART', - account_id: account.id, - cart_id: cartId!, - changed_by: user.id, + accountId: account.id, + cartId: cartId!, }); - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } } export async function handleNavigateToPayment({ @@ -107,8 +85,7 @@ export async function handleNavigateToPayment({ language: string; paymentSessionId: string; }) { - const supabase = getSupabaseServerClient(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } @@ -117,30 +94,33 @@ export async function handleNavigateToPayment({ if (!cart) { throw new Error('No cart found'); } - const orderedTtoServices = await getOrderedTtoServices({ cart }); + const orderedTtoServices = await getOrderedTtoServices({ cart }); - if (orderedTtoServices?.length) { - const unavailableLineItemIds: string[] = [] - for (const ttoService of orderedTtoServices) { - const availabilities = await getAvailableAppointmentsForService( - ttoService.service_id, - ttoService.provider.key, - ttoService.location_sync_id, - new Date(ttoService.start_time), - 1, - ); - const isAvailable = availabilities?.T_Booking?.length ? availabilities.T_Booking.find((timeSlot) => isSameMinute(ttoService.start_time, timeSlot.StartTime)) : false - - if (!isAvailable) { - unavailableLineItemIds.push(ttoService.medusa_cart_line_item_id!) - } - } + if (orderedTtoServices?.length) { + const unavailableLineItemIds: string[] = []; + for (const ttoService of orderedTtoServices) { + const availabilities = await getAvailableAppointmentsForService( + ttoService.service_id, + ttoService.provider.key, + ttoService.location_sync_id, + new Date(ttoService.start_time), + 1, + ); + const isAvailable = availabilities?.T_Booking?.length + ? availabilities.T_Booking.find((timeSlot) => + isSameMinute(ttoService.start_time, timeSlot.StartTime), + ) + : false; - if (unavailableLineItemIds.length) { - return { unavailableLineItemIds } + if (!isAvailable) { + unavailableLineItemIds.push(ttoService.medusa_cart_line_item_id!); } } - + + if (unavailableLineItemIds.length) { + return { unavailableLineItemIds }; + } + } const paymentLink = await new MontonioOrderHandlerService().getMontonioPaymentLink({ @@ -152,17 +132,12 @@ export async function handleNavigateToPayment({ locale: language, merchantReference: `${account.id}:${paymentSessionId}:${cart.id}`, }); - - const { error } = await supabase.schema('audit').from('cart_entries').insert({ - operation: 'NAVIGATE_TO_PAYMENT', - account_id: account.id, - cart_id: cart.id, - changed_by: user.id, + + await createCartEntriesLog({ + operation: 'NAVIGATE_TO_PAYMENT', + accountId: account.id, + cartId: cart.id, }); - - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } return { url: paymentLink }; } @@ -172,21 +147,16 @@ export async function handleLineItemTimeout({ }: { lineItem: StoreCartLineItem; }) { - const supabase = getSupabaseServerClient(); - const { account, user } = await loadCurrentUserAccount(); + const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found'); } await deleteLineItem(lineItem.id); - const { error } = await supabase.schema('audit').from('cart_entries').insert({ + await createCartEntriesLog({ operation: 'LINE_ITEM_TIMEOUT', - account_id: account.id, - cart_id: lineItem.cart_id, - changed_by: user.id, + accountId: account.id, + cartId: lineItem.cart_id, }); - if (error) { - throw new Error('Error logging cart entry: ' + error.message); - } } \ No newline at end of file diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index b3a95ac..330a8b2 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -11,6 +11,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { EnrichedCartItem } from '../../app/home/(user)/_components/cart/types'; import { loadCurrentUserAccount } from '../../app/home/(user)/_lib/server/load-user-account'; +import { createCartEntriesLog } from './audit/cartEntries'; import { handleDeleteCartItem } from './medusaCart.service'; type Locations = Tables<{ schema: 'medreport' }, 'connected_online_locations'>; @@ -305,29 +306,21 @@ export async function updateReservationTime( .throwOnError(); logger.info(`Successfully updated reservation ${reservationData}`); - await supabase - .schema('audit') - .from('cart_entries') - .insert({ - operation: 'CHANGE_RESERVATION', - account_id: account.id, - cart_id: cartId, - changed_by: user.id, - comment: `${reservationData}`, - }); + await createCartEntriesLog({ + operation: 'CHANGE_RESERVATION', + accountId: account.id, + cartId: cartId, + comment: `${reservationData}`, + }); revalidatePath('/home/cart', 'layout'); } catch (e) { logger.error(`Failed to update reservation ${reservationData}`); - await supabase - .schema('audit') - .from('cart_entries') - .insert({ - operation: 'CHANGE_RESERVATION', - account_id: account.id, - cart_id: cartId, - changed_by: user.id, - comment: `${e}`, - }); + await createCartEntriesLog({ + operation: 'CHANGE_RESERVATION', + accountId: account.id, + cartId: cartId, + comment: `${e}`, + }); throw e; } } diff --git a/public/locales/en/booking.json b/public/locales/en/booking.json index 5f30e95..e5eee34 100644 --- a/public/locales/en/booking.json +++ b/public/locales/en/booking.json @@ -5,10 +5,16 @@ "title": "Analysis packages", "description": "Get to know the personal analysis packages and order" }, - "noCategories": "List of services not found, try again later", - "noResults": "No availabilities found for selected dates", - "serviceNotFound": "Service not found", + "noCategories": "Service list not found, please try again later", + "noResults": "No available times on the selected dates", + "services": "Services", + "locations": "Locations", + "showAll": "Show all", + "showAllLocations": "Show all locations", + "bookTimeSuccess": "Time selected", + "bookTimeError": "Failed to select time", "bookTimeLoading": "Selecting time...", + "serviceNotFound": "Service not found", "noProducts": "No products found", "timeSlotUnavailable": "Service availability has changed, please select a new time" } \ No newline at end of file diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 63dbf49..544719b 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -16,7 +16,9 @@ "goToDashboard": "Continue", "error": { "title": "Something went wrong", - "description": "Please try again later." + "description": "Please try again later.", + "BOOKING_FAILED": "Service error, please try again later.", + "TIME_SLOT_UNAVAILABLE": "The selected time is not available." }, "timeLeft": "Time left {{timeLeft}}", "timeoutTitle": "Reservation expired", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index cf41acd..7c1978a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -147,5 +147,7 @@ "language": "Language", "yes": "Yes", "no": "No", - "preferNotToAnswer": "Prefer not to answer" + "preferNotToAnswer": "Prefer not to answer", + "book": "Book", + "change": "Change" } \ No newline at end of file diff --git a/public/locales/en/orders.json b/public/locales/en/orders.json index 7aa958c..c3077f5 100644 --- a/public/locales/en/orders.json +++ b/public/locales/en/orders.json @@ -9,12 +9,27 @@ "status": "Status" }, "status": { - "QUEUED": "Waiting to send to lab", - "PROCESSING": "Waiting for results", - "PARTIAL_ANALYSIS_RESPONSE": "Partial analysis response", - "FULL_ANALYSIS_RESPONSE": "All analysis responses received, waiting for doctor response", - "COMPLETED": "Completed", + "QUEUED": "Queued", + "PROCESSING": "Processing", + "PARTIAL_ANALYSIS_RESPONSE": "Partial results", + "FULL_ANALYSIS_RESPONSE": "All results received", + "COMPLETED": "Confirmed", "REJECTED": "Rejected", - "CANCELLED": "Cancelled" + "CANCELLED": "Cancelled", + "analysisOrder": { + "QUEUED": "Queued", + "PROCESSING": "Sent to Synlab", + "PARTIAL_ANALYSIS_RESPONSE": "Partial results", + "FULL_ANALYSIS_RESPONSE": "All results received, awaiting doctor's summary", + "COMPLETED": "Confirmed", + "REJECTED": "Rejected", + "CANCELLED": "Cancelled" + }, + "ttoService": { + "PENDING": "Started", + "CONFIRMED": "Confirmed", + "REJECTED": "Rejected", + "CANCELLED": "Cancelled" + } } } \ No newline at end of file diff --git a/public/locales/ru/booking.json b/public/locales/ru/booking.json index 4987043..95b163a 100644 --- a/public/locales/ru/booking.json +++ b/public/locales/ru/booking.json @@ -6,7 +6,13 @@ "description": "Ознакомьтесь с персональными пакетами анализов и закажите" }, "noCategories": "Список услуг не найден, попробуйте позже", - "noResults": "Для выбранных дат доступных вариантов не найдено", + "noResults": "На выбранные даты нет свободного времени", + "services": "Услуги", + "locations": "Учреждения", + "showAll": "Показать все", + "showAllLocations": "Показать все учреждения", + "bookTimeSuccess": "Время выбрано", + "bookTimeError": "Не удалось выбрать время", "bookTimeLoading": "Выбор времени...", "serviceNotFound": "Услуга не найдена", "noProducts": "Товары не найдены", diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 279f3d2..1eae1a4 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -16,7 +16,9 @@ "goToDashboard": "Продолжить", "error": { "title": "Что-то пошло не так", - "description": "Пожалуйста, попробуйте позже." + "description": "Пожалуйста, попробуйте позже.", + "BOOKING_FAILED": "Ошибка сервиса, попробуйте позже.", + "TIME_SLOT_UNAVAILABLE": "Выбранное время недоступно." }, "timeLeft": "Осталось времени {{timeLeft}}", "timeoutTitle": "Бронирование истекло", diff --git a/public/locales/ru/orders.json b/public/locales/ru/orders.json index 6669aff..e013422 100644 --- a/public/locales/ru/orders.json +++ b/public/locales/ru/orders.json @@ -10,11 +10,26 @@ }, "status": { "QUEUED": "Отправлено", - "PROCESSING": "Передано в Synlab", + "PROCESSING": "В обработке", "PARTIAL_ANALYSIS_RESPONSE": "Частичные результаты", - "FULL_ANALYSIS_RESPONSE": "Все результаты получены, ожидается заключение врача", + "FULL_ANALYSIS_RESPONSE": "Все результаты получены", "COMPLETED": "Подтверждено", - "REJECTED": "Возвращено", - "CANCELLED": "Отменено" + "REJECTED": "Отклонено", + "CANCELLED": "Отменено", + "analysisOrder": { + "QUEUED": "Отправлено", + "PROCESSING": "Отправлено в Synlab", + "PARTIAL_ANALYSIS_RESPONSE": "Частичные результаты", + "FULL_ANALYSIS_RESPONSE": "Все результаты получены, ожидается заключение врача", + "COMPLETED": "Подтверждено", + "REJECTED": "Отклонено", + "CANCELLED": "Отменено" + }, + "ttoService": { + "PENDING": "Начато", + "CONFIRMED": "Подтверждено", + "REJECTED": "Отклонено", + "CANCELLED": "Отменено" + } } } \ No newline at end of file diff --git a/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql index 85dd813..6c647de 100644 --- a/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql +++ b/supabase/migrations/20250908125839_add_connected_online_service_provider_table.sql @@ -16,7 +16,7 @@ create table "medreport"."connected_online_service_providers" ( ALTER TABLE audit.request_entries DROP COLUMN personal_code; -ALTER TABLE audit.request_entries ADD COLUMN user_id uuid default auth.uid() references auth.users(id); +ALTER TABLE audit.request_entries ADD COLUMN user_id uuid default auth.uid(); create policy "insert_own" on "audit"."request_entries" From 249611886b35d6e68df3240231a94b4c13323714 Mon Sep 17 00:00:00 2001 From: Helena Date: Fri, 19 Sep 2025 17:30:32 +0300 Subject: [PATCH 13/30] remove unused import --- lib/services/medusaCart.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index 46f2f08..f1e013b 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -7,7 +7,6 @@ import { getCartId } from '@lib/data/cookies'; import { StoreCartLineItem, StoreProductVariant } from '@medusajs/types'; import { isSameMinute } from 'date-fns'; import { z } from 'zod'; -import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { cancelReservation, getOrderedTtoServices } from '~/lib/services/reservation.service'; import { createCartEntriesLog } from './audit/cartEntries'; import { getAvailableAppointmentsForService } from './connected-online.service'; From a520c04a02ed430d7350fbf379a66c2f06406606 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 24 Sep 2025 14:57:52 +0300 Subject: [PATCH 14/30] feat(MED-97): clean up --- .../20250924145253_fix_upsert_and_rls.sql | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/supabase/migrations/20250924145253_fix_upsert_and_rls.sql b/supabase/migrations/20250924145253_fix_upsert_and_rls.sql index d6d7589..b03c9c0 100644 --- a/supabase/migrations/20250924145253_fix_upsert_and_rls.sql +++ b/supabase/migrations/20250924145253_fix_upsert_and_rls.sql @@ -52,49 +52,3 @@ $$; -- 2. Grant permissions to authenticated users grant select, insert, update, delete on table "medreport"."benefit_distribution_schedule" to authenticated; - --- 3. Grant execute permissions to all functions -grant execute on function medreport.get_account_balance(uuid) to authenticated; -grant execute on function medreport.distribute_health_benefits(uuid, numeric, text) to authenticated; -grant execute on function medreport.consume_account_balance(uuid, numeric, text, text) to authenticated; -grant execute on function medreport.upsert_benefit_distribution_schedule(uuid, numeric, text) to authenticated; -grant execute on function medreport.calculate_next_distribution_date(text, timestamp with time zone) to authenticated; -grant execute on function medreport.trigger_benefit_distribution(uuid) to authenticated; -grant execute on function medreport.trigger_distribute_benefits() to authenticated; -grant execute on function medreport.process_periodic_benefit_distributions() to authenticated; - --- 4. Ensure trigger function has security definer -create or replace function medreport.trigger_distribute_benefits() -returns trigger -language plpgsql -security definer -as $$ -begin - -- Only distribute if benefit_amount is set and greater than 0 - if new.benefit_amount is not null and new.benefit_amount > 0 then - -- Distribute benefits to all company members immediately - perform medreport.distribute_health_benefits( - new.account_id, - new.benefit_amount, - coalesce(new.benefit_occurance, 'yearly') - ); - - -- Create or update the distribution schedule for future distributions - perform medreport.upsert_benefit_distribution_schedule( - new.account_id, - new.benefit_amount, - coalesce(new.benefit_occurance, 'yearly') - ); - else - -- If benefit_amount is 0 or null, deactivate the schedule - update medreport.benefit_distribution_schedule - set is_active = false, updated_at = now() - where company_id = new.account_id; - end if; - - return new; -end; -$$; - --- 5. Grant execute permission to the updated trigger function -grant execute on function medreport.trigger_distribute_benefits() to authenticated, service_role; From 41593be44a34fcf24ffe5361c504daf6138fd2d5 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 24 Sep 2025 15:14:13 +0300 Subject: [PATCH 15/30] fix types --- packages/supabase/src/database.types.ts | 264 ++++++++++++++++++------ 1 file changed, 196 insertions(+), 68 deletions(-) diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index ac8dc32..d9a0c45 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -199,7 +199,6 @@ export type Database = { changed_by: string created_at: string extra_data: Json | null - extra_data: Json | null id: number } Insert: { @@ -208,7 +207,6 @@ export type Database = { changed_by: string created_at?: string extra_data?: Json | null - extra_data?: Json | null id?: number } Update: { @@ -217,7 +215,6 @@ export type Database = { changed_by?: string created_at?: string extra_data?: Json | null - extra_data?: Json | null id?: number } Relationships: [] @@ -340,6 +337,91 @@ export type Database = { } medreport: { Tables: { + account_balance_entries: { + Row: { + account_id: string + amount: number + created_at: string + created_by: string | null + description: string | null + entry_type: string + expires_at: string | null + id: string + is_active: boolean + reference_id: string | null + source_company_id: string | null + } + Insert: { + account_id: string + amount: number + created_at?: string + created_by?: string | null + description?: string | null + entry_type: string + expires_at?: string | null + id?: string + is_active?: boolean + reference_id?: string | null + source_company_id?: string | null + } + Update: { + account_id?: string + amount?: number + created_at?: string + created_by?: string | null + description?: string | null + entry_type?: string + expires_at?: string | null + id?: string + is_active?: boolean + reference_id?: string | null + source_company_id?: string | null + } + Relationships: [ + { + foreignKeyName: "account_balance_entries_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_source_company_id_fkey" + columns: ["source_company_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_source_company_id_fkey" + columns: ["source_company_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "account_balance_entries_source_company_id_fkey" + columns: ["source_company_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } account_params: { Row: { account_id: string @@ -751,9 +833,6 @@ export type Database = { response_value_is_negative: boolean | null response_value_is_within_norm: boolean | null status: string | null - response_value_is_negative: boolean | null - response_value_is_within_norm: boolean | null - status: string | null unit: string | null updated_at: string | null } @@ -772,12 +851,9 @@ export type Database = { original_response_element: Json response_time?: string | null response_value?: number | null - response_time?: string | null - response_value?: number | null response_value_is_negative?: boolean | null response_value_is_within_norm?: boolean | null status?: string | null - status?: string | null unit?: string | null updated_at?: string | null } @@ -799,7 +875,6 @@ export type Database = { response_value_is_negative?: boolean | null response_value_is_within_norm?: boolean | null status?: string | null - status?: string | null unit?: string | null updated_at?: string | null } @@ -851,6 +926,64 @@ export type Database = { }, ] } + benefit_distribution_schedule: { + Row: { + benefit_amount: number + benefit_occurrence: string + company_id: string + created_at: string + id: string + is_active: boolean + last_distributed_at: string | null + next_distribution_at: string + updated_at: string + } + Insert: { + benefit_amount: number + benefit_occurrence: string + company_id: string + created_at?: string + id?: string + is_active?: boolean + last_distributed_at?: string | null + next_distribution_at: string + updated_at?: string + } + Update: { + benefit_amount?: number + benefit_occurrence?: string + company_id?: string + created_at?: string + id?: string + is_active?: boolean + last_distributed_at?: string | null + next_distribution_at?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "benefit_distribution_schedule_company_id_fkey" + columns: ["company_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "benefit_distribution_schedule_company_id_fkey" + columns: ["company_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "benefit_distribution_schedule_company_id_fkey" + columns: ["company_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + ] + } billing_customers: { Row: { account_id: string @@ -1349,7 +1482,6 @@ export type Database = { id: number status: Database["medreport"]["Enums"]["analysis_feedback_status"] updated_at: string | null - updated_at: string | null updated_by: string | null user_id: string value: string | null @@ -1362,7 +1494,6 @@ export type Database = { id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] updated_at?: string | null - updated_at?: string | null updated_by?: string | null user_id: string value?: string | null @@ -1375,7 +1506,6 @@ export type Database = { id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] updated_at?: string | null - updated_at?: string | null updated_by?: string | null user_id?: string value?: string | null @@ -1462,7 +1592,6 @@ export type Database = { Row: { action: string created_at: string | null - created_at: string | null has_analysis_results: boolean has_error: boolean id: string @@ -1472,13 +1601,6 @@ export type Database = { response_xml: string | null updated_at: string | null xml: string | null - id: string - medipost_external_order_id: string | null - medipost_private_message_id: string | null - medusa_order_id: string | null - response_xml: string | null - updated_at: string | null - xml: string | null } Insert: { action: string @@ -1505,31 +1627,7 @@ export type Database = { response_xml?: string | null updated_at?: string | null xml?: string | null - created_at?: string | null - has_analysis_results?: boolean - has_error?: boolean - id?: string - medipost_external_order_id?: string | null - medipost_private_message_id?: string | null - medusa_order_id?: string | null - response_xml?: string | null - updated_at?: string | null - xml?: string | null } - Update: { - action?: string - created_at?: string | null - has_analysis_results?: boolean - has_error?: boolean - id?: string - medipost_external_order_id?: string | null - medipost_private_message_id?: string | null - medusa_order_id?: string | null - response_xml?: string | null - updated_at?: string | null - xml?: string | null - } - Relationships: [] Relationships: [] } medreport_product_groups: { @@ -2097,10 +2195,23 @@ export type Database = { } Returns: Database["medreport"]["Tables"]["invitations"]["Row"][] } + calculate_next_distribution_date: { + Args: { p_current_date?: string; p_occurrence: string } + Returns: string + } can_action_account_member: { Args: { target_team_account_id: string; target_user_id: string } Returns: boolean } + consume_account_balance: { + Args: { + p_account_id: string + p_amount: number + p_description: string + p_reference_id?: string + } + Returns: boolean + } create_invitation: { Args: { account_id: string; email: string; role: string } Returns: { @@ -2154,6 +2265,18 @@ export type Database = { updated_by: string | null } } + distribute_health_benefits: { + Args: { + p_benefit_amount: number + p_benefit_occurrence?: string + p_company_id: string + } + Returns: undefined + } + get_account_balance: { + Args: { p_account_id: string } + Returns: number + } get_account_invitations: { Args: { account_slug: string } Returns: { @@ -2199,6 +2322,18 @@ export type Database = { personal_code: string }[] } + get_latest_analysis_response_elements_for_current_user: { + Args: { p_user_id: string } + Returns: { + analysis_name: string + analysis_name_lab: string + norm_lower: number + norm_status: number + norm_upper: number + response_time: string + response_value: number + }[] + } get_latest_medipost_dispatch_state_for_order: { Args: { medusa_order_id: string } Returns: { @@ -2301,9 +2436,10 @@ export type Database = { order_has_medipost_dispatch_error: { Args: { medusa_order_id: string } Returns: boolean - order_has_medipost_dispatch_error: { - Args: { medusa_order_id: string } - Returns: boolean + } + process_periodic_benefit_distributions: { + Args: Record + Returns: undefined } revoke_nonce: { Args: { p_id: string; p_reason?: string } @@ -2329,27 +2465,11 @@ export type Database = { Args: { new_owner_id: string; target_account_id: string } Returns: undefined } + trigger_benefit_distribution: { + Args: { p_company_id: string } + Returns: undefined + } update_account: { - Args: - | { - p_city: string - p_email: string - p_has_consent_personal_data: boolean - p_last_name: string - p_name: string - p_personal_code: string - p_phone: string - p_uid: string - } - | { - p_city: string - p_has_consent_personal_data: boolean - p_last_name: string - p_name: string - p_personal_code: string - p_phone: string - p_uid: string - } Args: | { p_city: string @@ -2388,6 +2508,14 @@ export type Database = { user_id: string } } + upsert_benefit_distribution_schedule: { + Args: { + p_benefit_amount: number + p_benefit_occurrence: string + p_company_id: string + } + Returns: undefined + } upsert_order: { Args: { billing_provider: Database["medreport"]["Enums"]["billing_provider"] From cf9a51e64f6069634ecbbdab282fd966c051a565 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 24 Sep 2025 15:27:31 +0300 Subject: [PATCH 16/30] rerun pipeline for updated aws parameters From b4cdc0853261b274ff8ffbb2e91d0eaa09045471 Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 24 Sep 2025 16:48:55 +0300 Subject: [PATCH 17/30] fix circular dependency due to index.ts import/export all same files --- packages/shared/src/config/admin-navigation.config.tsx | 3 ++- .../shared/src/config/personal-account-navigation.config.tsx | 3 ++- packages/shared/src/config/team-account-navigation.config.tsx | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/config/admin-navigation.config.tsx b/packages/shared/src/config/admin-navigation.config.tsx index 405fe91..72f1b2c 100644 --- a/packages/shared/src/config/admin-navigation.config.tsx +++ b/packages/shared/src/config/admin-navigation.config.tsx @@ -1,9 +1,10 @@ import { LayoutDashboard, Users } from 'lucide-react'; import { z } from 'zod'; -import { pathsConfig } from '@kit/shared/config'; import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; +import pathsConfig from './paths.config'; + const iconClasses = 'w-4 stroke-[1.5px]'; const routes = [ diff --git a/packages/shared/src/config/personal-account-navigation.config.tsx b/packages/shared/src/config/personal-account-navigation.config.tsx index b77f19c..d2763d7 100644 --- a/packages/shared/src/config/personal-account-navigation.config.tsx +++ b/packages/shared/src/config/personal-account-navigation.config.tsx @@ -8,9 +8,10 @@ import { } from 'lucide-react'; import { z } from 'zod'; -import { pathsConfig } from '@kit/shared/config'; import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; +import pathsConfig from './paths.config'; + const iconClasses = 'w-4 stroke-[1.5px]'; const routes = [ diff --git a/packages/shared/src/config/team-account-navigation.config.tsx b/packages/shared/src/config/team-account-navigation.config.tsx index 6f2c924..ac466cc 100644 --- a/packages/shared/src/config/team-account-navigation.config.tsx +++ b/packages/shared/src/config/team-account-navigation.config.tsx @@ -1,8 +1,10 @@ import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react'; -import { featureFlagsConfig, pathsConfig } from '@kit/shared/config'; import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; +import pathsConfig from './paths.config'; +import featureFlagsConfig from './feature-flags.config'; + const iconClasses = 'w-4'; const getRoutes = (account: string) => [ From c7298d2b7ee3ed20b0a3a80a7d672b067867b95b Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 24 Sep 2025 16:54:36 +0300 Subject: [PATCH 18/30] refactor --- app/home/(user)/(dashboard)/page.tsx | 1 - .../_components/booking/booking.provider.tsx | 18 +++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 967dc2c..22a671c 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -17,7 +17,6 @@ import DashboardCards from '../_components/dashboard-cards'; import Recommendations from '../_components/recommendations'; import RecommendationsSkeleton from '../_components/recommendations-skeleton'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; -import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index ceb4ab8..3ae2f1c 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -33,18 +33,14 @@ export const BookingProvider: React.FC<{ const [isLoadingTimeSlots, setIsLoadingTimeSlots] = useState(false); useEffect(() => { - let metadataServiceIds = []; - try { - metadataServiceIds = JSON.parse( - selectedService?.metadata?.serviceIds as string, - ); - } catch (e) { - console.error(e); + const metadataServiceIds = selectedService?.metadata?.serviceIds as string; + if (metadataServiceIds) { + const json = JSON.parse(metadataServiceIds); + if (Array.isArray(json)) { + updateTimeSlots(json); + } } - if (metadataServiceIds.length) { - updateTimeSlots(metadataServiceIds); - } - }, [selectedService?.metadata?.serviceIds, selectedLocationId]); + }, [selectedService, selectedLocationId]); const updateTimeSlots = async (serviceIds: number[]) => { setIsLoadingTimeSlots(true); From 6c6e7a6847ed3c8a5b098a8cdb30699ca54fa61e Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Wed, 24 Sep 2025 18:45:29 +0300 Subject: [PATCH 19/30] fix conflict duplicates --- .../cart/montonio-callback/actions.ts | 72 +++++-------------- .../cart/montonio-callback/error/page.tsx | 7 +- app/home/(user)/(dashboard)/cart/page.tsx | 6 -- app/home/(user)/(dashboard)/order/page.tsx | 6 -- .../_components/booking/booking.provider.tsx | 1 + app/home/(user)/_components/cart/index.tsx | 51 ++----------- lib/services/medusaCart.service.ts | 4 +- .../medusa-storefront/src/lib/data/regions.ts | 2 +- 8 files changed, 29 insertions(+), 120 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index f8cd535..b1ccb05 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -1,10 +1,5 @@ 'use server'; -import { MontonioOrderToken } from '@/app/home/(user)/_components/cart/types'; -import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; -import { placeOrder, retrieveCart } from '@lib/data/cart'; -import { listProductTypes } from '@lib/data/products'; -import type { StoreOrder } from '@medusajs/types'; import { MontonioOrderToken } from '@/app/home/(user)/_components/cart/types'; import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account'; import { placeOrder, retrieveCart } from '@lib/data/cart'; @@ -12,37 +7,26 @@ import { listProductTypes } from '@lib/data/products'; import type { StoreOrder } from '@medusajs/types'; import jwt from 'jsonwebtoken'; import { z } from 'zod'; + +import { AccountWithParams } from '@kit/accounts/types/accounts'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { - bookAppointment, -} from '~/lib/services/connected-online.service'; -import { FailureReason } from '~/lib/types/connected-online'; -import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; +import { bookAppointment } from '~/lib/services/connected-online.service'; import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service'; import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service'; -import { AccountWithParams } from '@kit/accounts/types/accounts'; +import { + createAnalysisOrder, + getAnalysisOrder, +} from '~/lib/services/order.service'; import { getOrderedTtoServices } from '~/lib/services/reservation.service'; +import { FailureReason } from '~/lib/types/connected-online'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; 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), - isEnabledDispatchOnMontonioCallback: z.boolean({ const env = () => z .object({ @@ -66,13 +50,6 @@ const env = () => isEnabledDispatchOnMontonioCallback: process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', }); - }) - .parse({ - emailSender: process.env.EMAIL_SENDER, - siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, - isEnabledDispatchOnMontonioCallback: - process.env.MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK === 'true', - }); const sendEmail = async ({ account, @@ -86,17 +63,9 @@ const sendEmail = async ({ analysisPackageName: string; partnerLocationName: string; language: string; - account: Pick; - email: string; - analysisPackageName: string; - partnerLocationName: string; - language: string; }) => { const client = getSupabaseServerAdminClient(); try { - const { renderSynlabAnalysisPackageEmail } = await import( - '@kit/email-templates' - ); const { renderSynlabAnalysisPackageEmail } = await import( '@kit/email-templates' ); @@ -133,7 +102,6 @@ const sendEmail = async ({ throw new Error(`Failed to send email, message=${error}`); } }; -}; async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; @@ -144,7 +112,6 @@ async function decodeOrderToken(orderToken: string) { if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) { throw new Error('Payment not successful'); - throw new Error('Payment not successful'); } return decoded; @@ -154,12 +121,10 @@ async function getCartByOrderToken(decoded: MontonioOrderToken) { const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); if (!cartId) { throw new Error('Cart ID not found'); - throw new Error('Cart ID not found'); } const cart = await retrieveCart(cartId); if (!cart) { throw new Error('Cart not found'); - throw new Error('Cart not found'); } return cart; } @@ -206,17 +171,12 @@ async function sendAnalysisPackageOrderEmail({ email, analysisPackageOrder, }: { - account: AccountWithParams; - email: string; account: AccountWithParams; email: string; analysisPackageOrder: { partnerLocationName: string; analysisPackageName: string; }; - partnerLocationName: string; - analysisPackageName: string; - }; }) { const { language } = await createI18nServerInstance(); const { analysisPackageName, partnerLocationName } = analysisPackageOrder; @@ -238,15 +198,18 @@ export async function processMontonioCallback(orderToken: string) { const { account } = await loadCurrentUserAccount(); if (!account) { throw new Error('Account not found in context'); - throw new Error('Account not found in context'); } try { const decoded = await decodeOrderToken(orderToken); const cart = await getCartByOrderToken(decoded); - const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); - const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); + const medusaOrder = await placeOrder(cart.id, { + revalidateCacheTags: false, + }); + const orderedAnalysisElements = await getOrderedAnalysisIds({ + medusaOrder, + }); const orderContainsSynlabItems = !!orderedAnalysisElements?.length; @@ -272,7 +235,8 @@ export async function processMontonioCallback(orderToken: string) { const orderResult = await getOrderResultParameters(medusaOrder); - const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = + orderResult; const orderedTtoServices = await getOrderedTtoServices({ medusaOrder }); let bookServiceResults: { @@ -314,7 +278,6 @@ export async function processMontonioCallback(orderToken: string) { } } else { console.error('Missing email to send order result email', orderResult); - console.error('Missing email to send order result email', orderResult); } if (env().isEnabledDispatchOnMontonioCallback && orderContainsSynlabItems) { @@ -334,7 +297,6 @@ export async function processMontonioCallback(orderToken: string) { return { success: true, orderId }; } catch (error) { - console.error('Failed to place order', error); console.error('Failed to place order', error); throw new Error(`Failed to place order, message=${error}`); } diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx index 49dd4c6..c84908a 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/error/page.tsx @@ -2,20 +2,15 @@ import { use } from 'react'; import Link from 'next/link'; -import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page'; -import { Button } from '@kit/ui/button'; - +import { toArray } from '@kit/shared/utils'; import { Button } from '@kit/ui/button'; import { Alert, AlertDescription } from '@kit/ui/shadcn/alert'; import { AlertTitle } from '@kit/ui/shadcn/alert'; import { Trans } from '@kit/ui/trans'; -import { FailureReason } from '~/lib/types/connected-online'; -import { toArray } from '~/lib/utils'; - export async function generateMetadata() { const { t } = await createI18nServerInstance(); diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 1623229..4a2af1f 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -25,7 +25,6 @@ export async function generateMetadata() { async function CartPage() { const cart = await retrieveCart().catch((error) => { - console.error('Failed to retrieve cart', error); console.error('Failed to retrieve cart', error); return notFound(); }); @@ -75,11 +74,6 @@ async function CartPage() { synlabAnalyses={synlabAnalyses} ttoServiceItems={ttoServiceItems} /> - ); } diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index f452e4b..2e9fe68 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -1,16 +1,11 @@ import React from 'react'; -import React from 'react'; - import { redirect } from 'next/navigation'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { listProductTypes } from '@lib/data/products'; import { Divider } from '@medusajs/ui'; -import { pathsConfig } from '@kit/shared/config'; -import { Divider } from '@medusajs/ui'; - import { pathsConfig } from '@kit/shared/config'; import { PageBody } from '@kit/ui/makerkit/page'; import { Trans } from '@kit/ui/trans'; @@ -92,7 +87,6 @@ async function OrdersPage() { /> ); - ); })} {analysisOrders.length === 0 && (
diff --git a/app/home/(user)/_components/booking/booking.provider.tsx b/app/home/(user)/_components/booking/booking.provider.tsx index 3ae2f1c..b0c21e1 100644 --- a/app/home/(user)/_components/booking/booking.provider.tsx +++ b/app/home/(user)/_components/booking/booking.provider.tsx @@ -45,6 +45,7 @@ export const BookingProvider: React.FC<{ const updateTimeSlots = async (serviceIds: number[]) => { setIsLoadingTimeSlots(true); try { + console.log('serviceIds', serviceIds, selectedLocationId); const response = await getAvailableTimeSlotsForDisplay( serviceIds, selectedLocationId, diff --git a/app/home/(user)/_components/cart/index.tsx b/app/home/(user)/_components/cart/index.tsx index f13b280..81fbdd4 100644 --- a/app/home/(user)/_components/cart/index.tsx +++ b/app/home/(user)/_components/cart/index.tsx @@ -1,14 +1,4 @@ 'use client'; -'use client'; - -import { useState } from 'react'; - -import { handleNavigateToPayment } from '@/lib/services/medusaCart.service'; -import { formatCurrency } from '@/packages/shared/src/utils'; -import { initiatePaymentSession } from '@lib/data/cart'; -import { StoreCart, StoreCartLineItem } from '@medusajs/types'; -import { Loader2 } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; import { useState } from 'react'; @@ -36,7 +26,6 @@ export default function Cart({ synlabAnalyses, ttoServiceItems, }: { - cart: StoreCart | null; cart: StoreCart | null; synlabAnalyses: StoreCartLineItem[]; ttoServiceItems: EnrichedCartItem[]; @@ -44,12 +33,10 @@ export default function Cart({ const { i18n: { language }, } = useTranslation(); - const { - i18n: { language }, - } = useTranslation(); const [isInitiatingSession, setIsInitiatingSession] = useState(false); - const [unavailableLineItemIds, setUnavailableLineItemIds] = useState() + const [unavailableLineItemIds, setUnavailableLineItemIds] = + useState(); const items = cart?.items ?? []; @@ -57,10 +44,6 @@ export default function Cart({ return (
-
{hasCartItems && ( <> -
-
-

@@ -127,7 +110,6 @@ export default function Cart({

-

{formatCurrency({ value: cart.subtotal, @@ -137,9 +119,6 @@ export default function Cart({

-
-
-

@@ -147,7 +126,6 @@ export default function Cart({

-

{formatCurrency({ value: cart.discount_total, @@ -157,9 +135,6 @@ export default function Cart({

-
-
-

@@ -167,7 +142,6 @@ export default function Cart({

-

{formatCurrency({ value: cart.total, @@ -180,10 +154,8 @@ export default function Cart({ )} -

{IS_DISCOUNT_SHOWN && ( -
@@ -197,7 +169,6 @@ export default function Cart({ )} {isLocationsShown && ( -
@@ -219,14 +190,6 @@ export default function Cart({
-
diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index 39ec1de..3495cea 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -17,7 +17,7 @@ import { } from '@kit/ui/table'; import { Trans } from '@kit/ui/trans'; -import type { AnalysisOrder } from '~/lib/types/analysis-order'; +import type { Order } from '~/lib/types/order'; import { logAnalysisResultsNavigateAction } from './actions'; @@ -26,12 +26,12 @@ export type OrderItemType = 'analysisOrder' | 'ttoService'; export default function OrderItemsTable({ items, title, - analysisOrder, + order, type = 'analysisOrder', }: { items: StoreOrderLineItem[]; title: string; - analysisOrder?: AnalysisOrder; + order: Order; type?: OrderItemType; }) { const router = useRouter(); @@ -43,9 +43,9 @@ export default function OrderItemsTable({ const isAnalysisOrder = type === 'analysisOrder'; const openAnalysisResults = async () => { - if (analysisOrder) { - await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); - router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`); + if (isAnalysisOrder && order?.medusaOrderId && order?.id) { + await logAnalysisResultsNavigateAction(order.medusaOrderId); + router.push(`${pathsConfig.app.analysisResults}/${order.id}`); } }; @@ -84,7 +84,7 @@ export default function OrderItemsTable({ diff --git a/app/home/(user)/_lib/server/actions.ts b/app/home/(user)/_lib/server/actions.ts index 43d90b8..f3998ca 100644 --- a/app/home/(user)/_lib/server/actions.ts +++ b/app/home/(user)/_lib/server/actions.ts @@ -23,16 +23,16 @@ export async function createInitialReservationAction( }); if (addedItem) { - const reservation = await createInitialReservation( + const reservation = await createInitialReservation({ serviceId, clinicId, appointmentUserId, - syncUserId, + syncUserID: syncUserId, startTime, - addedItem.id, + medusaLineItemId: addedItem.id, locationId, comments, - ); + }); await updateLineItem({ lineId: addedItem.id, diff --git a/lib/services/medipost/medipostPrivateMessage.service.ts b/lib/services/medipost/medipostPrivateMessage.service.ts index 055acd6..997a12f 100644 --- a/lib/services/medipost/medipostPrivateMessage.service.ts +++ b/lib/services/medipost/medipostPrivateMessage.service.ts @@ -16,8 +16,8 @@ import axios from 'axios'; import { toArray } from '@kit/shared/utils'; import { Tables } from '@kit/supabase/database'; -import type { AnalysisOrder } from '~/lib/types/analysis-order'; import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element'; +import type { AnalysisOrder } from '~/lib/types/order'; import { getAccountAdmin } from '../account.service'; import { getAnalyses } from '../analyses.service'; diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 12368be..c554ae9 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -4,7 +4,7 @@ import type { Tables } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import type { AnalysisOrder } from '../types/analysis-order'; +import type { AnalysisOrder, TTOOrder } from '../types/order'; export async function createAnalysisOrder({ medusaOrder, @@ -176,10 +176,7 @@ export async function getTtoOrders({ orderStatus, lineItemIds, }: { - orderStatus?: Tables< - { schema: 'medreport' }, - 'connected_online_reservation' - >['status']; + orderStatus?: TTOOrder['status']; lineItemIds?: string[]; } = {}) { const client = getSupabaseServerClient(); diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index 330a8b2..7abf1d1 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -150,16 +150,25 @@ export async function getCartReservations( return results; } -export async function createInitialReservation( - serviceId: number, - clinicId: number, - appointmentUserId: number, - syncUserID: number, - startTime: Date, - medusaLineItemId: string, - locationId?: number | null, +export async function createInitialReservation({ + serviceId, + clinicId, + appointmentUserId, + syncUserID, + startTime, + medusaLineItemId, + locationId, comments = '', -) { +}: { + serviceId: number; + clinicId: number; + appointmentUserId: number; + syncUserID: number; + startTime: Date; + medusaLineItemId: string; + locationId?: number | null; + comments?: string; +}) { const logger = await getLogger(); const supabase = getSupabaseServerClient(); @@ -255,15 +264,23 @@ export async function getOrderedTtoServices({ return orderedTtoServices; } -export async function updateReservationTime( - reservationId: number, - newStartTime: Date, - newServiceId: number, - newAppointmentUserId: number, - newSyncUserId: number, - newLocationId: number | null, // TODO stop allowing null when Connected starts returning the correct ids instead of -1 - cartId: string, -) { +export async function updateReservationTime({ + reservationId, + newStartTime, + newServiceId, + newAppointmentUserId, + newSyncUserId, + newLocationId, // TODO stop allowing null when Connected starts returning the correct ids instead of -1 + cartId, +}: { + reservationId: number; + newStartTime: Date; + newServiceId: number; + newAppointmentUserId: number; + newSyncUserId: number; + newLocationId: number | null; + cartId: string; +}) { const logger = await getLogger(); const supabase = getSupabaseServerClient(); diff --git a/lib/types/analysis-order.ts b/lib/types/analysis-order.ts deleted file mode 100644 index a7ce721..0000000 --- a/lib/types/analysis-order.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Tables } from '@kit/supabase/database'; - -export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; diff --git a/lib/types/order.ts b/lib/types/order.ts new file mode 100644 index 0000000..92ce3f7 --- /dev/null +++ b/lib/types/order.ts @@ -0,0 +1,12 @@ +import type { Tables } from '@kit/supabase/database'; + +export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; +export type TTOOrder = Tables< + { schema: 'medreport' }, + 'connected_online_reservation' +>; +export type Order = { + medusaOrderId?: string; + id?: number; + status?: string; +}; diff --git a/public/locales/et/orders.json b/public/locales/et/orders.json index 036c076..a57ff1c 100644 --- a/public/locales/et/orders.json +++ b/public/locales/et/orders.json @@ -27,7 +27,7 @@ "CANCELLED": "Tühistatud" }, "ttoService": { - "PENDING": "Alustatud", + "PENDING": "Laekumise ootel", "CONFIRMED": "Kinnitatud", "REJECTED": "Tagasi lükatud", "CANCELLED": "Tühistatud" From 2e2498577f498a4c0a639cdb3b5be723a23e8958 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 15:40:37 +0300 Subject: [PATCH 21/30] MED-103: create job --- app/api/job/handler/sync-connected-online.ts | 4 +++- ...500925153100_sync_connected_online_cron_job.sql | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/202500925153100_sync_connected_online_cron_job.sql diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 07b7435..41528ba 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -38,7 +38,9 @@ function getSpokenLanguages(spokenLanguages?: string) { } export default async function syncConnectedOnline() { - const isProd = process.env.NODE_ENV === 'production'; + const isProd = !['test', 'localhost'].some((pathString) => + process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString), + ); const baseUrl = process.env.CONNECTED_ONLINE_URL; diff --git a/supabase/migrations/202500925153100_sync_connected_online_cron_job.sql b/supabase/migrations/202500925153100_sync_connected_online_cron_job.sql new file mode 100644 index 0000000..f3fde10 --- /dev/null +++ b/supabase/migrations/202500925153100_sync_connected_online_cron_job.sql @@ -0,0 +1,14 @@ +select + cron.schedule( + 'sync-connected-online-every-night', -- Unique job name + '0 1 * * *', -- Cron schedule: every night at 04:00 (GMT +3) + $$ + select + net.http_post( + url := 'https://test.medreport.ee/api/job/sync-connected-online', + headers := jsonb_build_object( + 'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84' + ) + ) as request_id; + $$ + ); From 5d88121e78106e5973e6eb7bf751b97f2c59b465 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 15:51:43 +0300 Subject: [PATCH 22/30] refactor demo clinic condition --- app/api/job/handler/sync-connected-online.ts | 40 +++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 41528ba..80e89c9 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -86,34 +86,20 @@ export default async function syncConnectedOnline() { let serviceProviders; let jobTitleTranslations; // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment - const isDemoClinic = (clinicId: number) => clinicId === 2; - if (isProd) { - clinics = responseData.Data.T_Lic.filter(({ ID }) => !isDemoClinic(ID)); - services = responseData.Data.T_Service.filter( - ({ ClinicID }) => !isDemoClinic(ClinicID), - ); - serviceProviders = responseData.Data.T_Doctor.filter( - ({ ClinicID }) => !isDemoClinic(ClinicID), - ); - jobTitleTranslations = createTranslationMap( - responseData.Data.P_JobTitleTranslations.filter( - ({ ClinicID }) => !isDemoClinic(ClinicID), - ), - ); - } else { - clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); - services = responseData.Data.T_Service.filter(({ ClinicID }) => + const isDemoClinic = (clinicId: number) => + isProd ? clinicId !== 2 : clinicId === 2; + clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID)); + services = responseData.Data.T_Service.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => + isDemoClinic(ClinicID), + ); + jobTitleTranslations = createTranslationMap( + responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => isDemoClinic(ClinicID), - ); - serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) => - isDemoClinic(ClinicID), - ); - jobTitleTranslations = createTranslationMap( - responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) => - isDemoClinic(ClinicID), - ), - ); - } + ), + ); const mappedClinics = clinics.map((clinic) => { return { From c69a1af0940c0d3a4f5915e31b4fd1bd9e009bbb Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 16:06:34 +0300 Subject: [PATCH 23/30] added CONNECTED_ONLINE_URL env From 85c6621b7a38598c35e3942a2ec736d505f203d4 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 16:11:08 +0300 Subject: [PATCH 24/30] use env --- lib/services/medusaCart.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/services/medusaCart.service.ts b/lib/services/medusaCart.service.ts index e91ef65..87efe47 100644 --- a/lib/services/medusaCart.service.ts +++ b/lib/services/medusaCart.service.ts @@ -31,8 +31,8 @@ const env = () => .min(1), }) .parse({ - medusaBackendPublicUrl: 'http://webhook.site:3000', - siteUrl: 'http://webhook.site:3000', + medusaBackendPublicUrl: process.env.MEDUSA_BACKEND_PUBLIC_URL!, + siteUrl: process.env.NEXT_PUBLIC_SITE_URL!, }); export async function handleAddToCart({ From 2188b73f5433880cb1cc18f2041e4ba5285d771d Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 17:11:00 +0300 Subject: [PATCH 25/30] recreated montonio env From 0f2bfb74b456f87e02a5fd4ce270f8df33695139 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 17:24:44 +0300 Subject: [PATCH 26/30] disable revalidatePath for home/cart --- lib/services/reservation.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index 330a8b2..223cacf 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -312,7 +312,7 @@ export async function updateReservationTime( cartId: cartId, comment: `${reservationData}`, }); - revalidatePath('/home/cart', 'layout'); + // revalidatePath('/home/cart', 'layout'); } catch (e) { logger.error(`Failed to update reservation ${reservationData}`); await createCartEntriesLog({ From 1acde486f27e707d7b0cbd0623ac21924f2c28a1 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 17:42:53 +0300 Subject: [PATCH 27/30] add dynamic export --- app/home/(user)/(dashboard)/cart/page.tsx | 2 ++ lib/services/reservation.service.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 4a2af1f..cef7d8b 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -15,6 +15,8 @@ import Cart from '../../_components/cart'; import CartTimer from '../../_components/cart/cart-timer'; import { EnrichedCartItem } from '../../_components/cart/types'; +export const dynamic = 'force-dynamic'; + export async function generateMetadata() { const { t } = await createI18nServerInstance(); diff --git a/lib/services/reservation.service.ts b/lib/services/reservation.service.ts index 223cacf..330a8b2 100644 --- a/lib/services/reservation.service.ts +++ b/lib/services/reservation.service.ts @@ -312,7 +312,7 @@ export async function updateReservationTime( cartId: cartId, comment: `${reservationData}`, }); - // revalidatePath('/home/cart', 'layout'); + revalidatePath('/home/cart', 'layout'); } catch (e) { logger.error(`Failed to update reservation ${reservationData}`); await createCartEntriesLog({ From 0a6137127116cb555d5dbffeb2b0f20fdade451b Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 25 Sep 2025 18:07:10 +0300 Subject: [PATCH 28/30] refactor env --- app/home/(user)/(dashboard)/cart/page.tsx | 2 -- .../src/services/montonio-webhook-handler.service.ts | 10 ++++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index cef7d8b..4a2af1f 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -15,8 +15,6 @@ import Cart from '../../_components/cart'; import CartTimer from '../../_components/cart/cart-timer'; import { EnrichedCartItem } from '../../_components/cart/types'; -export const dynamic = 'force-dynamic'; - export async function generateMetadata() { const { t } = await createI18nServerInstance(); diff --git a/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts b/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts index 97a2fd0..2df2be2 100644 --- a/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts +++ b/packages/billing/montonio/src/services/montonio-webhook-handler.service.ts @@ -37,10 +37,11 @@ interface MontonioOrderToken { exp: number; } -const { secretKey } = MontonioServerEnvSchema.parse({ - apiUrl: process.env.MONTONIO_API_URL, - secretKey: process.env.MONTONIO_SECRET_KEY, -}); +const env = () => + MontonioServerEnvSchema.parse({ + apiUrl: process.env.MONTONIO_API_URL, + secretKey: process.env.MONTONIO_SECRET_KEY, + }); export class MontonioWebhookHandlerService implements BillingWebhookHandlerService @@ -50,6 +51,7 @@ export class MontonioWebhookHandlerService async verifyWebhookSignature(request: Request) { const logger = await getLogger(); + const { secretKey } = env(); let token: string; try { From bfdd1ec62a3eab59a457c22f66a3c98c170980d1 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Fri, 26 Sep 2025 15:58:25 +0300 Subject: [PATCH 29/30] add tto order --- .../(dashboard)/order/[orderId]/page.tsx | 19 +++++++------------ .../(user)/_components/booking/time-slots.tsx | 8 +++++--- .../_components/order/order-details.tsx | 8 ++++++-- .../(user)/_components/orders/order-block.tsx | 5 ++++- .../_components/orders/order-items-table.tsx | 16 ++++++++-------- .../medusa-storefront/src/lib/data/orders.ts | 5 ----- 6 files changed, 30 insertions(+), 31 deletions(-) diff --git a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx index e6bf47d..f28b5ae 100644 --- a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx +++ b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx @@ -26,17 +26,7 @@ async function OrderConfirmedPage(props: { params: Promise<{ orderId: string }>; }) { const params = await props.params; - - const order = await getAnalysisOrder({ - analysisOrderId: Number(params.orderId), - }).catch(() => null); - if (!order) { - redirect(pathsConfig.app.myOrders); - } - - const medusaOrder = await retrieveOrder(order.medusa_order_id).catch( - () => null, - ); + const medusaOrder = await retrieveOrder(params.orderId).catch(() => null); if (!medusaOrder) { redirect(pathsConfig.app.myOrders); } @@ -46,7 +36,12 @@ async function OrderConfirmedPage(props: { } />
- + diff --git a/app/home/(user)/_components/booking/time-slots.tsx b/app/home/(user)/_components/booking/time-slots.tsx index 4e28adc..4d5fc14 100644 --- a/app/home/(user)/_components/booking/time-slots.tsx +++ b/app/home/(user)/_components/booking/time-slots.tsx @@ -171,9 +171,11 @@ const TimeSlots = ({ reservationId, newStartTime: timeSlot.StartTime, newServiceId: Number(syncedService.id), - timeSlot.UserID, - timeSlot.SyncUserID, - booking.selectedLocationId ? booking.selectedLocationId : null, + newAppointmentUserId: timeSlot.UserID, + newSyncUserId: timeSlot.SyncUserID, + newLocationId: booking.selectedLocationId + ? booking.selectedLocationId + : null, cartId, }); diff --git a/app/home/(user)/_components/order/order-details.tsx b/app/home/(user)/_components/order/order-details.tsx index 8dcff3f..0107790 100644 --- a/app/home/(user)/_components/order/order-details.tsx +++ b/app/home/(user)/_components/order/order-details.tsx @@ -4,14 +4,18 @@ import { Trans } from '@kit/ui/trans'; import type { AnalysisOrder } from '~/lib/types/order'; -export default function OrderDetails({ order }: { order: AnalysisOrder }) { +export default function OrderDetails({ + order, +}: { + order: { id: string; created_at: string | Date }; +}) { return (
:{' '} - {order.medusa_order_id} + {order.id}
diff --git a/app/home/(user)/_components/orders/order-block.tsx b/app/home/(user)/_components/orders/order-block.tsx index ef6ab6d..08daf05 100644 --- a/app/home/(user)/_components/orders/order-block.tsx +++ b/app/home/(user)/_components/orders/order-block.tsx @@ -64,7 +64,10 @@ export default function OrderBlock({ items={itemsTtoService} title="orders:table.ttoService" type="ttoService" - order={{ status: medusaOrderStatus.toUpperCase() }} + order={{ + status: medusaOrderStatus.toUpperCase(), + medusaOrderId, + }} /> )} { + const openDetailedView = async () => { if (isAnalysisOrder && order?.medusaOrderId && order?.id) { await logAnalysisResultsNavigateAction(order.medusaOrderId); router.push(`${pathsConfig.app.analysisResults}/${order.id}`); + } else { + router.push(`${pathsConfig.app.myOrders}/${order.medusaOrderId}`); } }; @@ -88,13 +90,11 @@ export default function OrderItemsTable({ /> - {isAnalysisOrder && ( - - - - )} + + + ))} diff --git a/packages/features/medusa-storefront/src/lib/data/orders.ts b/packages/features/medusa-storefront/src/lib/data/orders.ts index c79c579..6473ab0 100644 --- a/packages/features/medusa-storefront/src/lib/data/orders.ts +++ b/packages/features/medusa-storefront/src/lib/data/orders.ts @@ -61,11 +61,6 @@ export const listOrders = async ( }; export const createTransferRequest = async ( - state: { - success: boolean; - error: string | null; - order: HttpTypes.StoreOrder | null; - }, formData: FormData, ): Promise<{ success: boolean; From c99beea46a3499277cd0265da2143beb4a683239 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Fri, 26 Sep 2025 16:18:50 +0300 Subject: [PATCH 30/30] add openai validation --- app/home/(user)/(dashboard)/page.tsx | 22 +++++++++---------- .../_lib/server/is-valid-open-ai-env.ts | 13 +++++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 app/home/(user)/_lib/server/is-valid-open-ai-env.ts diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 22a671c..cdb7bcf 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -16,6 +16,7 @@ import Dashboard from '../_components/dashboard'; import DashboardCards from '../_components/dashboard-cards'; import Recommendations from '../_components/recommendations'; import RecommendationsSkeleton from '../_components/recommendations-skeleton'; +import { isValidOpenAiEnv } from '../_lib/server/is-valid-open-ai-env'; import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; export const generateMetadata = async () => { @@ -52,17 +53,16 @@ async function UserHomePage() { /> - {process.env.OPENAI_API_KEY && - process.env.PROMPT_ID_ANALYSIS_RECOMMENDATIONS && ( - <> -

- -

- }> - - - - )} + {(await isValidOpenAiEnv()) && ( + <> +

+ +

+ }> + + + + )}
); diff --git a/app/home/(user)/_lib/server/is-valid-open-ai-env.ts b/app/home/(user)/_lib/server/is-valid-open-ai-env.ts new file mode 100644 index 0000000..d6dbfae --- /dev/null +++ b/app/home/(user)/_lib/server/is-valid-open-ai-env.ts @@ -0,0 +1,13 @@ +import OpenAI from 'openai'; + +export const isValidOpenAiEnv = async () => { + const client = new OpenAI(); + + try { + await client.models.list(); + return true; + } catch (e) { + console.log('No openAI env'); + return false; + } +};