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:
+101
-8
@@ -289,6 +289,10 @@ function AppContent() {
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
@@ -627,6 +631,10 @@ function AppContent() {
|
||||
notificationEmail: settings.notificationEmail,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
@@ -1927,7 +1935,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailStockReminders}
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -1938,7 +1946,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrStockReminders}
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -1952,7 +1960,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailIntakeReminders}
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -1963,7 +1971,7 @@ function AppContent() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrIntakeReminders}
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -1975,16 +1983,94 @@ function AppContent() {
|
||||
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
|
||||
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
|
||||
)}
|
||||
|
||||
{/* Skip reminders for taken doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border-color)"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.skipTakenDoses')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.skipRemindersForTakenDoses}
|
||||
onChange={(e) => setSettings({ ...settings, skipRemindersForTakenDoses: e.target.checked })}
|
||||
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Repeat reminders for missed doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "12px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.repeatReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.repeatRemindersTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatRemindersEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, repeatRemindersEnabled: e.target.checked })}
|
||||
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Reminder interval (only shown when repeat is enabled) */}
|
||||
{settings.repeatRemindersEnabled && (
|
||||
<>
|
||||
<div className="setting-row compact" style={{marginTop: "12px", marginLeft: "24px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.reminderInterval')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.reminderIntervalTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="480"
|
||||
step="5"
|
||||
value={settings.reminderRepeatIntervalMinutes}
|
||||
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.maxNaggingReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.maxNaggingRemindersTooltip')}>ⓘ</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
value={settings.maxNaggingReminders ?? 5}
|
||||
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.email')}</h3>
|
||||
<label className="toggle-switch small">
|
||||
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, emailEnabled: e.target.checked })}
|
||||
checked={settings.smtpHost ? settings.emailEnabled : false}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.shoutrrrEnabled) {
|
||||
setSettings({ ...settings, emailEnabled: false, emailStockReminders: false, emailIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
} else {
|
||||
setSettings({ ...settings, emailEnabled: newVal });
|
||||
}
|
||||
}}
|
||||
disabled={!settings.smtpHost}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
@@ -2028,7 +2114,14 @@ function AppContent() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrEnabled}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrEnabled: e.target.checked })}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.emailEnabled) {
|
||||
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
} else {
|
||||
setSettings({ ...settings, shoutrrrEnabled: newVal });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
|
||||
@@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
||||
{t("auth.register", "Create Account")}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{/* SSO Login Button - also show on registration */}
|
||||
{authState?.oidcEnabled && (
|
||||
<div className="auth-sso">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary auth-submit sso-btn"
|
||||
onClick={() => window.location.href = "/api/auth/oidc/login"}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sso-icon">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
||||
</button>
|
||||
{authState?.localAuthEnabled && (
|
||||
<div className="auth-divider">
|
||||
<span>{t("auth.or", "or")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Registration Form - only show if local auth is enabled */}
|
||||
{authState?.localAuthEnabled && (
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
||||
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{onSwitchToLogin && (
|
||||
<div className="auth-links">
|
||||
|
||||
@@ -157,7 +157,15 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Bestands-Erinnerungen",
|
||||
"intakeReminders": "Einnahme-Erinnerungen",
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten."
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
||||
"repeatRemindersTooltip": "Sende automatisch wiederholte Erinnerungen für Dosen, die noch nicht als genommen markiert wurden",
|
||||
"reminderInterval": "Erinnerungsintervall (Minuten)",
|
||||
"reminderIntervalTooltip": "Wie oft wiederholte Erinnerungen für verpasste Dosen gesendet werden sollen",
|
||||
"maxNaggingReminders": "Max. Erinnerungen pro Dosis",
|
||||
"maxNaggingRemindersTooltip": "Wiederholungserinnerungen nach dieser Anzahl Versuchen stoppen (1-20)"
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Empfänger",
|
||||
|
||||
@@ -159,7 +159,15 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Stock Reminders",
|
||||
"intakeReminders": "Intake Reminders",
|
||||
"enableHint": "Enable at least one channel below to receive notifications."
|
||||
"enableHint": "Enable at least one channel below to receive notifications.",
|
||||
"skipTakenDoses": "Skip reminders for taken doses",
|
||||
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
|
||||
"repeatReminders": "Repeat reminders for missed doses",
|
||||
"repeatRemindersTooltip": "Automatically send repeated reminders for doses that haven't been marked as taken",
|
||||
"reminderInterval": "Reminder interval (minutes)",
|
||||
"reminderIntervalTooltip": "How often to send repeated reminders for missed doses",
|
||||
"maxNaggingReminders": "Max reminders per dose",
|
||||
"maxNaggingRemindersTooltip": "Stop sending repeat reminders after this many attempts (1-20)"
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Recipient",
|
||||
|
||||
Reference in New Issue
Block a user