feat: create email template for TTO reservation confirmation

feat: implement order notifications service with TTO reservation confirmation handling

feat: create migration for TTO booking email webhook trigger
This commit is contained in:
Danel Kungla
2025-09-30 16:05:43 +03:00
parent 4003284f3a
commit 72f6f2b716
56 changed files with 3692 additions and 294 deletions

View File

@@ -43,12 +43,17 @@ export class AccountBalanceService {
offset?: number;
entryType?: string;
includeInactive?: boolean;
} = {}
} = {},
): Promise<{
entries: AccountBalanceEntry[];
total: number;
}> {
const { limit = 50, offset = 0, entryType, includeInactive = false } = options;
const {
limit = 50,
offset = 0,
entryType,
includeInactive = false,
} = options;
let query = this.supabase
.schema('medreport')
@@ -105,7 +110,8 @@ export class AccountBalanceService {
console.error('Error getting expiring balance:', expiringError);
}
const expiringSoon = expiringData?.reduce((sum, entry) => sum + (entry.amount || 0), 0) || 0;
const expiringSoon =
expiringData?.reduce((sum, entry) => sum + (entry.amount || 0), 0) || 0;
return {
totalBalance: balance,
@@ -116,12 +122,13 @@ export class AccountBalanceService {
async processPeriodicBenefitDistributions(): Promise<void> {
console.info('Processing periodic benefit distributions...');
const { error } = await this.supabase.schema('medreport').rpc('process_periodic_benefit_distributions');
const { error } = await this.supabase
.schema('medreport')
.rpc('process_periodic_benefit_distributions');
if (error) {
console.error('Error processing periodic benefit distributions:', error);
throw new Error('Failed to process periodic benefit distributions');
}
console.info('Periodic benefit distributions processed successfully');
}
}

View File

@@ -1,3 +1,4 @@
import type { Database } from '@kit/supabase/database';
export type AccountBalanceEntry = Database['medreport']['Tables']['account_balance_entries']['Row'];
export type AccountBalanceEntry =
Database['medreport']['Tables']['account_balance_entries']['Row'];

View File

@@ -22,4 +22,5 @@ export type AccountWithParams = Account & {
export type CompanyParams =
Database['medreport']['Tables']['company_params']['Row'];
export type BmiThresholds = Database['medreport']['Tables']['bmi_thresholds']['Row'];
export type BmiThresholds =
Database['medreport']['Tables']['bmi_thresholds']['Row'];

View File

@@ -2,8 +2,8 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import type { ApplicationRole } from '@kit/accounts/types/accounts';
import { Database } from '@kit/supabase/database';
export function createAdminAccountsService(client: SupabaseClient<Database>) {
return new AdminAccountsService(client);
@@ -24,10 +24,7 @@ class AdminAccountsService {
}
}
async updateRole(
accountId: string,
role: ApplicationRole,
) {
async updateRole(accountId: string, role: ApplicationRole) {
const { error } = await this.adminClient
.schema('medreport')
.from('accounts')

View File

@@ -5,7 +5,7 @@ import { redirect } from 'next/navigation';
import { sdk } from '@lib/config';
import medusaError from '@lib/util/medusa-error';
import { HttpTypes } from '@medusajs/types';
import { HttpTypes, StoreCart } from '@medusajs/types';
import {
getAuthHeaders,
@@ -259,18 +259,19 @@ export async function setShippingMethod({
}
export async function initiateMultiPaymentSession(
cart: HttpTypes.StoreCart,
cart: StoreCart,
benefitsAmount: number,
) {
const headers = {
...(await getAuthHeaders()),
};
return sdk.client.fetch<unknown>(`/store/multi-payment`, {
method: 'POST',
body: { cartId: cart.id, benefitsAmount },
headers,
})
return sdk.client
.fetch<unknown>(`/store/multi-payment`, {
method: 'POST',
body: { cartId: cart.id, benefitsAmount },
headers,
})
.then(async (response) => {
console.info('Payment session initiated:', response);
const cartCacheTag = await getCacheTag('carts');
@@ -284,7 +285,11 @@ export async function initiateMultiPaymentSession(
};
})
.catch((e) => {
console.error('Error initiating payment session:', e, JSON.stringify(Object.keys(e)));
console.error(
'Error initiating payment session:',
e,
JSON.stringify(Object.keys(e)),
);
return medusaError(e);
});
}

View File

@@ -30,7 +30,7 @@ export const listCollections = async (
queryParams.limit = queryParams.limit || '100';
queryParams.offset = queryParams.offset || '0';
console.log('SDK_CONFIG: ', SDK_CONFIG.baseUrl);
return sdk.client
.fetch<{ collections: HttpTypes.StoreCollection[]; count: number }>(
'/store/collections',

View File

@@ -1,9 +1,12 @@
import { sdk } from '@lib/config';
import {
renderAllResultsReceivedEmail,
renderFirstResultsReceivedEmail,
renderOrderProcessingEmail,
renderPatientFirstResultsReceivedEmail,
renderPatientFullResultsReceivedEmail,
renderTtoReservationConfirmationEmail,
} from '@kit/email-templates';
import { getLogger } from '@kit/shared/logger';
import { getFullName } from '@kit/shared/utils';
@@ -25,13 +28,15 @@ import {
} from '~/lib/services/mailer.service';
type AnalysisOrder = Database['medreport']['Tables']['analysis_orders']['Row'];
type TtoReservation =
Database['medreport']['Tables']['connected_online_reservation']['Row'];
export function createAnalysisOrderWebhooksService() {
return new AnalysisOrderWebhooksService();
export function createOrderWebhooksService() {
return new OrderWebhooksService();
}
class AnalysisOrderWebhooksService {
private readonly namespace = 'analysis_orders.webhooks';
class OrderWebhooksService {
private readonly namespace = 'orders.webhooks';
async handleStatusChangeWebhook(analysisOrder: AnalysisOrder) {
const logger = await getLogger();
@@ -90,6 +95,128 @@ class AnalysisOrderWebhooksService {
}
}
async handleTtoReservationConfirmationWebhook(
ttoReservation: TtoReservation,
) {
const logger = await getLogger();
const ctx = {
ttoReservationId: ttoReservation.id,
namespace: this.namespace,
};
try {
const userContact = await getUserContactAdmin(ttoReservation.user_id);
if (!userContact.email) {
await createNotificationLog({
action: NotificationAction.TTO_ORDER_CONFIRMATION,
status: 'FAIL',
comment: 'No email found for ' + ttoReservation.user_id,
relatedRecordId: ttoReservation.id,
});
logger.warn(ctx, 'No email found ');
return;
}
const supabaseClient = getSupabaseServerAdminClient();
if (!ttoReservation.medusa_cart_line_item_id) {
await createNotificationLog({
action: NotificationAction.TTO_ORDER_CONFIRMATION,
status: 'FAIL',
comment: 'No cart item id found for ' + ttoReservation.user_id,
relatedRecordId: ttoReservation.id,
});
logger.warn(ctx, 'No cart item id found ');
return;
}
const [
{ data: cartItem },
{ data: location },
{ data: serviceProvider },
] = await Promise.all([
supabaseClient
.from('cart_line_item')
.select('cart_id,title')
.eq('id', ttoReservation.medusa_cart_line_item_id)
.single(),
supabaseClient
.schema('medreport')
.from('connected_online_locations')
.select('name,address')
.eq('sync_id', ttoReservation.location_sync_id || 0)
.single(),
supabaseClient
.schema('medreport')
.from('connected_online_providers')
.select('email,phone_number,name')
.eq('id', ttoReservation.clinic_id)
.single(),
]);
if (!cartItem) {
await createNotificationLog({
action: NotificationAction.TTO_ORDER_CONFIRMATION,
status: 'FAIL',
comment: 'No medusa cart item found for ' + ttoReservation.user_id,
relatedRecordId: ttoReservation.medusa_cart_line_item_id,
});
logger.warn(ctx, 'No cart item found ');
return;
}
const [{ data: orderCart }] = await Promise.all([
supabaseClient
.from('order_cart')
.select('order_id')
.eq('cart_id', cartItem.cart_id)
.single(),
]);
if (!orderCart) {
await createNotificationLog({
action: NotificationAction.TTO_ORDER_CONFIRMATION,
status: 'FAIL',
comment: 'No medusa order cart found for ' + ttoReservation.user_id,
relatedRecordId: cartItem.cart_id,
});
logger.warn(ctx, 'No order cart found ');
return;
}
await sendEmailFromTemplate(
renderTtoReservationConfirmationEmail,
{
language: userContact.preferred_locale ?? 'et',
recipientName: getFullName(userContact.name, userContact.last_name),
startTime: ttoReservation.start_time,
orderName: cartItem.title,
locationName: location?.name,
locationAddress: location?.address,
orderId: orderCart.order_id,
serviceProviderName: serviceProvider?.name,
serviceProviderEmail: serviceProvider?.email,
serviceProviderPhone: serviceProvider?.phone_number,
},
userContact.email,
);
return createNotificationLog({
action: NotificationAction.TTO_ORDER_CONFIRMATION,
status: 'SUCCESS',
relatedRecordId: orderCart.order_id,
});
} catch (e: any) {
createNotificationLog({
action: NotificationAction.TTO_ORDER_CONFIRMATION,
status: 'FAIL',
comment: e?.message,
relatedRecordId: ttoReservation.id,
});
logger.error(
ctx,
`Error while processing tto reservation: ${JSON.stringify(e)}`,
);
}
}
async sendProcessingNotification(analysisOrder: AnalysisOrder) {
const logger = await getLogger();
const supabase = getSupabaseServerAdminClient();

View File

@@ -6,6 +6,7 @@ import { ColumnDef } from '@tanstack/react-table';
import { Ellipsis } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatCurrency } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
@@ -20,7 +21,6 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { Trans } from '@kit/ui/trans';
import { formatCurrency } from '@kit/shared/utils';
import { RemoveMemberDialog } from './remove-member-dialog';
import { RoleBadge } from './role-badge';
@@ -135,7 +135,10 @@ function useGetColumns(
}[];
},
): ColumnDef<Members[0]>[] {
const { t, i18n: { language } } = useTranslation('teams');
const {
t,
i18n: { language },
} = useTranslation('teams');
return useMemo(
() => [
@@ -183,7 +186,7 @@ function useGetColumns(
header: t('distributedBenefitsAmount'),
cell: ({ row }) => {
const benefitAmount = params.membersBenefitsUsage.find(
(usage) => usage.personal_account_id === row.original.id
(usage) => usage.personal_account_id === row.original.id,
)?.benefit_amount;
if (typeof benefitAmount !== 'number') {
return '-';
@@ -203,7 +206,11 @@ function useGetColumns(
const isPrimaryOwner = primary_owner_user_id === user_id;
return (
<span className={'flex items-center space-x-1 flex-wrap space-y-1 sm:space-y-0 sm:flex-nowrap'}>
<span
className={
'flex flex-wrap items-center space-y-1 space-x-1 sm:flex-nowrap sm:space-y-0'
}
>
<RoleBadge role={role} />
<If condition={isPrimaryOwner}>

View File

@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
import { z } from 'zod';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
import { enhanceAction } from '@kit/next/actions';
import { createNotificationsApi } from '@kit/notifications/api';
import { getLogger } from '@kit/shared/logger';
@@ -18,7 +19,6 @@ import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
import { createAccountInvitationsService } from '../services/account-invitations.service';
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
/**
* @name createInvitationsAction

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import type { Account } from '@kit/accounts/types/accounts';
import { getLogger } from '@kit/shared/logger';
export function createAccountWebhooksService() {
return new AccountWebhooksService();

View File

@@ -4,7 +4,10 @@ import type { UuringuVastus } from '@kit/shared/types/medipost-analysis';
import { toArray } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database';
import type { AnalysisOrder, AnalysisOrderStatus } from '../types/analysis-orders';
import type {
AnalysisOrder,
AnalysisOrderStatus,
} from '../types/analysis-orders';
import type {
AnalysisResultDetailsElement,
AnalysisResultDetailsMapped,
@@ -462,8 +465,10 @@ class UserAnalysesApi {
}) {
const orderIdParam = orderId;
const medusaOrderIdParam = medusaOrderId;
console.info(`Updating order id=${orderId} medusaOrderId=${medusaOrderId} status=${orderStatus}`);
console.info(
`Updating order id=${orderId} medusaOrderId=${medusaOrderId} status=${orderStatus}`,
);
if (!orderIdParam && !medusaOrderIdParam) {
throw new Error('Either orderId or medusaOrderId must be provided');
}