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