Merge branch 'develop' into MED-97

This commit is contained in:
2025-09-26 17:01:24 +03:00
86 changed files with 11249 additions and 3151 deletions

View File

@@ -1,14 +1,13 @@
'use server';
import { RequestStatus } from '@/lib/types/audit';
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 +15,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 +67,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);
}
}

View File

@@ -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);
}
};

View File

@@ -6,6 +6,7 @@ export enum PageViewAction {
REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS',
VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS',
VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD',
VIEW_TTO_SERVICE_BOOKING = 'VIEW_TTO_SERVICE_BOOKING',
}
export const createPageViewLog = async ({
@@ -37,6 +38,7 @@ export const createPageViewLog = async ({
account_id: accountId,
action,
changed_by: user.id,
extra_data: extraData,
})
.throwOnError();
} catch (error) {

View File

@@ -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 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 '~/home/(user)/_components/booking/booking.context';
import { sendEmailFromTemplate } from './mailer.service';
export async function getAvailableAppointmentsForService(
serviceId: number,
key: string,
locationId: number | null,
startTime?: Date,
maxDays?: number,
) {
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: maxDays ?? 120,
LocationId: locationId ?? -1,
...start,
}),
},
);
@@ -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,
ServiceID: service.sync_id,
ClinicServiceID: service.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,83 @@ 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<TimeSlotResponse> {
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',
),
};
}

View File

@@ -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';

View File

@@ -2,12 +2,19 @@
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { MontonioOrderHandlerService } from '@/packages/billing/montonio/src';
import { addToCart, deleteLineItem } from '@lib/data/cart';
import { addToCart, deleteLineItem, retrieveCart } from '@lib/data/cart';
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';
const env = () =>
z
@@ -35,53 +42,44 @@ export async function handleAddToCart({
selectedVariant: Pick<StoreProductVariant, 'id'>;
countryCode: string;
}) {
const supabase = getSupabaseServerClient();
const { account, user } = await loadCurrentUserAccount();
const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
const quantity = 1;
const cart = await addToCart({
const { newCart, addedItem } = await addToCart({
variantId: selectedVariant.id,
quantity,
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: cart.id,
changed_by: user.id,
accountId: account.id,
cartId: newCart.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();
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({
@@ -97,12 +95,43 @@ export async function handleNavigateToPayment({
currencyCode: string;
cartId: string;
}) {
const supabase = getSupabaseServerClient();
const { account, user } = await loadCurrentUserAccount();
const { account } = await loadCurrentUserAccount();
if (!account) {
throw new Error('Account not found');
}
const cart = await retrieveCart();
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({
notificationUrl: `${env().medusaBackendPublicUrl}/hooks/payment/montonio_montonio`,
@@ -114,17 +143,13 @@ export async function handleNavigateToPayment({
merchantReference: `${account.id}:${paymentSessionId}:${cartId}`,
});
const { error } = await supabase.schema('audit').from('cart_entries').insert({
await createCartEntriesLog({
operation: 'NAVIGATE_TO_PAYMENT',
account_id: account.id,
cart_id: cartId,
changed_by: user.id,
accountId: account.id,
cartId: cart.id,
});
if (error) {
throw new Error('Error logging cart entry: ' + error.message);
}
return paymentLink;
return { url: paymentLink };
}
export async function handleLineItemTimeout({
@@ -132,21 +157,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);
}
}

View File

@@ -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,
@@ -129,3 +129,39 @@ export async function getAnalysisOrdersAdmin({
.throwOnError();
return orders.data;
}
export async function getTtoOrders({
orderStatus,
lineItemIds,
}: {
orderStatus?: TTOOrder['status'];
lineItemIds?: string[];
} = {}) {
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);
}
if (lineItemIds?.length) {
query.in('medusa_cart_line_item_id', lineItemIds);
}
const orders = await query
.order('created_at', { ascending: false })
.throwOnError();
return orders.data;
}

View File

@@ -0,0 +1,343 @@
'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 { createCartEntriesLog } from './audit/cartEntries';
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<EnrichedCartItem[]> {
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,
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();
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,
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();
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 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 createCartEntriesLog({
operation: 'CHANGE_RESERVATION',
accountId: account.id,
cartId: cartId,
comment: `${e}`,
});
throw e;
}
}

View File

@@ -1,3 +0,0 @@
import type { Tables } from '@kit/supabase/database';
export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>;

View File

@@ -10,13 +10,15 @@ export type BookTimeResponse = z.infer<typeof BookTimeResponseSchema>;
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<typeof ConfirmedLoadResponseSchema>;
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',
}

12
lib/types/order.ts Normal file
View File

@@ -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;
};

35
lib/types/reservation.ts Normal file
View File

@@ -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<typeof LocationSchema>;
export const ServiceSchema = z.object({
name: z.string(),
id: z.number(),
});
export type Service = z.infer<typeof ServiceSchema>;
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<typeof ServiceProviderSchema>;
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<typeof ReservationSchema>;

View File

@@ -140,3 +140,10 @@ export default class PersonalCode {
};
}
}
export const findProductTypeIdByHandle = (
productTypes: { metadata?: Record<string, unknown> | null; id: string }[],
handle: string,
) => {
return productTypes.find(({ metadata }) => metadata?.handle === handle)?.id;
};