feat(auth): implement user authentication and profile management

- Added authentication context and provider to manage user state.
- Created login and registration forms with validation and error handling.
- Implemented user profile component for updating user information and changing passwords.
- Introduced user settings in the database for notification preferences.
- Updated translations for authentication-related strings in English and German.
- Enhanced styles for authentication components and user profile.
- Added middleware for optional and required authentication checks.
This commit is contained in:
Daniel Volz
2025-12-26 19:57:35 +01:00
parent 5900fddb2d
commit a7f9f90db4
20 changed files with 2020 additions and 402 deletions
@@ -1,9 +1,10 @@
import nodemailer from "nodemailer";
import { eq } from "drizzle-orm";
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";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
import { getReminderState, updateReminderSentTime } from "./reminder-scheduler.js";
@@ -261,7 +262,22 @@ ${tr.intakeReminder.footer}`;
}
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
if (allUserSettings.length === 0) {
return; // No users with settings
}
for (const userSettings of allUserSettings) {
await checkAndSendIntakeRemindersForUser(userSettings, logger);
}
}
async function checkAndSendIntakeRemindersForUser(
settings: UserSettings & { userId: number },
logger: { info: (msg: string) => void; error: (msg: string) => void }
): Promise<void> {
const language = settings.language;
const tr = getTranslations(language);
@@ -270,22 +286,22 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
if (!emailEnabled && !shoutrrrEnabled) {
return; // No intake reminder notifications enabled, skip silently
return; // No intake reminder notifications enabled for this user
}
// Get all medications with intake reminders enabled
const rows = await db.select().from(medications).orderBy(medications.id);
// Get all medications with intake reminders enabled for this user
const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
return; // No medications have reminders enabled
return; // No medications have reminders enabled for this user
}
const state = loadIntakeReminderState();
const allUpcoming: UpcomingIntake[] = [];
const locale = getDateLocale(language);
// Find all upcoming intakes across all medications
// Find all upcoming intakes across all medications for this user
for (const med of medsWithReminders) {
const slices = parseSlices(med);
const upcoming = getUpcomingIntakes(med.name, slices, REMINDER_MINUTES_BEFORE, med.takenBy, med.pillWeightMg, locale);
@@ -296,9 +312,9 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
return; // No upcoming intakes in the window
}
// Filter out already-sent reminders
// Filter out already-sent reminders (keyed by user)
const newReminders = allUpcoming.filter(intake => {
const key = `${intake.medName}:${intake.intakeTime.getTime()}`;
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
return !state.sentReminders.includes(key);
});
@@ -306,19 +322,19 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
return; // All reminders already sent
}
logger.info(`[IntakeReminder] Sending reminder for ${newReminders.length} upcoming intakes...`);
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${newReminders.length} upcoming intakes...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
// Send email if enabled for intake reminders
if (emailEnabled) {
const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders, language);
const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language);
emailSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Email sent successfully`);
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
} else {
logger.error(`[IntakeReminder] Failed to send email: ${result.error}`);
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
}
}
@@ -337,18 +353,18 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
})
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Push notification sent successfully`);
logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`);
} else {
logger.error(`[IntakeReminder] Failed to send push: ${result.error}`);
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`);
}
}
// Update state if any notification was sent successfully
if (emailSuccess || shoutrrrSuccess) {
const newKeys = newReminders.map(i => `${i.medName}:${i.intakeTime.getTime()}`);
const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`);
// Clean up old entries (older than 24 hours)
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
+42 -75
View File
@@ -1,32 +1,14 @@
import nodemailer from "nodemailer";
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { medications } from "../db/schema.js";
import { medications, users } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js";
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
type Slice = { usage: number; every: number; start: string };
type NotificationSettings = {
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
// Granular notification settings
emailStockReminders: boolean;
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
// Language setting
language: Language;
};
type ReminderState = {
lastAutoEmailSent: string | null; // ISO date string
lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today
@@ -232,8 +214,8 @@ type LowStockItem = {
depletionDate: string | null;
};
async function getMedicationsNeedingReminder(reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).orderBy(medications.id);
async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const lowStock: LowStockItem[] = [];
@@ -361,7 +343,23 @@ ${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyN
}
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
if (allUserSettings.length === 0) {
logger.info("[Reminder] No users with settings found");
return;
}
for (const userSettings of allUserSettings) {
await checkAndSendReminderForUser(userSettings, logger);
}
}
async function checkAndSendReminderForUser(
settings: UserSettings & { userId: number },
logger: { info: (msg: string) => void; error: (msg: string) => void }
): Promise<void> {
const language = settings.language;
const tr = getTranslations(language);
@@ -370,79 +368,48 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders;
if (!emailEnabled && !shoutrrrEnabled) {
logger.info("[Reminder] No stock reminder notifications enabled");
return;
return; // No stock reminder notifications enabled for this user
}
const state = loadReminderState();
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
const userStateKey = `user_${settings.userId}`;
// Get all medications that need a reminder
const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore, language);
// Get all medications that need a reminder for this user
const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language);
if (allLowStock.length === 0) {
// No low stock - clear the notified list (medications have been restocked)
if (state.notifiedMedications.length > 0) {
saveReminderState({
...state,
notifiedMedications: [],
});
logger.info("[Reminder] Cleared notified medications list (all restocked)");
}
logger.info("[Reminder] No medications need reminder");
return;
return; // No low stock for this user
}
// Get names of currently low stock medications
const currentLowStockNames = allLowStock.map((m) => m.name);
// Remove medications from notified list that are no longer low stock (restocked)
const stillLowStock = state.notifiedMedications.filter((name) => currentLowStockNames.includes(name));
// Find NEW medications that haven't been notified yet
const newLowStock = allLowStock.filter((m) => !state.notifiedMedications.includes(m.name));
// Determine what to send
let medsToNotify: LowStockItem[] = [];
if (settings.repeatDailyReminders) {
// Daily reminders enabled - send for ALL low stock, but only once per day
if (state.lastAutoEmailDate === today) {
logger.info("[Reminder] Daily reminder already sent today, skipping");
return;
}
medsToNotify = allLowStock;
} else {
// Only notify NEW medications (not previously notified)
if (newLowStock.length === 0) {
logger.info("[Reminder] No new medications to notify (already notified previously)");
return;
}
medsToNotify = newLowStock;
// Simple per-user tracking - check if we already sent today
const userNotifiedKey = `${userStateKey}_${today}`;
if (state.notifiedMedications.includes(userNotifiedKey) && !settings.repeatDailyReminders) {
return; // Already notified this user today
}
logger.info(`[Reminder] Sending reminder for ${medsToNotify.length} medications...`);
logger.info(`[Reminder] User ${settings.userId}: Sending reminder for ${allLowStock.length} medications...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
// Send email if enabled
if (emailEnabled) {
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify, language, settings.repeatDailyReminders);
const result = await sendReminderEmail(settings.notificationEmail!, allLowStock, language, settings.repeatDailyReminders);
emailSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`);
} else {
logger.error(`[Reminder] Failed to send email: ${result.error}`);
logger.error(`[Reminder] User ${settings.userId}: Failed to send email: ${result.error}`);
}
}
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
const title = medsToNotify.length === 1
const title = allLowStock.length === 1
? tr.push.stockTitle
: t(tr.push.stockTitleMultiple, { count: medsToNotify.length });
let message = medsToNotify
: t(tr.push.stockTitleMultiple, { count: allLowStock.length });
let message = allLowStock
.map((m) => `${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`)
.join("\n");
@@ -450,12 +417,12 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
message += `\n\n${tr.push.repeatDailyNote}`;
}
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] Push notification sent successfully`);
logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`);
} else {
logger.error(`[Reminder] Failed to send push notification: ${result.error}`);
logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`);
}
}
@@ -466,7 +433,7 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])],
notifiedMedications: [...new Set([...currentState.notifiedMedications, userNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "stock",
lastNotificationChannel: channel,