Medication reminder and pdr worker
This commit is contained in:
+249
-3
@@ -13,8 +13,9 @@ import Stripe from 'stripe';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { enqueueAdoptionReportJob, adoptionReportQueueEvents } from './queues/adoptionReportQueue.js';
|
import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js';
|
||||||
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
|
||||||
import {
|
import {
|
||||||
consumeMagicLinkToken,
|
consumeMagicLinkToken,
|
||||||
consumeOAuthState,
|
consumeOAuthState,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
completePendingBirdTransfersForOwner,
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
createBirdMilestoneReminderDelivery,
|
createBirdMilestoneReminderDelivery,
|
||||||
|
createMedicationReminderDelivery,
|
||||||
createBirdTransferCode,
|
createBirdTransferCode,
|
||||||
createMedicationForBird,
|
createMedicationForBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
@@ -51,6 +53,7 @@ import {
|
|||||||
getOpenBirdTransferCodeForBird,
|
getOpenBirdTransferCodeForBird,
|
||||||
listBirds,
|
listBirds,
|
||||||
listDueBirdMilestoneReminders,
|
listDueBirdMilestoneReminders,
|
||||||
|
listDueMedicationReminders,
|
||||||
listMemorializedBirds,
|
listMemorializedBirds,
|
||||||
listMedicationAdministrationsForBird,
|
listMedicationAdministrationsForBird,
|
||||||
listMedicationsForBird,
|
listMedicationsForBird,
|
||||||
@@ -118,8 +121,9 @@ import type {
|
|||||||
FlockNoteRow,
|
FlockNoteRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationRow,
|
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
|
MedicationReminderCandidateRow,
|
||||||
|
MedicationRow,
|
||||||
ProviderKey,
|
ProviderKey,
|
||||||
RescueVerificationStatus,
|
RescueVerificationStatus,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -148,10 +152,11 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
|
|||||||
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
||||||
const sessionDays = 30;
|
const sessionDays = 30;
|
||||||
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
||||||
|
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
|
||||||
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||||
|
const medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||||
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
||||||
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
||||||
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
|
|
||||||
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
||||||
|
|
||||||
if (trustProxy) {
|
if (trustProxy) {
|
||||||
@@ -325,6 +330,7 @@ const medicationSchema = z
|
|||||||
startDate: dateStringSchema,
|
startDate: dateStringSchema,
|
||||||
endDate: dateStringSchema.optional().or(z.literal('')),
|
endDate: dateStringSchema.optional().or(z.literal('')),
|
||||||
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
|
remindersEnabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
||||||
message: 'End date must be on or after start date.',
|
message: 'End date must be on or after start date.',
|
||||||
@@ -720,6 +726,7 @@ const normalizeMedication = (row: MedicationRow) => ({
|
|||||||
startDate: row.start_date,
|
startDate: row.start_date,
|
||||||
endDate: row.end_date,
|
endDate: row.end_date,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
|
remindersEnabled: row.reminders_enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
|
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
|
||||||
@@ -1218,6 +1225,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTimeInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(date);
|
||||||
|
const hour = parts.find((part) => part.type === 'hour')?.value ?? `${date.getUTCHours()}`.padStart(2, '0');
|
||||||
|
const minute = parts.find((part) => part.type === 'minute')?.value ?? `${date.getUTCMinutes()}`.padStart(2, '0');
|
||||||
|
return `${hour === '24' ? '00' : hour}:${minute}`;
|
||||||
|
};
|
||||||
|
|
||||||
const formatOrdinal = (value: number) => {
|
const formatOrdinal = (value: number) => {
|
||||||
const remainder = value % 100;
|
const remainder = value % 100;
|
||||||
if (remainder >= 11 && remainder <= 13) {
|
if (remainder >= 11 && remainder <= 13) {
|
||||||
@@ -1725,6 +1744,33 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatReminderDoseTime = (time: string) => {
|
||||||
|
const [rawHour, rawMinute] = time.split(':');
|
||||||
|
const hour = Number(rawHour);
|
||||||
|
const minute = rawMinute ?? '00';
|
||||||
|
if (Number.isNaN(hour)) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
const period = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHour = hour % 12 || 12;
|
||||||
|
return `${displayHour}:${minute} ${period}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) => {
|
||||||
|
const doseTime = formatReminderDoseTime(reminder.administration_time);
|
||||||
|
const slotLabel = reminder.administration_label || 'Dose';
|
||||||
|
const route = reminder.route ? ` by ${reminder.route}` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
|
||||||
|
eyebrow: 'Medication Reminder',
|
||||||
|
headline: `${slotLabel} time for ${reminder.name}`,
|
||||||
|
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
|
||||||
|
body: `Dose: ${reminder.dosage}${route}.`,
|
||||||
|
detailLabel: `${slotLabel} at ${doseTime}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const sendBirdMilestoneReminderNotification = async ({
|
const sendBirdMilestoneReminderNotification = async ({
|
||||||
reminder,
|
reminder,
|
||||||
recipients,
|
recipients,
|
||||||
@@ -1831,6 +1877,120 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
return { delivered: true };
|
return { delivered: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendMedicationReminderNotification = async ({
|
||||||
|
reminder,
|
||||||
|
recipients,
|
||||||
|
}: {
|
||||||
|
reminder: MedicationReminderCandidateRow;
|
||||||
|
recipients: string[];
|
||||||
|
}) => {
|
||||||
|
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
|
||||||
|
|
||||||
|
if (!uniqueRecipients.length) {
|
||||||
|
return { delivered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = buildMedicationReminderCopy(reminder);
|
||||||
|
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
||||||
|
const logoAttachment = getFlockPalLogoAttachment();
|
||||||
|
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
|
||||||
|
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
|
||||||
|
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
|
||||||
|
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
|
||||||
|
|
||||||
|
if (logoAttachment) {
|
||||||
|
attachments.push(logoAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadedBirdPhoto) {
|
||||||
|
attachments.push({
|
||||||
|
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
|
||||||
|
content: uploadedBirdPhoto.content,
|
||||||
|
contentType: uploadedBirdPhoto.contentType,
|
||||||
|
cid: birdPhotoCid,
|
||||||
|
contentDisposition: 'inline',
|
||||||
|
});
|
||||||
|
} else if (defaultBirdPhoto) {
|
||||||
|
attachments.push(defaultBirdPhoto);
|
||||||
|
}
|
||||||
|
|
||||||
|
const birdPhotoHtml = birdPhotoCid
|
||||||
|
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
|
||||||
|
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
|
||||||
|
const medicationNotesHtml = reminder.medication_notes
|
||||||
|
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
|
||||||
|
: '';
|
||||||
|
const lines = [
|
||||||
|
copy.headline,
|
||||||
|
'',
|
||||||
|
copy.intro,
|
||||||
|
copy.body,
|
||||||
|
'',
|
||||||
|
`Bird: ${reminder.name}`,
|
||||||
|
`Medication: ${reminder.medication_name}`,
|
||||||
|
`Dose: ${reminder.dosage}`,
|
||||||
|
`Scheduled dose: ${copy.detailLabel}`,
|
||||||
|
reminder.medication_notes ? `Notes: ${reminder.medication_notes}` : '',
|
||||||
|
'',
|
||||||
|
`Open FlockPal: ${frontendBaseUrl}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (!mailTransport) {
|
||||||
|
console.log(`Medication reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
|
||||||
|
return { delivered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailTransport.sendMail({
|
||||||
|
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
||||||
|
to: smtpFromEmail,
|
||||||
|
bcc: uniqueRecipients,
|
||||||
|
subject: copy.subject,
|
||||||
|
text: lines.join('\n'),
|
||||||
|
attachments,
|
||||||
|
html: `
|
||||||
|
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
|
||||||
|
<div style="max-width: 680px; margin: 0 auto 18px;">
|
||||||
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
|
</div>
|
||||||
|
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
|
||||||
|
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
|
||||||
|
${
|
||||||
|
logoAttachment
|
||||||
|
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
|
||||||
|
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style="padding: 30px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
|
||||||
|
${birdPhotoHtml}
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align: top; padding: 0 0 20px;">
|
||||||
|
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
|
||||||
|
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
|
||||||
|
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
|
||||||
|
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
|
||||||
|
${medicationNotesHtml}
|
||||||
|
<p style="margin: 0;">
|
||||||
|
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="max-width: 680px; margin: 18px auto 0;">
|
||||||
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { delivered: true };
|
||||||
|
};
|
||||||
|
|
||||||
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
|
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
|
||||||
const reminders = await listDueBirdMilestoneReminders(runDate);
|
const reminders = await listDueBirdMilestoneReminders(runDate);
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
@@ -1875,7 +2035,53 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
|
||||||
|
const reminders = await listDueMedicationReminders(runDate, currentTime);
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const reminder of reminders) {
|
||||||
|
try {
|
||||||
|
const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
|
||||||
|
const result = await sendMedicationReminderNotification({ reminder, recipients });
|
||||||
|
|
||||||
|
if (!result.delivered) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delivery = await createMedicationReminderDelivery({
|
||||||
|
medicationId: reminder.medication_id,
|
||||||
|
birdId: reminder.id,
|
||||||
|
workspaceId: reminder.workspace_id,
|
||||||
|
scheduledOn: runDate,
|
||||||
|
administrationSlot: reminder.administration_slot,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (delivery) {
|
||||||
|
sent += 1;
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`Unable to send medication reminder for medication ${reminder.medication_id}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runDate,
|
||||||
|
currentTime,
|
||||||
|
checked: reminders.length,
|
||||||
|
sent,
|
||||||
|
skipped,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
let lastMilestoneReminderRunDate = '';
|
let lastMilestoneReminderRunDate = '';
|
||||||
|
let lastMedicationReminderRunKey = '';
|
||||||
|
|
||||||
export const startBirdMilestoneReminderScheduler = () => {
|
export const startBirdMilestoneReminderScheduler = () => {
|
||||||
if (!milestoneRemindersEnabled) {
|
if (!milestoneRemindersEnabled) {
|
||||||
@@ -1909,6 +2115,42 @@ export const startBirdMilestoneReminderScheduler = () => {
|
|||||||
}, milestoneReminderCheckIntervalMs);
|
}, milestoneReminderCheckIntervalMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const startMedicationReminderScheduler = () => {
|
||||||
|
if (!medicationRemindersEnabled) {
|
||||||
|
console.log('Medication reminders are disabled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runIfNeeded = async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const runDate = getDateInTimeZone(now);
|
||||||
|
const currentTime = getTimeInTimeZone(now);
|
||||||
|
const runKey = `${runDate}-${currentTime.slice(0, 2)}`;
|
||||||
|
|
||||||
|
if (lastMedicationReminderRunKey === runKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMedicationReminderRunKey = runKey;
|
||||||
|
const job = await enqueueMedicationReminderJob(runDate, currentTime);
|
||||||
|
console.log(`Medication reminder job queued for ${runDate} ${currentTime}: id=${job.id ?? 'unknown'}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
void runIfNeeded().catch((error) => {
|
||||||
|
lastMedicationReminderRunKey = '';
|
||||||
|
console.error('Medication reminder scheduler failed', error);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
void runIfNeeded().catch((error) => {
|
||||||
|
lastMedicationReminderRunKey = '';
|
||||||
|
console.error('Medication reminder scheduler failed', error);
|
||||||
|
});
|
||||||
|
}, milestoneReminderCheckIntervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
const readBearerToken = (authorizationHeader?: string) => {
|
const readBearerToken = (authorizationHeader?: string) => {
|
||||||
if (!authorizationHeader) {
|
if (!authorizationHeader) {
|
||||||
return '';
|
return '';
|
||||||
@@ -2060,6 +2302,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
|
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
|
||||||
|
const medicationReminderQueueCounts = await getMedicationReminderQueueCounts();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
startedAt: requestMetrics.startedAt,
|
startedAt: requestMetrics.startedAt,
|
||||||
@@ -2081,6 +2324,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
|||||||
},
|
},
|
||||||
queues: {
|
queues: {
|
||||||
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
||||||
|
medicationReminders: medicationReminderQueueCounts,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3844,6 +4088,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
|||||||
parsed.data.startDate,
|
parsed.data.startDate,
|
||||||
emptyToNull(parsed.data.endDate),
|
emptyToNull(parsed.data.endDate),
|
||||||
emptyToNull(parsed.data.notes),
|
emptyToNull(parsed.data.notes),
|
||||||
|
parsed.data.remindersEnabled ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
||||||
@@ -3887,6 +4132,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
|||||||
parsed.data.startDate,
|
parsed.data.startDate,
|
||||||
emptyToNull(parsed.data.endDate),
|
emptyToNull(parsed.data.endDate),
|
||||||
emptyToNull(parsed.data.notes),
|
emptyToNull(parsed.data.notes),
|
||||||
|
parsed.data.remindersEnabled ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!medication) {
|
if (!medication) {
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE,
|
end_date DATE,
|
||||||
notes VARCHAR(1000),
|
notes VARCHAR(1000),
|
||||||
|
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
CHECK (end_date IS NULL OR end_date >= start_date)
|
||||||
);
|
);
|
||||||
@@ -444,6 +445,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medications
|
ALTER TABLE medications
|
||||||
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE medications
|
||||||
|
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
@@ -477,6 +481,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
scheduled_on DATE NOT NULL,
|
||||||
|
administration_slot VARCHAR(80) NOT NULL,
|
||||||
|
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (medication_id, scheduled_on, administration_slot)
|
||||||
|
);
|
||||||
|
|
||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
||||||
|
|
||||||
@@ -495,6 +510,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
||||||
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
|
||||||
|
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
||||||
ON medication_administrations (bird_id, administered_on DESC);
|
ON medication_administrations (bird_id, administered_on DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,3 @@ export const closeAdoptionReportQueue = async () => {
|
|||||||
await adoptionReportQueue.close();
|
await adoptionReportQueue.close();
|
||||||
await adoptionReportQueueEvents.close();
|
await adoptionReportQueueEvents.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Queue, type Job } from 'bullmq';
|
||||||
|
|
||||||
|
import { redisConnection } from './redisConnection.js';
|
||||||
|
|
||||||
|
export type MedicationReminderJobData = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
requestedBy: 'scheduler';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderJobResult = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
checked: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const medicationReminderQueueName = 'medication-reminders';
|
||||||
|
|
||||||
|
export const medicationReminderQueue = new Queue<MedicationReminderJobData, MedicationReminderJobResult>(medicationReminderQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 60_000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueueMedicationReminderJob = (
|
||||||
|
runDate: string,
|
||||||
|
currentTime: string,
|
||||||
|
): Promise<Job<MedicationReminderJobData, MedicationReminderJobResult>> =>
|
||||||
|
medicationReminderQueue.add(
|
||||||
|
'run-medication-reminders',
|
||||||
|
{
|
||||||
|
runDate,
|
||||||
|
currentTime,
|
||||||
|
requestedBy: 'scheduler',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: `medication-reminders-${runDate}-${currentTime.slice(0, 2)}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const closeMedicationReminderQueue = async () => {
|
||||||
|
await medicationReminderQueue.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMedicationReminderQueueCounts = () => medicationReminderQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
|
||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
MedicationDoseScheduleItem,
|
MedicationDoseScheduleItem,
|
||||||
|
MedicationReminderCandidateRow,
|
||||||
|
MedicationReminderDeliveryRow,
|
||||||
MedicationRow,
|
MedicationRow,
|
||||||
PendingBirdTransferRow,
|
PendingBirdTransferRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
@@ -283,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
|
||||||
|
const result = await db.query<MedicationReminderCandidateRow>(
|
||||||
|
`SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
medications.id AS medication_id,
|
||||||
|
medications.name AS medication_name,
|
||||||
|
medications.dosage,
|
||||||
|
medications.frequency,
|
||||||
|
medications.dose_schedule,
|
||||||
|
medications.route,
|
||||||
|
medications.start_date::text AS medication_start_date,
|
||||||
|
medications.end_date::text AS medication_end_date,
|
||||||
|
medications.notes AS medication_notes,
|
||||||
|
$1::date::text AS scheduled_on,
|
||||||
|
dose.key AS administration_slot,
|
||||||
|
dose.label AS administration_label,
|
||||||
|
dose.time AS administration_time
|
||||||
|
FROM medications
|
||||||
|
INNER JOIN birds ON birds.id = medications.bird_id
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE medications.reminders_enabled = TRUE
|
||||||
|
AND birds.memorialized_at IS NULL
|
||||||
|
AND medications.start_date <= $1::date
|
||||||
|
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
|
||||||
|
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
|
||||||
|
AND dose.time <= $2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM medication_reminder_deliveries deliveries
|
||||||
|
WHERE deliveries.medication_id = medications.id
|
||||||
|
AND deliveries.scheduled_on = $1::date
|
||||||
|
AND deliveries.administration_slot = dose.key
|
||||||
|
)
|
||||||
|
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
|
||||||
|
[runDate, currentTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMedicationReminderDelivery = async ({
|
||||||
|
medicationId,
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
scheduledOn,
|
||||||
|
administrationSlot,
|
||||||
|
}: {
|
||||||
|
medicationId: string;
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
scheduledOn: string;
|
||||||
|
administrationSlot: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<MedicationReminderDeliveryRow>(
|
||||||
|
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
|
||||||
|
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
|
||||||
|
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
birdId,
|
birdId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -902,7 +977,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
|||||||
|
|
||||||
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes
|
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
|
||||||
FROM medications
|
FROM medications
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -928,12 +1003,13 @@ export const createMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes)
|
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -950,6 +1026,7 @@ export const updateMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`UPDATE medications
|
`UPDATE medications
|
||||||
@@ -960,11 +1037,12 @@ export const updateMedicationForBird = async (
|
|||||||
route = $7,
|
route = $7,
|
||||||
start_date = $8,
|
start_date = $8,
|
||||||
end_date = $9,
|
end_date = $9,
|
||||||
notes = $10
|
notes = $10,
|
||||||
|
reminders_enabled = $11
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND bird_id = $2
|
AND bird_id = $2
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export type MedicationRow = {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
reminders_enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationDoseScheduleItem = {
|
export type MedicationDoseScheduleItem = {
|
||||||
@@ -210,6 +211,33 @@ export type MedicationDoseScheduleItem = {
|
|||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderCandidateRow = BirdRow & {
|
||||||
|
workspace_name: string;
|
||||||
|
medication_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
dosage: string;
|
||||||
|
frequency: string;
|
||||||
|
dose_schedule: MedicationDoseScheduleItem[];
|
||||||
|
route: string | null;
|
||||||
|
medication_start_date: string;
|
||||||
|
medication_end_date: string | null;
|
||||||
|
medication_notes: string | null;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
administration_label: string;
|
||||||
|
administration_time: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderDeliveryRow = {
|
||||||
|
id: string;
|
||||||
|
medication_id: string;
|
||||||
|
bird_id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
delivered_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MedicationAdministrationRow = {
|
export type MedicationAdministrationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
medication_id: string;
|
medication_id: string;
|
||||||
|
|||||||
+35
-1
@@ -2,7 +2,12 @@ import { Worker } from 'bullmq';
|
|||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
import {
|
||||||
|
runBirdMilestoneReminders,
|
||||||
|
runMedicationReminders,
|
||||||
|
startBirdMilestoneReminderScheduler,
|
||||||
|
startMedicationReminderScheduler,
|
||||||
|
} from './app.js';
|
||||||
import {
|
import {
|
||||||
adoptionReportQueueName,
|
adoptionReportQueueName,
|
||||||
closeAdoptionReportQueue,
|
closeAdoptionReportQueue,
|
||||||
@@ -15,10 +20,17 @@ import {
|
|||||||
type BirdMilestoneReminderJobData,
|
type BirdMilestoneReminderJobData,
|
||||||
type BirdMilestoneReminderJobResult,
|
type BirdMilestoneReminderJobResult,
|
||||||
} from './queues/birdMilestoneReminderQueue.js';
|
} from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
import {
|
||||||
|
closeMedicationReminderQueue,
|
||||||
|
medicationReminderQueueName,
|
||||||
|
type MedicationReminderJobData,
|
||||||
|
type MedicationReminderJobResult,
|
||||||
|
} from './queues/medicationReminderQueue.js';
|
||||||
import { redisConnection } from './queues/redisConnection.js';
|
import { redisConnection } from './queues/redisConnection.js';
|
||||||
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||||
|
|
||||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||||
|
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
||||||
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
||||||
|
|
||||||
const startWorker = async () => {
|
const startWorker = async () => {
|
||||||
@@ -43,6 +55,25 @@ const startWorker = async () => {
|
|||||||
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
|
||||||
|
medicationReminderQueueName,
|
||||||
|
async (job) => {
|
||||||
|
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
|
||||||
|
console.log(
|
||||||
|
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
medicationReminderWorker.on('failed', (job, error) => {
|
||||||
|
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
||||||
adoptionReportQueueName,
|
adoptionReportQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -63,14 +94,17 @@ const startWorker = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
startBirdMilestoneReminderScheduler();
|
startBirdMilestoneReminderScheduler();
|
||||||
|
startMedicationReminderScheduler();
|
||||||
console.log('FlockPal worker started.');
|
console.log('FlockPal worker started.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||||
await birdMilestoneWorker?.close();
|
await birdMilestoneWorker?.close();
|
||||||
|
await medicationReminderWorker?.close();
|
||||||
await adoptionReportWorker?.close();
|
await adoptionReportWorker?.close();
|
||||||
await closeBirdMilestoneReminderQueue();
|
await closeBirdMilestoneReminderQueue();
|
||||||
|
await closeMedicationReminderQueue();
|
||||||
await closeAdoptionReportQueue();
|
await closeAdoptionReportQueue();
|
||||||
await db.close();
|
await db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -139,6 +140,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -132,6 +133,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type Medication = {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
remindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
|
type MedicationFrequency = 'once_daily' | 'twice_daily' | 'every_8_hours' | 'every_6_hours' | 'as_needed';
|
||||||
@@ -1589,6 +1590,7 @@ function App() {
|
|||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
remindersEnabled: false,
|
||||||
});
|
});
|
||||||
const [flockTransferForm, setFlockTransferForm] = useState({
|
const [flockTransferForm, setFlockTransferForm] = useState({
|
||||||
birdId: '',
|
birdId: '',
|
||||||
@@ -3653,6 +3655,7 @@ function App() {
|
|||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
remindersEnabled: false,
|
||||||
});
|
});
|
||||||
setEditingMedicationId('');
|
setEditingMedicationId('');
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
@@ -3672,6 +3675,7 @@ function App() {
|
|||||||
startDate: medication.startDate,
|
startDate: medication.startDate,
|
||||||
endDate: medication.endDate ?? '',
|
endDate: medication.endDate ?? '',
|
||||||
notes: medication.notes ?? '',
|
notes: medication.notes ?? '',
|
||||||
|
remindersEnabled: medication.remindersEnabled,
|
||||||
});
|
});
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
@@ -3687,6 +3691,7 @@ function App() {
|
|||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
endDate: '',
|
endDate: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
remindersEnabled: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4988,6 +4993,7 @@ function App() {
|
|||||||
<small>
|
<small>
|
||||||
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
|
{formatDate(medication.startDate)} to {formatDate(medication.endDate)}
|
||||||
</small>
|
</small>
|
||||||
|
<small>{medication.remindersEnabled ? 'Medication reminders enabled' : 'Medication reminders off'}</small>
|
||||||
<small>{medication.notes || 'No notes recorded.'}</small>
|
<small>{medication.notes || 'No notes recorded.'}</small>
|
||||||
{latestAdministration ? (
|
{latestAdministration ? (
|
||||||
<small>
|
<small>
|
||||||
@@ -5995,6 +6001,17 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="checkbox-row wide-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={medicationForm.remindersEnabled}
|
||||||
|
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Send medication reminders</strong>
|
||||||
|
<small>Uses the medication frequency, dose times, and start/end dates.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="wide-field">
|
<label className="wide-field">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
@@ -7965,6 +7982,17 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="checkbox-row wide-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={medicationForm.remindersEnabled}
|
||||||
|
onChange={(event) => setMedicationForm({ ...medicationForm, remindersEnabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Send medication reminders</strong>
|
||||||
|
<small>Uses the medication frequency, dose times, and start/end dates.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="wide-field">
|
<label className="wide-field">
|
||||||
Notes
|
Notes
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
Reference in New Issue
Block a user