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

@@ -48,6 +48,12 @@ class DatabaseWebhookRouterService {
return this.handleAnalysisOrdersWebhook(payload);
}
case 'connected_online_reservation': {
const payload = body as RecordChange<typeof body.table>;
return this.handleTtoOrdersWebhook(payload);
}
default: {
return;
}
@@ -100,13 +106,27 @@ class DatabaseWebhookRouterService {
return;
}
const { createAnalysisOrderWebhooksService } = await import(
'@kit/notifications/webhooks/analysis-order-notifications.service'
const { createOrderWebhooksService } = await import(
'@kit/notifications/webhooks/order-notifications.service'
);
const service = createAnalysisOrderWebhooksService();
const service = createOrderWebhooksService();
return service.handleStatusChangeWebhook(record);
}
}
private async handleTtoOrdersWebhook(
body: RecordChange<'connected_online_reservation'>,
) {
if (body.type === 'UPDATE' && body.record) {
const { createOrderWebhooksService } = await import(
'@kit/notifications/webhooks/order-notifications.service'
);
const service = createOrderWebhooksService();
return service.handleTtoReservationConfirmationWebhook(body.record);
}
}
}

View File

@@ -4,17 +4,20 @@
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"email:dev": "email dev --dir src/emails --port 3001"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@react-email/components": "0.0.41"
"@react-email/components": "0.0.41",
"react-email": "4.2.12"
},
"devDependencies": {
"@kit/i18n": "workspace:*",
"@kit/tsconfig": "workspace:*"
"@kit/tsconfig": "workspace:*",
"@react-email/preview-server": "4.2.12"
},
"typesVersions": {
"*": {

View File

@@ -6,22 +6,28 @@ import { EmailFooter } from './footer';
export default function CommonFooter({ t }: { t: TFunction }) {
const namespace = 'common';
const lines = [
t(`${namespace}:footer.lines1`),
t(`${namespace}:footer.lines2`),
t(`${namespace}:footer.lines3`),
t(`${namespace}:footer.lines4`),
];
return (
<EmailFooter>
{lines.map((line, index) => (
<Text
key={index}
className="text-[16px] leading-[24px] text-[#242424]"
dangerouslySetInnerHTML={{ __html: line }}
/>
))}
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:footer.title`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:footer.emailField`)}{' '}
<a href={`mailto:${t(`${namespace}:footer.title`)}`}>
{t(`${namespace}:footer.email`)}
</a>
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:footer.phoneField`)}{' '}
<a href={`tel:${t(`${namespace}:footer.phone`)}`}>
{t(`${namespace}:footer.phone`)}
</a>
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
<a href={`https://${t(`${namespace}:footer.website`)}`}>
{t(`${namespace}:footer.website`)}
</a>
</Text>
</EmailFooter>
);
}

View File

@@ -1,11 +1,11 @@
import { Container, Text } from '@react-email/components';
import { Container, Section } from '@react-email/components';
export function EmailFooter(props: React.PropsWithChildren) {
return (
<Container className="mt-[24px]">
<Text className="px-4 text-[12px] leading-[20px] text-gray-300">
<Section className="px-4 text-[12px] leading-[20px] text-gray-300">
{props.children}
</Text>
</Section>
</Container>
);
}

View File

@@ -0,0 +1,8 @@
import React from 'react';
const Email = () => {
return <div>Email</div>;
};
export default Email;
Email.PreviewProps = {};

View File

@@ -0,0 +1,137 @@
import {
Body,
Head,
Html,
Link,
Preview,
Tailwind,
Text,
render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
export async function renderTtoReservationConfirmationEmail({
language,
recipientName,
startTime,
orderName,
locationName,
locationAddress,
orderId,
serviceProviderName,
serviceProviderEmail,
serviceProviderPhone,
}: {
language: string;
recipientName: string;
startTime: string;
orderName: string;
locationName?: string;
locationAddress?: string | null;
orderId: string;
serviceProviderName?: string;
serviceProviderEmail?: string | null;
serviceProviderPhone?: string | null;
}) {
const namespace = 'tto-reservation-confirmation-email';
const { t } = await initializeEmailI18n({
language,
namespace: [namespace, 'common'],
});
const previewText = t(`${namespace}:previewText`, {
reservation: orderName,
});
const subject = t(`${namespace}:subject`, {
reservation: orderName,
});
const email = (
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailContent>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:helloName`, { name: recipientName })}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:thankYou`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{orderName}, {new Date(startTime).toLocaleString()},{' '}
{locationAddress}, {locationName}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
<Link
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
>
{t(`${namespace}:viewOrder`)}
</Link>
</Text>
<Text className="mt-10 text-[16px] leading-[24px] text-[#242424]">
{serviceProviderName}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:customerSupport`)}
<Link href={`tel:${serviceProviderPhone})}`}>
{serviceProviderPhone}
</Link>
</Text>
<Link href={`mailto:${serviceProviderEmail}`}>
{serviceProviderEmail}
</Link>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>
);
const html = await render(email);
return {
html,
subject,
email,
};
}
const PreviewEmail = async () => {
const { email } = await renderTtoReservationConfirmationEmail({
language: 'et',
recipientName: 'John Doe',
startTime: '2025-09-27 05:45:00+00',
orderName: 'Hambaarst',
locationName: 'Tallinn',
locationAddress: 'Põhja puiestee, 2/3A',
orderId: '123123',
serviceProviderName: 'Dentas OÜ',
serviceProviderEmail: 'email@example.ee',
serviceProviderPhone: '+372111111',
});
return email;
};
export default PreviewEmail;
PreviewEmail.PreviewProps = {};

View File

@@ -11,3 +11,4 @@ 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';
export * from './emails/tto-reservation-confirmation.email';

View File

@@ -0,0 +1,8 @@
{
"previewText": "Your booking is confirmed! - {{reservation}}",
"subject": "Your booking is confirmed! - {{reservation}}",
"helloName": "Hello, {{name}}!",
"thankYou": "Thank you for your order!",
"viewOrder": "See booking",
"customerSupport": "Customer support: "
}

View File

@@ -1,9 +1,11 @@
{
"footer": {
"lines1": "MedReport",
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
"title": "MedReport",
"emailField": "E-mail:",
"email": "info@medreport.ee",
"phoneField": "Klienditugi:",
"phone": "+372 5887 1517",
"website": "www.medreport.ee"
},
"helloName": "Tere, {{name}}",
"hello": "Tere"

View File

@@ -0,0 +1,8 @@
{
"previewText": "Teie broneering on kinnitatud! - {{reservation}}",
"subject": "Teie broneering on kinnitatud! - {{reservation}}",
"helloName": "Tere, {{name}}!",
"thankYou": "Täname tellimuse eest!",
"viewOrder": "Vaata broneeringut",
"customerSupport": "Klienditugi: "
}

View File

@@ -0,0 +1,8 @@
{
"previewText": "Your booking is confirmed! - {{reservation}}",
"subject": "Your booking is confirmed! - {{reservation}}",
"helloName": "Hello, {{name}}!",
"thankYou": "Thank you for your order!",
"viewOrder": "See booking",
"customerSupport": "Customer support: "
}

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

View File

@@ -2,8 +2,8 @@ import { Euro, LayoutDashboard, Settings, Users } from 'lucide-react';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
import pathsConfig from './paths.config';
import featureFlagsConfig from './feature-flags.config';
import pathsConfig from './paths.config';
const iconClasses = 'w-4';
@@ -23,10 +23,10 @@ const getRoutes = (account: string) => [
},
featureFlagsConfig.enableTeamAccountBilling
? {
label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <Euro className={iconClasses} />,
}
label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <Euro className={iconClasses} />,
}
: undefined,
{
label: 'common:routes.companySettings',