diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index b83941e..c4a2ad3 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -16,6 +16,7 @@ async function runMigrations() { { name: "expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date TEXT" }, { name: "notes", sql: "ALTER TABLE medications ADD COLUMN notes TEXT" }, { name: "generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name TEXT" }, + { name: "intake_reminders_enabled", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0" }, ]; for (const migration of migrations) { diff --git a/backend/src/db/migrations/0007_add_intake_reminders.sql b/backend/src/db/migrations/0007_add_intake_reminders.sql new file mode 100644 index 0000000..a5ae1e7 --- /dev/null +++ b/backend/src/db/migrations/0007_add_intake_reminders.sql @@ -0,0 +1,2 @@ +-- Add intake_reminders_enabled column to medications table +ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0; diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index e4c7bac..05dc333 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -6,6 +6,7 @@ { "idx": 3, "version": 1, "when": 1734900000, "tag": "0003_add_image_url", "breakpoint": false }, { "idx": 4, "version": 1, "when": 1735000000, "tag": "0004_add_expiry_date", "breakpoint": false }, { "idx": 5, "version": 1, "when": 1735100000, "tag": "0005_add_notes", "breakpoint": false }, - { "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false } + { "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false }, + { "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 660d730..4fb90a0 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -27,6 +27,7 @@ export const medications = sqliteTable("medications", { imageUrl: text("image_url"), expiryDate: text("expiry_date"), notes: text("notes"), + intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index ec9701e..c3814af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,7 @@ import { medicationRoutes } from "./routes/medications.js"; import { settingsRoutes } from "./routes/settings.js"; import { plannerRoutes } from "./routes/planner.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; +import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; // Wait for database migrations before anything else await migrationsReady; @@ -93,6 +94,12 @@ const start = async () => { info: (msg) => app.log.info(msg), error: (msg) => app.log.error(msg), }); + + // Start the intake reminder scheduler (checks every minute) + startIntakeReminderScheduler({ + info: (msg) => app.log.info(msg), + error: (msg) => app.log.error(msg), + }); } catch (err) { app.log.error(err); process.exit(1); diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 3d391e1..eb95100 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -24,6 +24,7 @@ const medicationSchema = z.object({ looseTablets: z.number().int().min(0).default(0), expiryDate: z.string().nullable().optional(), notes: z.string().max(500).nullable().optional(), + intakeRemindersEnabled: z.boolean().default(false), // count will be derived on the backend slices: z.array(sliceSchema).min(1).max(12), }); @@ -66,6 +67,7 @@ export async function medicationRoutes(app: FastifyInstance) { imageUrl: row.imageUrl, expiryDate: row.expiryDate, notes: row.notes, + intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, updatedAt: row.updatedAt, })); }); @@ -74,7 +76,7 @@ export async function medicationRoutes(app: FastifyInstance) { const parsed = medicationSchema.safeParse(req.body); if (!parsed.success) return reply.status(400).send(parsed.error.format()); - const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, slices } = parsed.data; + const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data; const usageJson = JSON.stringify(slices.map((s) => s.usage)); const everyJson = JSON.stringify(slices.map((s) => s.every)); const startJson = JSON.stringify(slices.map((s) => s.start)); @@ -95,6 +97,7 @@ export async function medicationRoutes(app: FastifyInstance) { looseTablets, expiryDate: expiryDate || null, notes: notes || null, + intakeRemindersEnabled: intakeRemindersEnabled ?? false, usageJson, everyJson, startJson, @@ -116,6 +119,7 @@ export async function medicationRoutes(app: FastifyInstance) { imageUrl: inserted.imageUrl, expiryDate: inserted.expiryDate, notes: inserted.notes, + intakeRemindersEnabled: inserted.intakeRemindersEnabled, updatedAt: inserted.updatedAt, }; }); @@ -126,7 +130,7 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, slices } = parsed.data; + const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data; const usageJson = JSON.stringify(slices.map((s) => s.usage)); const everyJson = JSON.stringify(slices.map((s) => s.every)); const startJson = JSON.stringify(slices.map((s) => s.start)); @@ -147,6 +151,7 @@ export async function medicationRoutes(app: FastifyInstance) { looseTablets, expiryDate: expiryDate || null, notes: notes || null, + intakeRemindersEnabled: intakeRemindersEnabled ?? false, usageJson, everyJson, startJson, @@ -172,6 +177,7 @@ export async function medicationRoutes(app: FastifyInstance) { imageUrl: result[0].imageUrl, expiryDate: result[0].expiryDate, notes: result[0].notes, + intakeRemindersEnabled: result[0].intakeRemindersEnabled, updatedAt: result[0].updatedAt, }; }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts new file mode 100644 index 0000000..895ef18 --- /dev/null +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -0,0 +1,315 @@ +import nodemailer from "nodemailer"; +import { db } from "../db/client.js"; +import { medications } from "../db/schema.js"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve } from "path"; +import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js"; + +type Slice = { usage: number; every: number; start: string }; + +type IntakeReminderState = { + sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders +}; + +const REMINDER_MINUTES_BEFORE = 15; +const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute + +// Get current timezone from TZ env variable or default to UTC +function getTimezone(): string { + return process.env.TZ || "UTC"; +} + +const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json"); + +function loadIntakeReminderState(): IntakeReminderState { + try { + if (existsSync(intakeReminderStateFile)) { + const saved = JSON.parse(readFileSync(intakeReminderStateFile, "utf-8")); + return { + sentReminders: saved.sentReminders ?? [], + }; + } + } catch { + // ignore + } + return { sentReminders: [] }; +} + +function saveIntakeReminderState(state: IntakeReminderState): void { + writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2)); +} + +function parseSlices(row: { usageJson: string; everyJson: string; startJson: string }): Slice[] { + try { + const usage = JSON.parse(row.usageJson) as number[]; + const every = JSON.parse(row.everyJson) as number[]; + const start = JSON.parse(row.startJson) as string[]; + const len = Math.min(usage.length, every.length, start.length); + const slices: Slice[] = []; + for (let i = 0; i < len; i++) { + slices.push({ usage: usage[i], every: every[i], start: start[i] }); + } + return slices; + } catch { + return []; + } +} + +type UpcomingIntake = { + medName: string; + usage: number; + intakeTime: Date; + intakeTimeStr: string; +}; + +function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: number): UpcomingIntake[] { + const now = Date.now(); + const windowStart = now; // Now + const windowEnd = now + (minutesBefore + 1) * 60 * 1000; // minutesBefore + 1 minute from now + + const upcoming: UpcomingIntake[] = []; + + for (const slice of slices) { + const startTime = new Date(slice.start).getTime(); + const intervalMs = slice.every * 24 * 60 * 60 * 1000; + + if (intervalMs <= 0) continue; + + // Find the next scheduled time for this slice + let nextTime = startTime; + + // If start is in the past, calculate the next occurrence + if (nextTime < now) { + const elapsed = now - startTime; + const intervals = Math.floor(elapsed / intervalMs); + nextTime = startTime + (intervals + 1) * intervalMs; + } + + // Check if this falls in our window (now to minutesBefore from now) + // We want to notify when the intake is minutesBefore minutes away + const notifyTime = nextTime - minutesBefore * 60 * 1000; + + if (notifyTime >= windowStart && notifyTime <= windowEnd) { + const intakeDate = new Date(nextTime); + upcoming.push({ + medName, + usage: slice.usage, + intakeTime: intakeDate, + intakeTimeStr: intakeDate.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + timeZone: getTimezone() + }), + }); + } + } + + return upcoming; +} + +async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[]): Promise<{ success: boolean; error?: string }> { + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587"); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + if (!smtpHost || !smtpUser) { + return { success: false, error: "SMTP not configured" }; + } + + const tableRows = intakes + .map( + (intake) => ` +
Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:
+ ++ 💊 ${intakes.length} medication${intakes.length > 1 ? "s" : ""} scheduled +
+| Medication | +Dosage | +Time | +
|---|
+ MedAssist Medication Planner +
+