MED-88: add doctor email notifications (#65)
* MED-88: add doctor email notifications * add logging, send open jobs notification on partial analysis response * update permissions * fix import, permissions * casing, let email be null * unused import
This commit is contained in:
@@ -4,14 +4,15 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
|
import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
|
||||||
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
|
|
||||||
import { CompanySubmitData } from '@/lib/types/company';
|
import { CompanySubmitData } from '@/lib/types/company';
|
||||||
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
|
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { renderCompanyOfferEmail } from '@kit/email-templates';
|
||||||
|
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
|
||||||
import { FormItem } from '@kit/ui/form';
|
import { FormItem } from '@kit/ui/form';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Label } from '@kit/ui/label';
|
import { Label } from '@kit/ui/label';
|
||||||
@@ -39,7 +40,14 @@ const CompanyOfferForm = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendCompanyOfferEmail(data, language)
|
sendEmailFromTemplate(
|
||||||
|
renderCompanyOfferEmail,
|
||||||
|
{
|
||||||
|
companyData: data,
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
process.env.CONTACT_EMAIL!,
|
||||||
|
)
|
||||||
.then(() => router.push('/company-offer/success'))
|
.then(() => router.push('/company-offer/success'))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
23
app/api/job/handler/send-open-jobs-emails.ts
Normal file
23
app/api/job/handler/send-open-jobs-emails.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { renderNewJobsAvailableEmail } from '@kit/email-templates';
|
||||||
|
|
||||||
|
import { getDoctorAccounts } from '~/lib/services/account.service';
|
||||||
|
import { getOpenJobAnalysisResponseIds } from '~/lib/services/doctor-jobs.service';
|
||||||
|
import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
|
||||||
|
|
||||||
|
export default async function sendOpenJobsEmails() {
|
||||||
|
const analysisResponseIds = await getOpenJobAnalysisResponseIds();
|
||||||
|
|
||||||
|
const doctorAccounts = await getDoctorAccounts();
|
||||||
|
const doctorEmails: string[] = doctorAccounts
|
||||||
|
.map(({ email }) => email)
|
||||||
|
.filter((email): email is string => !!email);
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderNewJobsAvailableEmail,
|
||||||
|
{
|
||||||
|
language: 'et',
|
||||||
|
analysisResponseIds,
|
||||||
|
},
|
||||||
|
doctorEmails,
|
||||||
|
);
|
||||||
|
}
|
||||||
53
app/api/job/send-open-jobs-emails/route.ts
Normal file
53
app/api/job/send-open-jobs-emails/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NotificationAction,
|
||||||
|
createNotificationLog,
|
||||||
|
} from '~/lib/services/audit/notificationEntries.service';
|
||||||
|
import loadEnv from '../handler/load-env';
|
||||||
|
import sendOpenJobsEmails from '../handler/send-open-jobs-emails';
|
||||||
|
import validateApiKey from '../handler/validate-api-key';
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendOpenJobsEmails();
|
||||||
|
console.info(
|
||||||
|
'Successfully sent out open job notification emails to doctors.',
|
||||||
|
);
|
||||||
|
await createNotificationLog({
|
||||||
|
action: NotificationAction.NEW_JOBS_ALERT,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Successfully sent out open job notification emails to doctors.',
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(
|
||||||
|
'Error sending out open job notification emails to doctors.',
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
await createNotificationLog({
|
||||||
|
action: NotificationAction.NEW_JOBS_ALERT,
|
||||||
|
status: 'FAIL',
|
||||||
|
comment: e?.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Failed to send out open job notification emails to doctors.',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
|
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
doctorAnalysisFeedbackFormSchema,
|
doctorAnalysisFeedbackFormSchema,
|
||||||
} from '@kit/doctor/schema/doctor-analysis.schema';
|
} from '@kit/doctor/schema/doctor-analysis.schema';
|
||||||
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
|
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
|
||||||
|
import {
|
||||||
|
useCurrentLocaleLanguageNames
|
||||||
|
} from '@kit/shared/hooks';
|
||||||
import { getFullName } from '@kit/shared/utils';
|
import { getFullName } from '@kit/shared/utils';
|
||||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -57,6 +61,8 @@ export default function AnalysisView({
|
|||||||
|
|
||||||
const { data: user } = useUser();
|
const { data: user } = useUser();
|
||||||
|
|
||||||
|
const languageNames = useCurrentLocaleLanguageNames();
|
||||||
|
|
||||||
const isInProgress = !!(
|
const isInProgress = !!(
|
||||||
!!feedback?.status &&
|
!!feedback?.status &&
|
||||||
feedback?.doctor_user_id &&
|
feedback?.doctor_user_id &&
|
||||||
@@ -191,6 +197,12 @@ export default function AnalysisView({
|
|||||||
<Trans i18nKey="doctor:email" />
|
<Trans i18nKey="doctor:email" />
|
||||||
</div>
|
</div>
|
||||||
<div>{patient.email}</div>
|
<div>{patient.email}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="common:language" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="xs:hidden block">
|
<div className="xs:hidden block">
|
||||||
<DoctorJobSelect
|
<DoctorJobSelect
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Eye } from 'lucide-react';
|
|||||||
import { getResultSetName } from '@kit/doctor/lib/helpers';
|
import { getResultSetName } from '@kit/doctor/lib/helpers';
|
||||||
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
|
||||||
import { getFullName } from '@kit/shared/utils';
|
import { getFullName } from '@kit/shared/utils';
|
||||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -23,7 +24,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@kit/ui/table';
|
} from '@kit/ui/table';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import DoctorJobSelect from './doctor-job-select';
|
import DoctorJobSelect from './doctor-job-select';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
|
|
||||||
export default function ResultsTable({
|
export default function ResultsTable({
|
||||||
results = [],
|
results = [],
|
||||||
@@ -58,6 +61,8 @@ export default function ResultsTable({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const { data: currentUser } = useUser();
|
const { data: currentUser } = useUser();
|
||||||
|
|
||||||
|
const languageNames = useCurrentLocaleLanguageNames();
|
||||||
|
|
||||||
const fetchPage = async (page: number) => {
|
const fetchPage = async (page: number) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await fetchAction({
|
const result = await fetchAction({
|
||||||
@@ -116,6 +121,9 @@ export default function ResultsTable({
|
|||||||
<TableHead className="w-20">
|
<TableHead className="w-20">
|
||||||
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
|
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.language" />
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-20">
|
<TableHead className="w-20">
|
||||||
<Trans i18nKey="doctor:resultsTable.assignedTo" />
|
<Trans i18nKey="doctor:resultsTable.assignedTo" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -179,6 +187,11 @@ export default function ResultsTable({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{capitalize(
|
||||||
|
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DoctorJobSelect
|
<DoctorJobSelect
|
||||||
doctorUserId={result.doctor?.primary_owner_user_id}
|
doctorUserId={result.doctor?.primary_owner_user_id}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ async function AnalysisPage({
|
|||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { id: analysisResponseId } = await params;
|
const { id: analysisOrderId } = await params;
|
||||||
const analysisResultDetails = await loadResult(Number(analysisResponseId));
|
const analysisResultDetails = await loadResult(Number(analysisOrderId));
|
||||||
|
|
||||||
if (!analysisResultDetails) {
|
if (!analysisResultDetails) {
|
||||||
return null;
|
return null;
|
||||||
@@ -28,7 +28,7 @@ async function AnalysisPage({
|
|||||||
if (analysisResultDetails) {
|
if (analysisResultDetails) {
|
||||||
await createDoctorPageViewLog({
|
await createDoctorPageViewLog({
|
||||||
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
|
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
|
||||||
recordKey: analysisResponseId,
|
recordKey: analysisOrderId,
|
||||||
dataOwnerUserId: analysisResultDetails.patient.userId,
|
dataOwnerUserId: analysisResultDetails.patient.userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -50,3 +50,5 @@ async function AnalysisPage({
|
|||||||
|
|
||||||
export default DoctorGuard(AnalysisPage);
|
export default DoctorGuard(AnalysisPage);
|
||||||
const loadResult = cache(getAnalysisResultsForDoctor);
|
const loadResult = cache(getAnalysisResultsForDoctor);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,43 @@ export async function getAccountAdmin({
|
|||||||
|
|
||||||
return data as unknown as AccountWithMemberships;
|
return data as unknown as AccountWithMemberships;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDoctorAccounts() {
|
||||||
|
const { data } = await getSupabaseServerAdminClient()
|
||||||
|
.schema('medreport')
|
||||||
|
.from('accounts')
|
||||||
|
.select('id, email, name, last_name, preferred_locale')
|
||||||
|
.eq('is_personal_account', true)
|
||||||
|
.eq('application_role', 'doctor')
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
return data?.map(({ id, email, name, last_name, preferred_locale }) => ({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
lastName: last_name,
|
||||||
|
preferredLocale: preferred_locale,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssignedDoctorAccount(analysisOrderId: number) {
|
||||||
|
const { data: doctorUser } = await getSupabaseServerAdminClient()
|
||||||
|
.schema('medreport')
|
||||||
|
.from('doctor_analysis_feedback')
|
||||||
|
.select('doctor_user_id')
|
||||||
|
.eq('analysis_order_id', analysisOrderId)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
const doctorData = doctorUser[0];
|
||||||
|
if (!doctorData || !doctorData.doctor_user_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await getSupabaseServerAdminClient()
|
||||||
|
.schema('medreport')
|
||||||
|
.from('accounts')
|
||||||
|
.select('email')
|
||||||
|
.eq('primary_owner_user_id', doctorData.doctor_user_id);
|
||||||
|
|
||||||
|
return { email: data?.[0]?.email };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
export enum NotificationAction {
|
export enum NotificationAction {
|
||||||
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
|
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
|
||||||
|
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
|
||||||
|
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotificationLog = async ({
|
export const createNotificationLog = async ({
|
||||||
@@ -17,7 +19,7 @@ export const createNotificationLog = async ({
|
|||||||
relatedRecordId?: string | number;
|
relatedRecordId?: string | number;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerAdminClient();
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.schema('audit')
|
.schema('audit')
|
||||||
@@ -30,6 +32,6 @@ export const createNotificationLog = async ({
|
|||||||
})
|
})
|
||||||
.throwOnError();
|
.throwOnError();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to insert doctor page view log', error);
|
console.error('Failed to insert doctor notification log', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
32
lib/services/doctor-jobs.service.ts
Normal file
32
lib/services/doctor-jobs.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
|
async function getAssignedOrderIds() {
|
||||||
|
const supabase = getSupabaseServerAdminClient();
|
||||||
|
|
||||||
|
const { data: assignedOrderIds } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('doctor_analysis_feedback')
|
||||||
|
.select('analysis_order_id')
|
||||||
|
.not('doctor_user_id', 'is', null)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
return assignedOrderIds?.map((f) => f.analysis_order_id) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpenJobAnalysisResponseIds() {
|
||||||
|
const supabase = getSupabaseServerAdminClient();
|
||||||
|
const assignedIds = await getAssignedOrderIds();
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('analysis_responses')
|
||||||
|
.select('id, analysis_order_id')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (assignedIds.length > 0) {
|
||||||
|
query = query.not('analysis_order_id', 'in', `(${assignedIds.join(',')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: analysisResponses } = await query.throwOnError();
|
||||||
|
return analysisResponses?.map(({ id }) => id) || [];
|
||||||
|
}
|
||||||
@@ -1,50 +1,41 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { CompanySubmitData } from '@/lib/types/company';
|
import { toArray } from '@/lib/utils';
|
||||||
import { emailSchema } from '@/lib/validations/email.schema';
|
|
||||||
|
|
||||||
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
|
|
||||||
import { getMailer } from '@kit/mailers';
|
import { getMailer } from '@kit/mailers';
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
|
||||||
export const sendDoctorSummaryCompletedEmail = async (
|
import { emailSchema } from '~/lib/validations/email.schema';
|
||||||
language: string,
|
|
||||||
recipientName: string,
|
|
||||||
recipientEmail: string,
|
|
||||||
orderNr: string,
|
|
||||||
orderId: number,
|
|
||||||
) => {
|
|
||||||
const { html, subject } = await renderDoctorSummaryReceivedEmail({
|
|
||||||
language,
|
|
||||||
recipientName,
|
|
||||||
recipientEmail,
|
|
||||||
orderNr,
|
|
||||||
orderId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmail({
|
type EmailTemplate = {
|
||||||
subject,
|
html: string;
|
||||||
html,
|
subject: string;
|
||||||
to: recipientEmail,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendCompanyOfferEmail = async (
|
type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
|
||||||
data: CompanySubmitData,
|
|
||||||
language: string,
|
|
||||||
) => {
|
|
||||||
const { renderCompanyOfferEmail } = await import('@kit/email-templates');
|
|
||||||
const { html, subject } = await renderCompanyOfferEmail({
|
|
||||||
language,
|
|
||||||
companyData: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmail({
|
export const sendEmailFromTemplate = async <T>(
|
||||||
subject,
|
renderer: EmailRenderer<T>,
|
||||||
html,
|
templateParams: T,
|
||||||
to: process.env.CONTACT_EMAIL || '',
|
recipients: string | string[],
|
||||||
});
|
) => {
|
||||||
|
const { html, subject } = await renderer(templateParams);
|
||||||
|
|
||||||
|
const recipientsArray = toArray(recipients);
|
||||||
|
if (!recipientsArray.length) {
|
||||||
|
throw new Error('No valid email recipients provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailPromises = recipientsArray.map((email) =>
|
||||||
|
sendEmail({
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
to: email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(emailPromises);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendEmail = enhanceAction(
|
export const sendEmail = enhanceAction(
|
||||||
@@ -53,7 +44,7 @@ export const sendEmail = enhanceAction(
|
|||||||
const log = await getLogger();
|
const log = await getLogger();
|
||||||
|
|
||||||
if (!process.env.EMAIL_USER) {
|
if (!process.env.EMAIL_USER) {
|
||||||
log.error('Sending email failed, as no sender found in env.')
|
log.error('Sending email failed, as no sender was found in env.');
|
||||||
throw new Error('No email user configured');
|
throw new Error('No email user configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
renderAllResultsReceivedEmail,
|
||||||
|
renderFirstResultsReceivedEmail,
|
||||||
|
} from '@kit/email-templates';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAssignedDoctorAccount,
|
||||||
|
getDoctorAccounts,
|
||||||
|
} from '../../../../../lib/services/account.service';
|
||||||
|
import {
|
||||||
|
NotificationAction,
|
||||||
|
createNotificationLog,
|
||||||
|
} from '../../../../../lib/services/audit/notificationEntries.service';
|
||||||
|
import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service';
|
||||||
import { RecordChange, Tables } from '../record-change.type';
|
import { RecordChange, Tables } from '../record-change.type';
|
||||||
|
|
||||||
export function createDatabaseWebhookRouterService(
|
export function createDatabaseWebhookRouterService(
|
||||||
@@ -42,6 +55,12 @@ class DatabaseWebhookRouterService {
|
|||||||
return this.handleAccountsWebhook(payload);
|
return this.handleAccountsWebhook(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'analysis_orders': {
|
||||||
|
const payload = body as RecordChange<typeof body.table>;
|
||||||
|
|
||||||
|
return this.handleAnalysisOrdersWebhook(payload);
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,4 +102,69 @@ class DatabaseWebhookRouterService {
|
|||||||
return service.handleAccountDeletedWebhook(body.old_record);
|
return service.handleAccountDeletedWebhook(body.old_record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleAnalysisOrdersWebhook(
|
||||||
|
body: RecordChange<'analysis_orders'>,
|
||||||
|
) {
|
||||||
|
if (body.type === 'UPDATE' && body.record && body.old_record) {
|
||||||
|
const { record, old_record } = body;
|
||||||
|
|
||||||
|
if (record.status === old_record.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let action;
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
analysisOrderId: record.id,
|
||||||
|
language: 'et',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
|
||||||
|
action = NotificationAction.NEW_JOBS_ALERT;
|
||||||
|
|
||||||
|
const doctorAccounts = await getDoctorAccounts();
|
||||||
|
const doctorEmails: string[] = doctorAccounts
|
||||||
|
.map(({ email }) => email)
|
||||||
|
.filter((email): email is string => !!email);
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderFirstResultsReceivedEmail,
|
||||||
|
data,
|
||||||
|
doctorEmails,
|
||||||
|
);
|
||||||
|
} else if (record.status === 'FULL_ANALYSIS_RESPONSE') {
|
||||||
|
action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT;
|
||||||
|
const doctorAccount = await getAssignedDoctorAccount(record.id);
|
||||||
|
const assignedDoctorEmail = doctorAccount?.email;
|
||||||
|
|
||||||
|
if (!assignedDoctorEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderAllResultsReceivedEmail,
|
||||||
|
data,
|
||||||
|
assignedDoctorEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
await createNotificationLog({
|
||||||
|
action,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
relatedRecordId: record.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (action)
|
||||||
|
await createNotificationLog({
|
||||||
|
action,
|
||||||
|
status: 'FAIL',
|
||||||
|
comment: e?.message,
|
||||||
|
relatedRecordId: record.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
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 { EmailButton } from '../components/email-button';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderAllResultsReceivedEmail({
|
||||||
|
language,
|
||||||
|
analysisOrderId,
|
||||||
|
}: {
|
||||||
|
language: string;
|
||||||
|
analysisOrderId: number;
|
||||||
|
}) {
|
||||||
|
const namespace = 'all-results-received-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`);
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`);
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`)}
|
||||||
|
</Text>
|
||||||
|
<EmailButton
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`)}
|
||||||
|
</EmailButton>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,14 +19,12 @@ import { initializeEmailI18n } from '../lib/i18n';
|
|||||||
|
|
||||||
export async function renderDoctorSummaryReceivedEmail({
|
export async function renderDoctorSummaryReceivedEmail({
|
||||||
language,
|
language,
|
||||||
recipientEmail,
|
|
||||||
recipientName,
|
recipientName,
|
||||||
orderNr,
|
orderNr,
|
||||||
orderId,
|
orderId,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
recipientEmail: string;
|
|
||||||
orderNr: string;
|
orderNr: string;
|
||||||
orderId: number;
|
orderId: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -37,8 +35,6 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
namespace: [namespace, 'common'],
|
namespace: [namespace, 'common'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const to = recipientEmail;
|
|
||||||
|
|
||||||
const previewText = t(`${namespace}:previewText`, {
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
orderNr,
|
orderNr,
|
||||||
});
|
});
|
||||||
@@ -92,6 +88,5 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
return {
|
return {
|
||||||
html,
|
html,
|
||||||
subject,
|
subject,
|
||||||
to,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
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 { EmailButton } from '../components/email-button';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderFirstResultsReceivedEmail({
|
||||||
|
language,
|
||||||
|
analysisOrderId,
|
||||||
|
}: {
|
||||||
|
language: string;
|
||||||
|
analysisOrderId: number;
|
||||||
|
}) {
|
||||||
|
const namespace = 'first-results-received-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`);
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`);
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:resultsReceivedForOrders`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<EmailButton
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`)}
|
||||||
|
</EmailButton>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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 renderNewJobsAvailableEmail({
|
||||||
|
language,
|
||||||
|
analysisResponseIds,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
analysisResponseIds: number[];
|
||||||
|
}) {
|
||||||
|
const namespace = 'new-jobs-available-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:resultsReceivedForOrders`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<ul className="list-none text-[16px] leading-[24px]">
|
||||||
|
{analysisResponseIds.map((analysisResponseId, index) => (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
key={analysisResponseId}
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`, { nr: index + 1 })}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/open-jobs`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export async function renderOtpEmail(props: Props) {
|
|||||||
|
|
||||||
<Section className="mb-[16px] mt-[16px] text-center">
|
<Section className="mb-[16px] mt-[16px] text-center">
|
||||||
<Button className={'w-full rounded bg-neutral-950 text-center'}>
|
<Button className={'w-full rounded bg-neutral-950 text-center'}>
|
||||||
<Text className="text-[16px] font-medium font-semibold leading-[16px] text-white">
|
<Text className="text-[16px] font-semibold leading-[16px] text-white">
|
||||||
{props.otp}
|
{props.otp}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
|
|
||||||
import { BodyStyle } from '../components/body-style';
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
import { EmailContent } from '../components/content';
|
import { EmailContent } from '../components/content';
|
||||||
import { EmailHeader } from '../components/header';
|
import { EmailHeader } from '../components/header';
|
||||||
import { EmailHeading } from '../components/heading';
|
import { EmailHeading } from '../components/heading';
|
||||||
import { EmailWrapper } from '../components/wrapper';
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
import { initializeEmailI18n } from '../lib/i18n';
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
import CommonFooter from '../components/common-footer';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
analysisPackageName: string;
|
analysisPackageName: string;
|
||||||
@@ -31,7 +31,10 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
|||||||
namespace: [namespace, 'common'],
|
namespace: [namespace, 'common'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewText = t(`${namespace}:previewText`);
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
|
analysisPackageName: props.analysisPackageName,
|
||||||
|
});
|
||||||
|
|
||||||
const subject = t(`${namespace}:subject`, {
|
const subject = t(`${namespace}:subject`, {
|
||||||
analysisPackageName: props.analysisPackageName,
|
analysisPackageName: props.analysisPackageName,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ export * from './emails/otp.email';
|
|||||||
export * from './emails/company-offer.email';
|
export * from './emails/company-offer.email';
|
||||||
export * from './emails/synlab.email';
|
export * from './emails/synlab.email';
|
||||||
export * from './emails/doctor-summary-received.email';
|
export * from './emails/doctor-summary-received.email';
|
||||||
|
export * from './emails/new-jobs-available.email';
|
||||||
|
export * from './emails/first-results-received.email';
|
||||||
|
export * from './emails/all-results-received.email';
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"previewText": "All analysis results have been received",
|
||||||
|
"subject": "All patient analysis results have been received",
|
||||||
|
"openOrdersHeading": "Review the results and prepare a summary:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
|
||||||
|
"hello": "Hello"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "First analysis responses received",
|
||||||
|
"subject": "New job - first analysis responses received",
|
||||||
|
"resultsReceivedForOrders": "New job available to claim",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see available jobs by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "New jobs available",
|
||||||
|
"subject": "Please write a summary",
|
||||||
|
"resultsReceivedForOrders": "Please review the results and write a summary.",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "Open job {{nr}}",
|
||||||
|
"ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"previewText": "Kõik analüüside vastused on saabunud",
|
||||||
|
"subject": "Patsiendi kõikide analüüside vastused on saabunud",
|
||||||
|
"openOrdersHeading": "Vaata tulemusi ja kirjuta kokkuvõte:",
|
||||||
|
"linkText": "Vaata tulemusi",
|
||||||
|
"ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:",
|
||||||
|
"hello": "Tere"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "Saabusid esimesed analüüside vastused",
|
||||||
|
"subject": "Uus töö - saabusid esimesed analüüside vastused",
|
||||||
|
"resultsReceivedForOrders": "Patsiendile saabusid esimesed analüüside vastused.",
|
||||||
|
"openOrdersHeading": "Vaata siit:",
|
||||||
|
"linkText": "Vaata tulemusi",
|
||||||
|
"ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:",
|
||||||
|
"hello": "Tere"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "Palun koosta kokkuvõte",
|
||||||
|
"subject": "Palun koosta kokkuvõte",
|
||||||
|
"resultsReceivedForOrders": "Palun vaata tulemused üle ja kirjuta kokkuvõte.",
|
||||||
|
"openOrdersHeading": "Vaata siit:",
|
||||||
|
"linkText": "Töö {{nr}}",
|
||||||
|
"ifLinksDisabled": "Kui lingid ei tööta, näed vabasid töid sellelt aadressilt:",
|
||||||
|
"hello": "Tere"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"previewText": "All analysis results have been received",
|
||||||
|
"subject": "All patient analysis results have been received",
|
||||||
|
"openOrdersHeading": "Review the results and prepare a summary:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
|
||||||
|
"hello": "Hello"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "First analysis responses received",
|
||||||
|
"subject": "New job - first analysis responses received",
|
||||||
|
"resultsReceivedForOrders": "New job available to claim",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "New jobs available",
|
||||||
|
"subject": "Please write a summary",
|
||||||
|
"resultsReceivedForOrders": "Please review the results and write a summary.",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "Open job {{nr}}",
|
||||||
|
"ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ export const PatientSchema = z.object({
|
|||||||
email: z.string().nullable(),
|
email: z.string().nullable(),
|
||||||
height: z.number().optional().nullable(),
|
height: z.number().optional().nullable(),
|
||||||
weight: z.number().optional().nullable(),
|
weight: z.number().optional().nullable(),
|
||||||
|
preferred_locale: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export type Patient = z.infer<typeof PatientSchema>;
|
export type Patient = z.infer<typeof PatientSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const AccountSchema = z.object({
|
|||||||
last_name: z.string().nullable(),
|
last_name: z.string().nullable(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
primary_owner_user_id: z.string(),
|
primary_owner_user_id: z.string(),
|
||||||
|
preferred_locale: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export type Account = z.infer<typeof AccountSchema>;
|
export type Account = z.infer<typeof AccountSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import 'server-only';
|
|||||||
|
|
||||||
import { isBefore } from 'date-fns';
|
import { isBefore } from 'date-fns';
|
||||||
|
|
||||||
|
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
|
||||||
import { getFullName } from '@kit/shared/utils';
|
import { getFullName } from '@kit/shared/utils';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { sendDoctorSummaryCompletedEmail } from '../../../../../../../lib/services/mailer.service';
|
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service';
|
||||||
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
|
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
|
||||||
import {
|
import {
|
||||||
AnalysisResponseBase,
|
AnalysisResponseBase,
|
||||||
@@ -54,7 +55,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
|||||||
supabase
|
supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('name, last_name, id, primary_owner_user_id')
|
.select('name, last_name, id, primary_owner_user_id, preferred_locale')
|
||||||
.in('primary_owner_user_id', userIds),
|
.in('primary_owner_user_id', userIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
|||||||
? await supabase
|
? await supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('name, last_name, id, primary_owner_user_id')
|
.select('name, last_name, id, primary_owner_user_id, preferred_locale')
|
||||||
.in('primary_owner_user_id', doctorUserIds)
|
.in('primary_owner_user_id', doctorUserIds)
|
||||||
: { data: [] };
|
: { data: [] };
|
||||||
|
|
||||||
@@ -408,7 +409,7 @@ export async function getAnalysisResultsForDoctor(
|
|||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select(
|
.select(
|
||||||
`primary_owner_user_id, id, name, last_name, personal_code, phone, email,
|
`primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale,
|
||||||
account_params(height,weight)`,
|
account_params(height,weight)`,
|
||||||
)
|
)
|
||||||
.eq('is_personal_account', true)
|
.eq('is_personal_account', true)
|
||||||
@@ -472,6 +473,7 @@ export async function getAnalysisResultsForDoctor(
|
|||||||
personal_code,
|
personal_code,
|
||||||
phone,
|
phone,
|
||||||
account_params,
|
account_params,
|
||||||
|
preferred_locale,
|
||||||
} = accountWithParams[0];
|
} = accountWithParams[0];
|
||||||
|
|
||||||
const analysisResponseElementsWithPreviousData = [];
|
const analysisResponseElementsWithPreviousData = [];
|
||||||
@@ -503,6 +505,7 @@ export async function getAnalysisResultsForDoctor(
|
|||||||
},
|
},
|
||||||
doctorFeedback: doctorFeedback?.[0],
|
doctorFeedback: doctorFeedback?.[0],
|
||||||
patient: {
|
patient: {
|
||||||
|
preferred_locale,
|
||||||
userId: primary_owner_user_id,
|
userId: primary_owner_user_id,
|
||||||
accountId,
|
accountId,
|
||||||
firstName: name,
|
firstName: name,
|
||||||
@@ -638,7 +641,7 @@ export async function submitFeedback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'COMPLETED') {
|
if (status === 'COMPLETED') {
|
||||||
const [{ data: recipient }, { data: medusaOrderIds }] = await Promise.all([
|
const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -659,18 +662,21 @@ export async function submitFeedback(
|
|||||||
throw new Error('Could not find user email.');
|
throw new Error('Could not find user email.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!medusaOrderIds?.[0]?.id) {
|
if (!analysisOrder?.[0]?.id) {
|
||||||
throw new Error('Could not retrieve order.');
|
throw new Error('Could not retrieve order.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { preferred_locale, name, last_name, email } = recipient[0];
|
const { preferred_locale, name, last_name, email } = recipient[0];
|
||||||
|
|
||||||
await sendDoctorSummaryCompletedEmail(
|
await sendEmailFromTemplate(
|
||||||
preferred_locale ?? 'et',
|
renderDoctorSummaryReceivedEmail,
|
||||||
getFullName(name, last_name),
|
{
|
||||||
|
language: preferred_locale ?? 'et',
|
||||||
|
recipientName: getFullName(name, last_name),
|
||||||
|
orderNr: analysisOrder?.[0]?.medusa_order_id ?? '',
|
||||||
|
orderId: analysisOrder[0].id,
|
||||||
|
},
|
||||||
email,
|
email,
|
||||||
medusaOrderIds?.[0]?.medusa_order_id ?? '',
|
|
||||||
medusaOrderIds[0].id,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './use-csrf-token';
|
export * from './use-csrf-token';
|
||||||
|
export * from './use-current-locale-language-names';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function useLanguageName(currentLanguage: string) {
|
||||||
|
return useMemo(() => {
|
||||||
|
return new Intl.DisplayNames([currentLanguage], {
|
||||||
|
type: 'language',
|
||||||
|
});
|
||||||
|
}, [currentLanguage]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrentLocaleLanguageNames() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const { language: currentLanguage } = i18n;
|
||||||
|
return useLanguageName(currentLanguage);
|
||||||
|
}
|
||||||
@@ -116,5 +116,6 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"invalidDataError": "Invalid data submitted"
|
"invalidDataError": "Invalid data submitted",
|
||||||
|
"language": "Language"
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"assignedTo": "Doctor",
|
"assignedTo": "Doctor",
|
||||||
"resultsStatus": "Analysis results",
|
"resultsStatus": "Analysis results",
|
||||||
"waitingForNr": "Waiting for {{nr}}",
|
"waitingForNr": "Waiting for {{nr}}",
|
||||||
|
"language": "Preferred language",
|
||||||
"responsesReceived": "Results complete"
|
"responsesReceived": "Results complete"
|
||||||
},
|
},
|
||||||
"otherPatients": "Other patients",
|
"otherPatients": "Other patients",
|
||||||
|
|||||||
@@ -136,5 +136,6 @@
|
|||||||
"confirm": "Kinnita",
|
"confirm": "Kinnita",
|
||||||
"previous": "Eelmine",
|
"previous": "Eelmine",
|
||||||
"next": "Järgmine",
|
"next": "Järgmine",
|
||||||
"invalidDataError": "Vigased andmed"
|
"invalidDataError": "Vigased andmed",
|
||||||
|
"language": "Keel"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"assignedTo": "Arst",
|
"assignedTo": "Arst",
|
||||||
"resultsStatus": "Analüüsitulemused",
|
"resultsStatus": "Analüüsitulemused",
|
||||||
"waitingForNr": "Ootel {{nr}}",
|
"waitingForNr": "Ootel {{nr}}",
|
||||||
|
"language": "Patsiendi keel",
|
||||||
"responsesReceived": "Tulemused koos"
|
"responsesReceived": "Tulemused koos"
|
||||||
},
|
},
|
||||||
"otherPatients": "Muud patsiendid",
|
"otherPatients": "Muud patsiendid",
|
||||||
|
|||||||
@@ -13,11 +13,12 @@
|
|||||||
"patientName": "Имя пациента",
|
"patientName": "Имя пациента",
|
||||||
"serviceName": "Услуга",
|
"serviceName": "Услуга",
|
||||||
"orderNr": "Номер заказа",
|
"orderNr": "Номер заказа",
|
||||||
"time": "Сдача пробы",
|
"time": "Время",
|
||||||
"assignedTo": "Врач",
|
"assignedTo": "Врач",
|
||||||
"resultsStatus": "Результаты анализов",
|
"resultsStatus": "Результаты анализов",
|
||||||
"waitingForNr": "В ожидании {{nr}}",
|
"waitingForNr": "В ожидании {{nr}}",
|
||||||
"responsesReceived": "Результаты получены"
|
"language": "Предпочтительный язык",
|
||||||
|
"responsesReceived": "Результаты готовы"
|
||||||
},
|
},
|
||||||
"otherPatients": "Другие пациенты",
|
"otherPatients": "Другие пациенты",
|
||||||
"analyses": "Анализы",
|
"analyses": "Анализы",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
create extension if not exists pg_cron;
|
||||||
|
create extension if not exists pg_net;
|
||||||
|
|
||||||
|
select
|
||||||
|
cron.schedule(
|
||||||
|
'send emails with new unassigned jobs 4x a day',
|
||||||
|
'0 4,9,14,18 * * 1-5', -- Run at 07:00, 12:00, 17:00 and 21:00 (GMT +3) on weekdays only
|
||||||
|
$$
|
||||||
|
select
|
||||||
|
net.http_post(
|
||||||
|
url := 'https://test.medreport.ee/api/job/send-open-jobs-emails',
|
||||||
|
headers := jsonb_build_object(
|
||||||
|
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
|
||||||
|
)
|
||||||
|
) as request_id;
|
||||||
|
$$
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
grant select on table "medreport"."doctor_analysis_feedback" to "service_role";
|
||||||
|
|
||||||
|
create policy "service_role_select"
|
||||||
|
on "medreport"."doctor_analysis_feedback"
|
||||||
|
as permissive
|
||||||
|
for select
|
||||||
|
to service_role
|
||||||
|
using (true);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
create trigger "trigger_doctor_notification" after update
|
||||||
|
on "medreport"."analysis_orders" for each row
|
||||||
|
execute function "supabase_functions"."http_request"(
|
||||||
|
'http://host.docker.internal:3000/api/db/webhook',
|
||||||
|
'POST',
|
||||||
|
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
|
||||||
|
'{}',
|
||||||
|
'5000'
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
alter table audit.notification_entries enable row level security;
|
||||||
|
|
||||||
|
create policy "service_role_insert"
|
||||||
|
on "audit"."notification_entries"
|
||||||
|
as permissive
|
||||||
|
for insert
|
||||||
|
to service_role
|
||||||
|
with check (true);
|
||||||
|
|
||||||
|
grant insert on table "audit"."notification_entries" to "service_role";
|
||||||
Reference in New Issue
Block a user