diff --git a/backend/src/app.ts b/backend/src/app.ts index 026ad31..1ced36a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,8 +13,9 @@ import Stripe from 'stripe'; import { z } from 'zod'; 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 { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js'; import { consumeMagicLinkToken, consumeOAuthState, @@ -36,6 +37,7 @@ import { completePendingBirdTransfersForOwner, createBird, createBirdMilestoneReminderDelivery, + createMedicationReminderDelivery, createBirdTransferCode, createMedicationForBird, createPendingBirdTransfer, @@ -51,6 +53,7 @@ import { getOpenBirdTransferCodeForBird, listBirds, listDueBirdMilestoneReminders, + listDueMedicationReminders, listMemorializedBirds, listMedicationAdministrationsForBird, listMedicationsForBird, @@ -118,8 +121,9 @@ import type { FlockNoteRow, IntegrationTokenRow, LostBirdMatchRow, - MedicationRow, MedicationAdministrationRow, + MedicationReminderCandidateRow, + MedicationRow, ProviderKey, RescueVerificationStatus, SubscriptionStatus, @@ -148,10 +152,11 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000'; const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`; const sessionDays = 30; 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 medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York'; 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'; if (trustProxy) { @@ -325,6 +330,7 @@ const medicationSchema = z startDate: dateStringSchema, endDate: dateStringSchema.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, { message: 'End date must be on or after start date.', @@ -720,6 +726,7 @@ const normalizeMedication = (row: MedicationRow) => ({ startDate: row.start_date, endDate: row.end_date, notes: row.notes, + remindersEnabled: row.reminders_enabled, }); const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({ @@ -1218,6 +1225,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo 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 remainder = value % 100; 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 ({ reminder, recipients, @@ -1831,6 +1877,120 @@ const sendBirdMilestoneReminderNotification = async ({ 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 = []; + 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 + ? `${escapeHtml(reminder.name)}` + : `
${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}
`; + const medicationNotesHtml = reminder.medication_notes + ? `

Medication notes: ${escapeHtml(reminder.medication_notes)}

` + : ''; + 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: ` +
+
+ +
+
+
+ ${ + logoAttachment + ? 'FlockPal' + : 'FlockPal' + } +
+
+ + + + + +
+ ${birdPhotoHtml} + +

${escapeHtml(copy.eyebrow)}

+

${escapeHtml(copy.headline)}

+

${escapeHtml(copy.intro)}

+
+

${escapeHtml(copy.body)}

+

Schedule: ${escapeHtml(copy.detailLabel)}

+ ${medicationNotesHtml} +

+ Open FlockPal +

+
+
+
+ +
+
+ `, + }); + + return { delivered: true }; +}; + export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => { const reminders = await listDueBirdMilestoneReminders(runDate); 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 lastMedicationReminderRunKey = ''; export const startBirdMilestoneReminderScheduler = () => { if (!milestoneRemindersEnabled) { @@ -1909,6 +2115,42 @@ export const startBirdMilestoneReminderScheduler = () => { }, 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) => { if (!authorizationHeader) { return ''; @@ -2060,6 +2302,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re try { const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts(); + const medicationReminderQueueCounts = await getMedicationReminderQueueCounts(); res.json({ startedAt: requestMetrics.startedAt, @@ -2081,6 +2324,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re }, queues: { birdMilestoneReminders: birdMilestoneReminderQueueCounts, + medicationReminders: medicationReminderQueueCounts, }, }); } catch (error) { @@ -3844,6 +4088,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ parsed.data.startDate, emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.notes), + parsed.data.remindersEnabled ?? false, ); 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, emptyToNull(parsed.data.endDate), emptyToNull(parsed.data.notes), + parsed.data.remindersEnabled ?? false, ); if (!medication) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index e7bc71c..f4ad8e1 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -437,6 +437,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { start_date DATE NOT NULL, end_date DATE, notes VARCHAR(1000), + reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (end_date IS NULL OR end_date >= start_date) ); @@ -444,6 +445,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ALTER TABLE medications 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 ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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 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 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 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 ON medication_administrations (bird_id, administered_on DESC); diff --git a/backend/src/queues/adoptionReportQueue.ts b/backend/src/queues/adoptionReportQueue.ts index 7a997cc..a1c60d6 100644 --- a/backend/src/queues/adoptionReportQueue.ts +++ b/backend/src/queues/adoptionReportQueue.ts @@ -40,4 +40,3 @@ export const closeAdoptionReportQueue = async () => { await adoptionReportQueue.close(); await adoptionReportQueueEvents.close(); }; - diff --git a/backend/src/queues/medicationReminderQueue.ts b/backend/src/queues/medicationReminderQueue.ts new file mode 100644 index 0000000..d6b831d --- /dev/null +++ b/backend/src/queues/medicationReminderQueue.ts @@ -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(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> => + 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'); diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 85e314a..23e602a 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -9,6 +9,8 @@ import type { LostBirdMatchRow, MedicationAdministrationRow, MedicationDoseScheduleItem, + MedicationReminderCandidateRow, + MedicationReminderDeliveryRow, MedicationRow, PendingBirdTransferRow, VetVisitRow, @@ -283,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({ return result.rows[0] ?? null; }; +export const listDueMedicationReminders = async (runDate: string, currentTime: string) => { + const result = await db.query( + `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( + `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 ({ birdId, workspaceId, @@ -902,7 +977,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) => export const listMedicationsForBird = async (birdId: string, workspaceId: number) => { const result = await db.query( - `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 WHERE bird_id = $1 AND EXISTS ( @@ -928,12 +1003,13 @@ export const createMedicationForBird = async ( startDate: string, endDate: string | null, notes: string | null, + remindersEnabled: boolean, ) => { const result = await db.query( - `INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, - [birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, 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, $10) + 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, remindersEnabled], ); return result.rows[0] ?? null; @@ -950,6 +1026,7 @@ export const updateMedicationForBird = async ( startDate: string, endDate: string | null, notes: string | null, + remindersEnabled: boolean, ) => { const result = await db.query( `UPDATE medications @@ -960,11 +1037,12 @@ export const updateMedicationForBird = async ( route = $7, start_date = $8, end_date = $9, - notes = $10 + notes = $10, + reminders_enabled = $11 WHERE id = $1 AND bird_id = $2 - RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, - [medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, 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, remindersEnabled], ); return result.rows[0] ?? null; diff --git a/backend/src/types.ts b/backend/src/types.ts index bd56bbd..eb5b411 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -202,6 +202,7 @@ export type MedicationRow = { start_date: string; end_date: string | null; notes: string | null; + reminders_enabled: boolean; }; export type MedicationDoseScheduleItem = { @@ -210,6 +211,33 @@ export type MedicationDoseScheduleItem = { 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 = { id: string; medication_id: string; diff --git a/backend/src/worker.ts b/backend/src/worker.ts index ee1c87a..7611d0e 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -2,7 +2,12 @@ import { Worker } from 'bullmq'; import { ensureSchema } from './db/schema.js'; import { db } from './db/client.js'; -import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js'; +import { + runBirdMilestoneReminders, + runMedicationReminders, + startBirdMilestoneReminderScheduler, + startMedicationReminderScheduler, +} from './app.js'; import { adoptionReportQueueName, closeAdoptionReportQueue, @@ -15,10 +20,17 @@ import { type BirdMilestoneReminderJobData, type BirdMilestoneReminderJobResult, } from './queues/birdMilestoneReminderQueue.js'; +import { + closeMedicationReminderQueue, + medicationReminderQueueName, + type MedicationReminderJobData, + type MedicationReminderJobResult, +} from './queues/medicationReminderQueue.js'; import { redisConnection } from './queues/redisConnection.js'; import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; let birdMilestoneWorker: Worker | null = null; +let medicationReminderWorker: Worker | null = null; let adoptionReportWorker: Worker | null = null; const startWorker = async () => { @@ -43,6 +55,25 @@ const startWorker = async () => { console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error); }); + medicationReminderWorker = new Worker( + 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( adoptionReportQueueName, async (job) => { @@ -63,14 +94,17 @@ const startWorker = async () => { }); startBirdMilestoneReminderScheduler(); + startMedicationReminderScheduler(); console.log('FlockPal worker started.'); }; const shutdown = async (signal: string) => { console.log(`FlockPal worker received ${signal}; shutting down.`); await birdMilestoneWorker?.close(); + await medicationReminderWorker?.close(); await adoptionReportWorker?.close(); await closeBirdMilestoneReminderQueue(); + await closeMedicationReminderQueue(); await closeAdoptionReportQueue(); await db.close(); process.exit(0); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index aaaa693..618df0e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -59,6 +59,7 @@ services: 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} 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} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} @@ -139,6 +140,7 @@ services: 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} 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} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} diff --git a/docker-compose.yml b/docker-compose.yml index 344abce..15fe01c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,7 @@ services: 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} 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} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} @@ -132,6 +133,7 @@ services: 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} 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} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a311fbb..59307bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,6 +75,7 @@ type Medication = { startDate: string; endDate: string | null; notes: string | null; + remindersEnabled: boolean; }; 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), endDate: '', notes: '', + remindersEnabled: false, }); const [flockTransferForm, setFlockTransferForm] = useState({ birdId: '', @@ -3653,6 +3655,7 @@ function App() { startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', + remindersEnabled: false, }); setEditingMedicationId(''); } catch (submitError) { @@ -3672,6 +3675,7 @@ function App() { startDate: medication.startDate, endDate: medication.endDate ?? '', notes: medication.notes ?? '', + remindersEnabled: medication.remindersEnabled, }); setError(''); }; @@ -3687,6 +3691,7 @@ function App() { startDate: new Date().toISOString().slice(0, 10), endDate: '', notes: '', + remindersEnabled: false, }); }; @@ -4988,6 +4993,7 @@ function App() { {formatDate(medication.startDate)} to {formatDate(medication.endDate)} + {medication.remindersEnabled ? 'Medication reminders enabled' : 'Medication reminders off'} {medication.notes || 'No notes recorded.'} {latestAdministration ? ( @@ -5995,6 +6001,17 @@ function App() { ))} +