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:
Daniel Volz
2026-01-10 21:05:44 +01:00
committed by GitHub
parent e754729e08
commit d0a40bde88
18 changed files with 1018 additions and 123 deletions
+101 -8
View File
@@ -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>
+27 -1
View File
@@ -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">
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",