feat: Nagging reminders with max limit + ENV defaults for settings (#18)
* ci: prevent duplicate test runs - tests only on PRs, inline tests for builds * docs: add testing and CI/CD documentation * security: fix CodeQL vulnerabilities (SSRF, XSS, rate limiting) - Add URL validation to prevent SSRF attacks on notification endpoints - Block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x) - Block localhost and internal hostnames - Only allow HTTP/HTTPS protocols - Add HTML escaping for medication names in email templates (XSS) - Add stricter rate limiting for auth routes (5 req/15min for login/register) - Add SSRF protection tests (405 tests total) * security: add rate limiting to remaining auth routes * chore: add CodeQL config to suppress rate-limit false positives Rate limiting IS implemented via @fastify/rate-limit plugin: - Global: 100 req/min (index.ts) - Auth routes: 5-10 req/min via config.rateLimit option CodeQL doesn't recognize Fastify's plugin-based rate limiting pattern. * ci: switch to CodeQL Advanced Setup - Add custom codeql.yml workflow - Configure to use codeql-config.yml - Exclude js/missing-rate-limiting rule (false positive) Rate limiting is implemented via @fastify/rate-limit plugin * ci: add explicit permissions to workflows Fixes CodeQL 'Workflow does not contain permissions' warnings. Sets minimal 'contents: read' at top level. * ci: add manual trigger to CodeQL workflow * ci: add explicit permissions to all workflow jobs * build(deps): bump esbuild, @vitest/coverage-v8 and vitest in /backend Bumps [esbuild](https://github.com/evanw/esbuild) to 0.27.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together. Updates `esbuild` from 0.21.5 to 0.27.2 - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2) Updates `@vitest/coverage-v8` from 2.1.9 to 4.0.16 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/coverage-v8) Updates `vitest` from 2.1.9 to 4.0.16 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.27.2 dependency-type: indirect - dependency-name: "@vitest/coverage-v8" dependency-version: 4.0.16 dependency-type: direct:development - dependency-name: vitest dependency-version: 4.0.16 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> * docs: add GitHub issue templates - Bug report template with deployment type, browser info, logs - Feature request template with affected area, priority - Config with link to discussions and README - Optimize test.yml to skip tests for non-code changes * Initial plan * Remove database schema duplication by creating shared schema-sql.ts module Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * Refactor frontend date formatting to eliminate duplication Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * docs: Add branch protection warning and PR workflow to instructions * ci: remove paths filter from test workflow to fix branch protection * fix: add .js extension to schema-sql imports for ESM compatibility (#15) * feat: add setting to skip reminders for taken doses - Add skipRemindersForTakenDoses setting to database schema - Extend settings API to save and load new setting - Update intake reminder scheduler to filter taken doses - Add frontend toggle in settings with i18n (EN/DE) - Only check doses from today (timezone-aware) - Update all test schemas with new field - All 405 tests passing * feat: add repeat reminders for missed doses - Add repeatRemindersEnabled and reminderRepeatIntervalMinutes settings - Refactor intake reminder state from array to object with sendCount tracking - Update scheduler to send repeated reminders at configurable intervals - Only remind for today's doses (timezone-aware filtering) - Add frontend toggle and interval input (5-480 minutes) in settings - Maintain backward compatibility for old state file format - Update all test schemas and assertions - All 406 tests passing * feat: add nagging reminders with max limit and ENV defaults - Add maxNaggingReminders setting to limit repeat reminders (1-20) - Add ENV defaults for all user settings (DEFAULT_*) - Add ALTER TABLE migrations for backward compatibility - Add smtpConfigured/shoutrrrConfigured to health endpoint - Fix Push toggle to allow enabling without existing URL - Disable skip/repeat toggles when no notifications enabled - Add Pocket ID button to registration page - Add getTodaysIntakes() for repeat reminder logic - Update translations (en/de) for new settings - Add comprehensive tests for new features --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
This commit is contained in:
@@ -188,6 +188,70 @@ export type UpcomingIntake = {
|
||||
pillWeightMg: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all intakes for today (past and future) - used for repeat reminders.
|
||||
* Returns all intakes scheduled for today in user's timezone.
|
||||
*/
|
||||
export function getTodaysIntakes(
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string
|
||||
): UpcomingIntake[] {
|
||||
const timezone = tz ?? getTimezone();
|
||||
const now = new Date();
|
||||
|
||||
// Get start and end of today in user's timezone
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const intakes: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = new Date(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find all occurrences that fall within today
|
||||
let currentTime = startTime;
|
||||
|
||||
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||
if (currentTime < todayStart.getTime()) {
|
||||
const elapsed = todayStart.getTime() - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
currentTime = startTime + intervals * intervalMs;
|
||||
}
|
||||
|
||||
// Collect all intakes for today
|
||||
while (currentTime <= todayEnd.getTime()) {
|
||||
if (currentTime >= todayStart.getTime()) {
|
||||
const intakeDate = new Date(currentTime);
|
||||
intakes.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
return intakes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming intakes that fall within the reminder window.
|
||||
* Returns intakes that should be notified about right now.
|
||||
@@ -277,8 +341,14 @@ export type ReminderState = {
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
};
|
||||
|
||||
export type IntakeReminderEntry = {
|
||||
firstSentAt: number; // Timestamp when first reminder was sent
|
||||
lastSentAt: number; // Timestamp when last reminder was sent
|
||||
sendCount: number; // How many times reminder was sent
|
||||
};
|
||||
|
||||
export type IntakeReminderState = {
|
||||
sentReminders: string[];
|
||||
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||
};
|
||||
|
||||
/** Create default reminder state */
|
||||
@@ -295,7 +365,7 @@ export function createDefaultReminderState(): ReminderState {
|
||||
|
||||
/** Create default intake reminder state */
|
||||
export function createDefaultIntakeReminderState(): IntakeReminderState {
|
||||
return { sentReminders: [] };
|
||||
return { reminders: {} };
|
||||
}
|
||||
|
||||
/** Parse reminder state from JSON string */
|
||||
@@ -315,12 +385,28 @@ export function parseReminderState(json: string): ReminderState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse intake reminder state from JSON string */
|
||||
/** Parse intake reminder state from JSON string (backward compatible) */
|
||||
export function parseIntakeReminderState(json: string): IntakeReminderState {
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
|
||||
// Backward compatibility: convert old array format to new map format
|
||||
if (Array.isArray(saved.sentReminders)) {
|
||||
const reminders: Record<string, IntakeReminderEntry> = {};
|
||||
const now = Date.now();
|
||||
for (const key of saved.sentReminders) {
|
||||
reminders[key] = {
|
||||
firstSentAt: now,
|
||||
lastSentAt: now,
|
||||
sendCount: 1,
|
||||
};
|
||||
}
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
// New format
|
||||
return {
|
||||
sentReminders: saved.sentReminders ?? [],
|
||||
reminders: saved.reminders ?? {},
|
||||
};
|
||||
} catch {
|
||||
return createDefaultIntakeReminderState();
|
||||
@@ -328,10 +414,21 @@ export function parseIntakeReminderState(json: string): IntakeReminderState {
|
||||
}
|
||||
|
||||
/** Clean up old intake reminder entries (older than given milliseconds) */
|
||||
export function cleanOldIntakeReminders(sentReminders: string[], maxAgeMs: number = 24 * 60 * 60 * 1000): string[] {
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
return sentReminders.filter(key => {
|
||||
/** Clean up old intake reminder entries (using timezone-aware day check) */
|
||||
export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
|
||||
// Get start of today in user's timezone
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayStartMs = todayStart.getTime();
|
||||
|
||||
// Keep only reminders from today onwards (based on dose timestamp in key)
|
||||
const cleaned: Record<string, IntakeReminderEntry> = {};
|
||||
for (const [key, entry] of Object.entries(reminders)) {
|
||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||
return timestamp > cutoff;
|
||||
});
|
||||
if (timestamp >= todayStartMs) {
|
||||
cleaned[key] = entry;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user