Compare commits

...

4 Commits

Author SHA1 Message Date
Daniel Volz ffab9ef4da feat: Add data export/import functionality (#22)
* feat: add data export/import functionality

- Add /export and /import API endpoints with schema-independent JSON format
- Export includes: medications, dose history, settings, share links
- Uses _exportId references for medications, remapped on import
- Images exported as base64 data URLs
- Optional sensitive data inclusion (shoutrrr URLs, etc.)
- Import replaces all existing data with confirmation warning
- Add comprehensive test coverage
- Add English and German translations
- Add frontend UI in Settings page with export/import controls

* fix: correct JSX structure and TypeScript types

- Fix modal placement outside ternary expression in Settings
- Add type assertion for request.body in import route test

* docs: translate copilot-instructions to English

- Add explicit rule that English is the primary language
- Translate all German sections to English
- User may communicate in German, but all project artifacts must be English
2026-01-16 19:59:48 +01:00
Daniel Volz ed707444a2 chore: release v1.1.0 (#19) 2026-01-10 21:29:53 +01:00
Daniel Volz d0a40bde88 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>
2026-01-10 21:05:44 +01:00
dependabot[bot] e754729e08 build(deps): bump react-router and react-router-dom in /frontend (#17)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.12.0 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router)

Updates `react-router-dom` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.12.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.12.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 07:58:44 +01:00
25 changed files with 2778 additions and 193 deletions
+40
View File
@@ -79,3 +79,43 @@ REMINDER_DAYS_BEFORE=7
REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM
REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder
EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# =============================================================================
# Default User Settings (applied when new user is created)
# =============================================================================
# These ENV values are only used as DEFAULTS when a new user is created.
# Once a user saves their settings in the app, these ENV values are ignored
# for that user - their saved preferences take precedence.
#
# Useful for server admins to pre-configure settings for all new users.
# =============================================================================
# Email notifications (requires SMTP config above)
# DEFAULT_EMAIL_ENABLED=false
# DEFAULT_NOTIFICATION_EMAIL=
# DEFAULT_EMAIL_STOCK_REMINDERS=true
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
# Push notifications (ntfy/gotify via Shoutrrr)
# DEFAULT_SHOUTRRR_ENABLED=false
# DEFAULT_SHOUTRRR_URL=
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
# Repeat/nagging reminders for missed doses
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
# DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES=30
# DEFAULT_MAX_NAGGING_REMINDERS=5
# DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES=false
# Stock reminder settings
# DEFAULT_REPEAT_DAILY_REMINDERS=false
# Stock thresholds (days of supply)
# DEFAULT_LOW_STOCK_DAYS=30
# DEFAULT_NORMAL_STOCK_DAYS=90
# DEFAULT_HIGH_STOCK_DAYS=180
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
+206 -65
View File
@@ -1,5 +1,11 @@
# MedAssist-ng - AI Coding Instructions # MedAssist-ng - AI Coding Instructions
## General Rules
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
## Architecture Overview ## Architecture Overview
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure: MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
@@ -41,25 +47,25 @@ cd backend && npm run test:coverage # Run with coverage report
## Testing (MANDATORY) ## Testing (MANDATORY)
> ⚠️ **WICHTIG**: Jede neue Funktionalität MUSS mit Tests abgedeckt werden! > ⚠️ **IMPORTANT**: Every new feature MUST be covered by tests!
> Pull Requests ohne Tests für neue Features werden nicht akzeptiert. > Pull Requests without tests for new features will not be accepted.
### Test-Framework ### Test Framework
- **Vitest 2.1** mit v8 Coverage - **Vitest 2.1** with v8 Coverage
- Tests in `backend/src/test/*.test.ts` - Tests in `backend/src/test/*.test.ts`
- Coverage-Ziel: Mindestens gleiche oder bessere Coverage nach Änderungen - Coverage goal: At least equal or better coverage after changes
### Test-Struktur ### Test Structure
| Datei | Testet | | File | Tests |
|-------|--------| |------|-------|
| `routes.test.ts` | API-Endpunkte (Auth, Medications, Doses, Settings, Share, Planner) | | `routes.test.ts` | API endpoints (Auth, Medications, Doses, Settings, Share, Planner) |
| `services.test.ts` | Scheduler-Utilities (Timezone, Blisters, Usage-Berechnung) | | `services.test.ts` | Scheduler utilities (Timezone, Blisters, Usage calculation) |
| `db.test.ts` | Datenbank-Schema und Operationen | | `db.test.ts` | Database schema and operations |
### Tests schreiben ### Writing Tests
```typescript ```typescript
// Backend Test Beispiel (backend/src/test/example.test.ts) // Backend Test Example (backend/src/test/example.test.ts)
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
@@ -90,21 +96,24 @@ describe('Feature Name', () => {
}); });
``` ```
### Test-Commands ### Test Commands
```bash ```bash
cd backend cd backend
npm test # Alle Tests ausführen CI=true npm test # Run tests once (ALWAYS run this way!)
npm run test:coverage # Mit Coverage-Report CI=true npm run test:coverage # With coverage report
npm test -- --watch # Watch-Mode für Entwicklung npm test -- --watch # Watch mode for manual development
npm test -- -t "test name" # Einzelnen Test ausführen npm test -- -t "test name" # Run single test
``` ```
> ⚠️ **IMPORTANT for AI agents**: ALWAYS run tests with `CI=true`!
> Without `CI=true`, Vitest runs in watch mode and waits for input.
## CI/CD Pipeline (GitHub Actions) ## CI/CD Pipeline (GitHub Actions)
### Workflow-Übersicht ### Workflow Overview
``` ```
Pull Request erstellt Pull Request created
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ test.yml │ │ test.yml │
@@ -116,65 +125,141 @@ Pull Request erstellt
│ ├─ npm ci │ │ ├─ npm ci │
│ └─ npm run build │ │ └─ npm run build │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
↓ Tests müssen bestehen ↓ Tests must pass
PR kann gemerged werden PR can be merged
Push to main / Tag erstellt Push to main / Tag created
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ docker-build.yml │ │ docker-build.yml │
│ ├─ backend-test (parallel) │ │ ├─ backend-test (parallel) │
│ ├─ frontend-build (parallel) │ │ ├─ frontend-build (parallel) │
│ └─ build-and-push (nach Tests) │ └─ build-and-push (after tests) │
│ ├─ Docker Images bauen │ ├─ Build Docker images │
│ └─ Push zu GHCR │ │ └─ Push to GHCR │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
``` ```
### Branch Protection ### Branch Protection
> ⚠️ **WICHTIG**: Der `main` Branch ist geschützt! > ⚠️ **IMPORTANT**: The `main` branch is protected!
> Direktes Pushen nach `main` ist **nicht möglich** - GitHub lehnt den Push ab. > Direct pushing to `main` is **not possible** - GitHub will reject the push.
> Alle Änderungen müssen über Pull Requests erfolgen. > All changes must go through Pull Requests.
- **main** Branch ist geschützt (Repository Rules) - **main** branch is protected (Repository Rules)
- Direktes Pushen wird von GitHub abgelehnt mit: `GH013: Repository rule violations` - Direct pushing is rejected by GitHub with: `GH013: Repository rule violations`
- PRs benötigen: - PRs require:
-`backend-test` Status Check bestanden -`backend-test` Status Check passed
-`frontend-build` Status Check bestanden -`frontend-build` Status Check passed
- Nach erfolgreichem Merge wird der Feature-Branch automatisch gelöscht - After successful merge, the feature branch is automatically deleted
**Workflow für Änderungen:** **Workflow for changes:**
```bash ```bash
# 1. Feature Branch erstellen # 1. Create feature branch
git checkout -b feat/mein-feature git checkout -b feat/my-feature
# 2. Änderungen committen und pushen # 2. Commit and push changes
git add . && git commit -m "feat: Beschreibung" git add . && git commit -m "feat: Description"
git push -u origin feat/mein-feature git push -u origin feat/my-feature
# 3. PR erstellen (via GitHub CLI oder Web) # 3. Create PR (via GitHub CLI or Web)
gh pr create --title "Mein Feature" --body "Beschreibung" gh pr create --title "My Feature" --body "Description"
# 4. Warten bis CI grün ist, dann mergen # 4. Wait until CI is green, then merge
gh pr merge --squash --delete-branch gh pr merge --squash --delete-branch
``` ```
### Workflow-Dateien ### Workflow Files
| Datei | Trigger | Zweck | | File | Trigger | Purpose |
|-------|---------|-------| |------|---------|--------|
| `.github/workflows/test.yml` | Pull Requests | Tests ausführen, PR blockieren bei Fehlern | | `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Docker Images bauen und pushen | | `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images |
### Neuen Code hinzufügen - Checkliste ### Adding New Code - Checklist
1.Feature implementieren 1.Implement feature
2.Tests für das Feature schreiben 2.Write tests for the feature
3.Lokal `npm run test:coverage` ausführen 3.Run `npm run test:coverage` locally
4. ✅ Coverage darf nicht sinken 4. ✅ Coverage must not decrease
5.Feature Branch erstellen und pushen 5.Create and push feature branch
6. ✅ Pull Request erstellen 6. Create Pull Request
7. ✅ Warten bis CI grün ist 7. ✅ Wait until CI is green
8.PR mergen (Branch wird automatisch gelöscht) 8.Merge PR (branch is automatically deleted)
## GitHub Releases
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
### Creating Release Notes
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
> Not just auto-generated commit lists, but a brief descriptive text.
**Structure of a release text:**
1. **Intro** (1-2 sentences): What's new, what was improved?
2. **Features & Changes**: Brief list of key changes
3. **Breaking Changes Warning** (if applicable): See below
4. **Optional**: Acknowledgements, documentation links
**Example of good release notes:**
```markdown
## What's New
This release adds intake reminder notifications and improves medication stock tracking. Users can now configure nagging reminders for missed doses and receive alerts when medication stock runs low.
### New Features
- 🔔 Intake reminder notifications with configurable nagging intervals
- 📊 Enhanced stock calculation with blister tracking
- 🌐 German translation improvements
### Bug Fixes
- Fixed timezone handling in dose scheduling
- Improved image upload validation
### Full Changelog
[All commits since v1.2.0](link)
```
### Breaking Changes Warning (CRITICAL!)
> ⚠️ **MANDATORY**: If an update breaks existing configurations or stored data, it MUST be prominently warned about in the release notes!
**Breaking Changes include:**
- Database schema changes without automatic migration
- Removed or renamed ENV variables
- Changed API endpoints
- Incompatible `.env` format changes
- Loss of stored data after update
**Format for Breaking Changes:**
```markdown
## ⚠️ BREAKING CHANGES - Please read before updating!
**Database migration required**: This update changes the database schema.
Existing installations need to:
1. Create backup of `data/` folder
2. Stop containers
3. Perform update
4. If issues occur: Rollback using backup
**ENV variables changed**:
- `OLD_VAR` was renamed to `NEW_VAR`
- `REMOVED_VAR` is no longer supported
**Medication data**: Intake schedules with only one time entry will be automatically
migrated. Please verify all times are correct after update.
```
**What is NOT a Breaking Change:**
- ✅ New optional columns with DEFAULT values
- ✅ New ENV variables (with sensible defaults)
- ✅ New features that don't affect existing data
- ✅ Bug fixes that correct behavior
**Rule of thumb**: If a user can simply run `docker compose pull && docker compose up -d`
without adjusting anything → Not a Breaking Change.
## Key Patterns ## Key Patterns
@@ -283,9 +368,25 @@ Each blister defines a recurring intake:
| Stock Thresholds | Warning days, critical days, expiry warning days | | Stock Thresholds | Warning days, critical days, expiry warning days |
| Email Notifications | Enable, email address, stock/intake toggles | | Email Notifications | Enable, email address, stock/intake toggles |
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles | | Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles |
| Reminder Settings | Days before, repeat daily | | Reminder Settings | Days before, repeat daily, skip for taken, repeat/nagging |
| SMTP | Email config (read-only from .env) | | SMTP | Email config (read-only from .env) |
### Settings ENV Defaults
All user settings can be pre-configured via ENV variables (see `.env.example`).
These are only used as **defaults when a new user is created**.
Once a user saves settings in the app, their saved values take precedence over ENV.
| ENV Variable | Setting | Default |
|--------------|---------|---------|
| `DEFAULT_EMAIL_ENABLED` | Email notifications | false |
| `DEFAULT_SHOUTRRR_ENABLED` | Push notifications | false |
| `DEFAULT_SHOUTRRR_URL` | ntfy/gotify URL | (empty) |
| `DEFAULT_REPEAT_REMINDERS_ENABLED` | Nagging reminders | false |
| `DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES` | Nag interval | 30 |
| `DEFAULT_MAX_NAGGING_REMINDERS` | Max nags | 5 |
| `DEFAULT_LOW_STOCK_DAYS` | Low stock threshold | 30 |
| `DEFAULT_LANGUAGE` | UI language | en |
## Database Schema (`backend/src/db/schema.ts`) ## Database Schema (`backend/src/db/schema.ts`)
| Table | Description | | Table | Description |
@@ -347,14 +448,54 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars - **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json` - **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
## Database Schema Changes ## Database Schema Changes (IMPORTANT: Backward Compatibility!)
When adding new database columns: > ⚠️ **CRITICAL**: The app MUST remain backward compatible with older databases!
> Users upgrade their Docker containers but keep their existing DB.
> The app must NOT crash if old columns are missing.
1. **Update schema**: `backend/src/db/schema.ts` - Add the Drizzle column definition ### Rules for New Columns
2. **Update client.ts**: `backend/src/db/client.ts` - Add column to `CREATE TABLE IF NOT EXISTS`
3. **Update migrate.ts**: `backend/src/db/migrate.ts` - Same as client.ts 1. **ALWAYS with DEFAULT value**: New columns must have `NOT NULL DEFAULT <value>`
4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart 2. **NULL-safe in code**: All queries must use `?? defaultValue` or `?? false`
3. **Update schema SQL**: Add to these files:
- `backend/src/db/schema.ts` - Drizzle Schema
- `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` for new DBs
- `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` migration
4. **Update test schemas**: All test files with their own schema:
- `backend/src/test/e2e-routes.test.ts`
- `backend/src/test/integration.test.ts`
- `backend/src/test/planner.test.ts`
### Example: Adding a New Column
```typescript
// 1. schema.ts - Drizzle definition
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
// 2. schema-sql.ts - For new databases
"max_nagging_reminders integer NOT NULL DEFAULT 5,"
// 3. client.ts - Migration for existing DBs (IN ensureTablesExist())
await client.execute(`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`).catch(() => {});
// 4. Routes - NULL-safe reading
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
```
### What is NOT Allowed
- ❌ Deleting or renaming columns (breaks old DBs)
-`NOT NULL` without `DEFAULT` (INSERT fails)
- ❌ Reading columns without fallback in code
- ❌ Documenting "delete DB" as a solution
### When Backward Compatibility is NOT Possible
If a breaking change is unavoidable:
1. **Explicitly communicate**: Document in release notes
2. **Migration script**: Provide automatic upgrade script
3. **Version check**: App should check DB version and warn
## File Locations ## File Locations
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.0.2", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+21
View File
@@ -60,6 +60,27 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo
} }
} }
// Run ALTER TABLE migrations for backward compatibility with older databases
// These add new columns to existing tables (silently fail if column already exists)
const alterMigrations = [
// Added in v1.x - repeat reminders and nagging settings
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
];
for (const sql of alterMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
// Silently ignore "duplicate column" errors - column already exists
if (!e.message?.includes("duplicate column")) {
errors.push(e.message);
}
}
}
return { success: errors.length === 0, errors }; return { success: errors.length === 0, errors };
} }
+4
View File
@@ -55,6 +55,10 @@ export function getTableCreationSQL(): string[] {
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7, reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0, repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
max_nagging_reminders integer NOT NULL DEFAULT 5,
low_stock_days integer NOT NULL DEFAULT 30, low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90, normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180, high_stock_days integer NOT NULL DEFAULT 180,
+4
View File
@@ -60,6 +60,10 @@ export const userSettings = sqliteTable("user_settings", {
// Reminder settings // Reminder settings
reminderDaysBefore: integer("reminder_days_before").notNull().default(7), reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false), repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false),
repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false),
reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30),
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
// Stock thresholds (days) // Stock thresholds (days)
lowStockDays: integer("low_stock_days").notNull().default(30), lowStockDays: integer("low_stock_days").notNull().default(30),
normalStockDays: integer("normal_stock_days").notNull().default(90), normalStockDays: integer("normal_stock_days").notNull().default(90),
+3
View File
@@ -19,6 +19,7 @@ import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js"; import { plannerRoutes } from "./routes/planner.js";
import { shareRoutes } from "./routes/share.js"; import { shareRoutes } from "./routes/share.js";
import { doseRoutes } from "./routes/doses.js"; import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
@@ -113,6 +114,7 @@ export async function createApp(options?: {
await app.register(plannerRoutes); await app.register(plannerRoutes);
await app.register(shareRoutes); await app.register(shareRoutes);
await app.register(doseRoutes); await app.register(doseRoutes);
await app.register(exportRoutes);
return app; return app;
} }
@@ -181,6 +183,7 @@ await app.register(settingsRoutes);
await app.register(plannerRoutes); await app.register(plannerRoutes);
await app.register(shareRoutes); await app.register(shareRoutes);
await app.register(doseRoutes); await app.register(doseRoutes);
await app.register(exportRoutes);
const start = async () => { const start = async () => {
try { try {
+499
View File
@@ -0,0 +1,499 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, userSettings, doseTracking, shareTokens } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { resolve, extname } from "path";
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.0";
// =============================================================================
// Zod Schemas for Import Validation
// =============================================================================
const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
remind: z.boolean().optional().default(false),
});
const inventorySchema = z.object({
packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
});
const medicationExportSchema = z.object({
_exportId: z.string(),
name: z.string().min(1),
genericName: z.string().nullable().optional(),
takenBy: z.array(z.string()).default([]),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
schedules: z.array(scheduleSchema).default([]),
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
image: z.string().nullable().optional(), // base64 data URL or null
});
const doseHistorySchema = z.object({
medicationRef: z.string(), // References _exportId
scheduleIndex: z.number().int().min(0),
scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(),
});
const shareLinkSchema = z.object({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
expiresAt: z.string().nullable().optional(), // ISO datetime
regenerateToken: z.boolean().default(true),
});
const settingsExportSchema = z.object({
// Email notifications
emailEnabled: z.boolean().default(false),
notificationEmail: z.string().nullable().optional(),
emailStockReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true),
// Push notifications
shoutrrrEnabled: z.boolean().optional(),
shoutrrrUrl: z.string().nullable().optional(),
shoutrrrStockReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true),
// Reminder settings
reminderDaysBefore: z.number().int().default(7),
repeatDailyReminders: z.boolean().default(false),
skipRemindersForTakenDoses: z.boolean().default(false),
repeatRemindersEnabled: z.boolean().default(false),
reminderRepeatIntervalMinutes: z.number().int().default(30),
maxNaggingReminders: z.number().int().default(5),
// Stock thresholds
lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180),
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
}).optional();
const importDataSchema = z.object({
version: z.string(),
exportedAt: z.string(),
includeSensitiveData: z.boolean().default(false),
medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]),
settings: settingsExportSchema,
shareLinks: z.array(shareLinkSchema).default([]),
});
// =============================================================================
// Helper Functions
// =============================================================================
// Helper to get user ID from request
async function getUserId(request: any, reply: any): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// Parse takenByJson safely
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
// Parse blisters from DB format to export format
function parseBlistersForExport(row: typeof medications.$inferSelect): Array<{ usage: number; every: number; start: string; remind: boolean }> {
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 schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = [];
for (let i = 0; i < len; i++) {
schedules.push({
usage: usage[i],
every: every[i],
start: start[i],
remind: row.intakeRemindersEnabled ?? false,
});
}
return schedules;
} catch {
return [];
}
}
// Read image file and convert to base64 data URL
function imageToBase64(imageUrl: string | null): string | null {
if (!imageUrl) return null;
const imagePath = resolve(IMAGES_DIR, imageUrl);
if (!existsSync(imagePath)) return null;
try {
const imageBuffer = readFileSync(imagePath);
const ext = extname(imageUrl).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
};
const mimeType = mimeTypes[ext] || "image/jpeg";
return `data:${mimeType};base64,${imageBuffer.toString("base64")}`;
} catch {
return null;
}
}
// Save base64 image to file and return filename
function base64ToImage(base64: string, medicationId: number): string | null {
if (!base64 || !base64.startsWith("data:")) return null;
try {
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
const matches = base64.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) return null;
const ext = matches[1] === "jpeg" ? "jpg" : matches[1];
const data = matches[2];
const buffer = Buffer.from(data, "base64");
const filename = `med-${medicationId}-${Date.now()}.${ext}`;
const filepath = resolve(IMAGES_DIR, filename);
// Ensure images directory exists
if (!existsSync(IMAGES_DIR)) {
mkdirSync(IMAGES_DIR, { recursive: true });
}
writeFileSync(filepath, buffer);
return filename;
} catch {
return null;
}
}
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}"
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number } | null {
const parts = doseId.split("-");
if (parts.length < 3) return null;
const medicationId = parseInt(parts[0], 10);
const blisterIndex = parseInt(parts[1], 10);
const timestampMs = parseInt(parts[2], 10);
if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null;
return { medicationId, blisterIndex, timestampMs };
}
// Build dose ID from parts
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number): string {
return `${medicationId}-${blisterIndex}-${timestampMs}`;
}
// =============================================================================
// Export Routes
// =============================================================================
export async function exportRoutes(app: FastifyInstance) {
// All export routes require auth
app.addHook("preHandler", requireAuth);
// ---------------------------------------------------------------------------
// GET /export - Export all user data
// ---------------------------------------------------------------------------
app.get<{ Querystring: { includeSensitive?: string } }>(
"/export",
async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
return {
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
looseTablets: med.looseTablets ?? 0,
},
pillWeightMg: med.pillWeightMg,
schedules: parseBlistersForExport(med),
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
image: imageToBase64(med.imageUrl),
};
});
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const exportDoseHistory = doses.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: new Date(parsed.timestampMs).toISOString(),
takenAt: dose.takenAt?.toISOString() ?? new Date().toISOString(),
markedBy: dose.markedBy,
};
}).filter((d): d is NonNullable<typeof d> => d !== null);
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const exportSettings = settings ? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
} : undefined;
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => ({
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt?.toISOString() ?? null,
regenerateToken: true, // Always regenerate tokens on import for security
}));
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// Set download headers
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return exportData;
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
app.post(
"/import",
async (request, reply) => {
const userId = await getUserId(request, reply);
// 1. Parse and validate import data
const parsed = importDataSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid import data format",
details: parsed.error.format(),
});
}
const importData = parsed.data;
// 2. Delete all existing user data (in correct order to respect foreign keys)
// Note: CASCADE delete should handle this, but let's be explicit
// First, delete images for existing medications
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
for (const med of existingMeds) {
if (med.imageUrl) {
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
if (existsSync(imagePath)) {
try { unlinkSync(imagePath); } catch { /* ignore */ }
}
}
}
// Delete in order: doses, share tokens, medications, settings
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
await db.delete(medications).where(eq(medications.userId, userId));
await db.delete(userSettings).where(eq(userSettings.userId, userId));
// 3. Import medications and build ID mapping
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
// Convert schedules back to JSON arrays
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
const takenByJson = JSON.stringify(med.takenBy);
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
const [inserted] = await db.insert(medications).values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
pillWeightMg: med.pillWeightMg || null,
usageJson,
everyJson,
startJson,
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
imageUrl: null, // Will be set after image is saved
}).returning();
// Save mapping
exportIdToNewId.set(med._exportId, inserted.id);
// Save image if present
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
await db.update(medications)
.set({ imageUrl })
.where(eq(medications.id, inserted.id));
}
}
}
// 4. Import dose history with remapped medication IDs
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue; // Skip orphaned doses
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
});
}
// 5. Import settings
if (importData.settings) {
await db.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
});
}
// 6. Import share links (with new tokens)
for (const share of importData.shareLinks) {
// Always generate new token for security
const token = randomBytes(8).toString("hex");
await db.insert(shareTokens).values({
userId,
token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
return {
success: true,
imported: {
medications: importData.medications.length,
doseHistory: importData.doseHistory.length,
settings: importData.settings ? 1 : 0,
shareLinks: importData.shareLinks.length,
},
};
}
);
}
+5 -1
View File
@@ -1,5 +1,9 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) { export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async () => ({ status: "ok" })); app.get("/health", async () => ({
status: "ok",
smtpConfigured: Boolean(process.env.SMTP_HOST),
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
}));
} }
+68 -23
View File
@@ -21,6 +21,10 @@ export type UserSettings = {
shoutrrrIntakeReminders: boolean; shoutrrrIntakeReminders: boolean;
reminderDaysBefore: number; reminderDaysBefore: number;
repeatDailyReminders: boolean; repeatDailyReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
maxNaggingReminders: number;
lowStockDays: number; lowStockDays: number;
normalStockDays: number; normalStockDays: number;
highStockDays: number; highStockDays: number;
@@ -45,6 +49,10 @@ type SettingsBody = {
emailIntakeReminders: boolean; emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean; shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean; shoutrrrIntakeReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
maxNaggingReminders: number;
language: string; language: string;
stockCalculationMode: "automatic" | "manual"; stockCalculationMode: "automatic" | "manual";
}; };
@@ -57,37 +65,58 @@ type TestShoutrrrBody = {
url: string; url: string;
}; };
// Default settings for new users // Helper to parse boolean env vars
const defaultSettings = { function envBool(key: string, defaultVal: boolean): boolean {
emailEnabled: false, const val = process.env[key];
notificationEmail: null, if (val === undefined) return defaultVal;
emailStockReminders: true, return val === "true" || val === "1";
emailIntakeReminders: true, }
shoutrrrEnabled: false,
shoutrrrUrl: null, // Helper to parse integer env vars
shoutrrrStockReminders: true, function envInt(key: string, defaultVal: number): number {
shoutrrrIntakeReminders: true, const val = process.env[key];
reminderDaysBefore: 7, if (val === undefined) return defaultVal;
repeatDailyReminders: false, const parsed = parseInt(val, 10);
lowStockDays: 30, return isNaN(parsed) ? defaultVal : parsed;
normalStockDays: 90, }
highStockDays: 180,
language: "en", // Default settings for new users - read from ENV with fallbacks
stockCalculationMode: "automatic" as const, function getDefaultSettings() {
lastAutoEmailSent: null, return {
lastNotificationType: null, emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
lastNotificationChannel: null, notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
}; emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
lastAutoEmailSent: null,
lastNotificationType: null,
lastNotificationChannel: null,
};
}
// Helper to get or create user settings // Helper to get or create user settings
async function getOrCreateUserSettings(userId: number) { async function getOrCreateUserSettings(userId: number) {
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
if (!settings) { if (!settings) {
// Create default settings for user // Create default settings for user (using ENV defaults)
[settings] = await db.insert(userSettings).values({ [settings] = await db.insert(userSettings).values({
userId, userId,
...defaultSettings, ...getDefaultSettings(),
}).returning(); }).returning();
} }
@@ -109,6 +138,10 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
reminderDaysBefore: settings.reminderDaysBefore, reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders, repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays, lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays, normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays, highStockDays: settings.highStockDays,
@@ -135,6 +168,10 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
reminderDaysBefore: settings.reminderDaysBefore, reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders, repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays, lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays, normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays, highStockDays: settings.highStockDays,
@@ -187,6 +224,10 @@ export async function settingsRoutes(app: FastifyInstance) {
emailIntakeReminders: settings.emailIntakeReminders, emailIntakeReminders: settings.emailIntakeReminders,
shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
language: settings.language, language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic", stockCalculationMode: settings.stockCalculationMode ?? "automatic",
// SMTP settings (from .env - shared/server-configured) // SMTP settings (from .env - shared/server-configured)
@@ -233,6 +274,10 @@ export async function settingsRoutes(app: FastifyInstance) {
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
reminderDaysBefore: body.reminderDaysBefore, reminderDaysBefore: body.reminderDaysBefore,
repeatDailyReminders, repeatDailyReminders,
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: body.maxNaggingReminders ?? 5,
lowStockDays: body.lowStockDays ?? 30, lowStockDays: body.lowStockDays ?? 30,
normalStockDays: body.normalStockDays ?? 90, normalStockDays: body.normalStockDays ?? 90,
highStockDays: body.highStockDays ?? 180, highStockDays: body.highStockDays ?? 180,
+208 -30
View File
@@ -1,7 +1,7 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { eq } from "drizzle-orm"; import { eq, and, gte, lte } from "drizzle-orm";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { medications } from "../db/schema.js"; import { medications, doseTracking } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs"; import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path"; import { resolve } from "path";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
@@ -14,6 +14,7 @@ import {
parseBlisters, parseBlisters,
parseTakenByJson, parseTakenByJson,
getUpcomingIntakes, getUpcomingIntakes,
getTodaysIntakes,
parseIntakeReminderState, parseIntakeReminderState,
createDefaultIntakeReminderState, createDefaultIntakeReminderState,
cleanOldIntakeReminders, cleanOldIntakeReminders,
@@ -46,7 +47,13 @@ function parseBlistersFromRow(row: { usageJson: string; everyJson: string; start
return parseBlisters(row); return parseBlisters(row);
} }
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> { async function sendIntakeReminderEmail(
email: string,
intakes: UpcomingIntake[],
language: Language,
isRepeat: boolean = false,
repeatIntervalMinutes?: number
): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST; const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER; const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
@@ -96,11 +103,16 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
? tr.intakeReminder.alertSingle ? tr.intakeReminder.alertSingle
: t(tr.intakeReminder.alertMultiple, { count: intakes.length }); : t(tr.intakeReminder.alertMultiple, { count: intakes.length });
// Different description for repeat reminders
const description = isRepeat && repeatIntervalMinutes
? `⚠️ Don't forget your medication! This reminder will be sent every ${repeatIntervalMinutes} minutes until you mark it as taken.`
: t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE });
const html = ` const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;"> <div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"> <div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.intakeReminder.title}</h2> <h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.intakeReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}</p> <p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${description}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;"> <div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;">
<p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;"> <p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;">
@@ -142,7 +154,7 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
const plainText = `${tr.intakeReminder.title} const plainText = `${tr.intakeReminder.title}
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })} ${description}
${intakes.map((i) => { ${intakes.map((i) => {
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : ""; const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
@@ -152,7 +164,9 @@ ${intakes.map((i) => {
--- ---
${tr.intakeReminder.footer}`; ${tr.intakeReminder.footer}`;
const subject = t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") }); const subject = isRepeat
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") })}`
: t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
try { try {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@@ -181,13 +195,18 @@ ${tr.intakeReminder.footer}`;
} }
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> { async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
logger.info(`[IntakeReminder] Checking for intake reminders...`);
// Get all user settings to iterate over each user // Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings(); const allUserSettings = await getAllUserSettings();
if (allUserSettings.length === 0) { if (allUserSettings.length === 0) {
logger.info(`[IntakeReminder] No users with settings found`);
return; // No users with settings return; // No users with settings
} }
logger.info(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
for (const userSettings of allUserSettings) { for (const userSettings of allUserSettings) {
await checkAndSendIntakeRemindersForUser(userSettings, logger); await checkAndSendIntakeRemindersForUser(userSettings, logger);
} }
@@ -200,56 +219,190 @@ async function checkAndSendIntakeRemindersForUser(
const language = settings.language; const language = settings.language;
const tr = getTranslations(language); const tr = getTranslations(language);
logger.info(`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`);
// Check if any intake reminder notifications are enabled (granular check) // Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders; const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders; const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
if (!emailEnabled && !shoutrrrEnabled) { if (!emailEnabled && !shoutrrrEnabled) {
logger.info(`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
return; // No intake reminder notifications enabled for this user return; // No intake reminder notifications enabled for this user
} }
logger.info(`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
// Get all medications with intake reminders enabled for this user // 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 rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled); const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) { if (medsWithReminders.length === 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
return; // No medications have reminders enabled for this user return; // No medications have reminders enabled for this user
} }
const state = loadIntakeReminderState(); logger.info(`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`);
const allUpcoming: UpcomingIntake[] = [];
const locale = getDateLocale(language);
// Find all upcoming intakes across all medications for this user const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
const locale = getDateLocale(language);
const tz = getTimezone();
// Get start and end of today in user's timezone (for filtering today's doses only)
const now = new Date();
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
logger.info(`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`);
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) { for (const med of medsWithReminders) {
const blisters = parseBlistersFromRow(med); const blisters = parseBlistersFromRow(med);
const takenByArray = parseTakenByJson(med.takenByJson); const takenByArray = parseTakenByJson(med.takenByJson);
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale);
allUpcoming.push(...upcoming); logger.info(`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters`);
// Process each blister separately to track blisterIndex
blisters.forEach((blister, blisterIndex) => {
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - start: ${blister.start}, every: ${blister.every} days, usage: ${blister.usage}`);
// Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes(med.name, [blister], REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale, tz);
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`);
// Add upcoming intakes for first reminders
allUpcoming.push(...upcomingIntakes.map(intake => ({
...intake,
medicationId: med.id,
blisterIndex,
})));
// If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) {
const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz);
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map(i => i.intakeTime.toISOString()).join(', ')}`);
const missedIntakes = allTodaysIntakes.filter(intake => intake.intakeTime.getTime() < now.getTime());
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${missedIntakes.length} missed intakes (past intake time)`);
// Add missed intakes for repeat reminders (only if not already in upcoming list)
const upcomingTimes = new Set(upcomingIntakes.map(i => i.intakeTime.getTime()));
allUpcoming.push(...missedIntakes
.filter(intake => !upcomingTimes.has(intake.intakeTime.getTime()))
.map(intake => ({
...intake,
medicationId: med.id,
blisterIndex,
})));
}
});
} }
logger.info(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
if (allUpcoming.length === 0) { if (allUpcoming.length === 0) {
return; // No upcoming intakes in the window logger.info(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
return; // No upcoming intakes for today
} }
// Filter out already-sent reminders (keyed by user) // Determine which doses need reminders (new or repeated)
const newReminders = allUpcoming.filter(intake => { const nowMs = Date.now();
let remindersToSend: typeof allUpcoming = [];
for (const intake of allUpcoming) {
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`; const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
return !state.sentReminders.includes(key); const existingEntry = state.reminders[key];
}); const intakeTimeMs = intake.intakeTime.getTime();
const isIntakePast = intakeTimeMs < nowMs;
if (newReminders.length === 0) { if (!existingEntry) {
return; // All reminders already sent // New dose - always send first reminder (upcoming or already missed)
remindersToSend.push(intake);
logger.info(`[IntakeReminder] User ${settings.userId}: First reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${isIntakePast ? 'missed' : 'upcoming'})`);
} else if (settings.repeatRemindersEnabled && isIntakePast) {
// Repeat reminder - only for intakes that are already past (missed)
const intervalMs = settings.reminderRepeatIntervalMinutes * 60 * 1000;
const timeSinceLastReminder = nowMs - existingEntry.lastSentAt;
const maxReminders = settings.maxNaggingReminders ?? 5;
if (existingEntry.sendCount >= maxReminders) {
// Max reminders reached - stop nagging
logger.info(`[IntakeReminder] User ${settings.userId}: Max reminders (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`);
} else if (timeSinceLastReminder >= intervalMs) {
remindersToSend.push(intake);
logger.info(`[IntakeReminder] User ${settings.userId}: Repeat reminder for missed "${intake.medName}" at ${intake.intakeTimeStr} (${existingEntry.sendCount + 1}/${maxReminders})`);
}
}
// Else: Already sent and either repeats disabled or intake not yet past - skip
} }
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${newReminders.length} upcoming intakes...`); if (remindersToSend.length === 0) {
return; // All reminders already sent and no repeats needed
}
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
if (settings.skipRemindersForTakenDoses) {
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
const takenToday = await db.select().from(doseTracking).where(
and(
eq(doseTracking.userId, settings.userId),
gte(doseTracking.takenAt, todayStart),
lte(doseTracking.takenAt, todayEnd)
)
);
const takenDoseIds = new Set(takenToday.map(d => d.doseId));
// Filter out reminders for doses that were already taken
remindersToSend = remindersToSend.filter(intake => {
const timestamp = intake.intakeTime.getTime();
// Check both with and without person suffix
if (intake.takenBy.length > 0) {
// For multi-person medications, check if any person has taken it
const anyTaken = intake.takenBy.some(person => {
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`;
return takenDoseIds.has(doseId);
});
return !anyTaken; // Skip if any person has taken it
} else {
// For non-person-specific medications
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`;
return !takenDoseIds.has(doseId);
}
});
if (remindersToSend.length === 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
return;
}
}
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
// Determine if this is a repeat reminder:
// - Any intake already has a state entry AND is past (repeat after first reminder)
// - OR intake is past even without state entry (missed the 15-min window)
const isRepeatReminder = remindersToSend.some(intake => {
const intakeTimeMs = intake.intakeTime.getTime();
const isIntakePast = intakeTimeMs < nowMs;
return isIntakePast; // Use repeat message for ANY missed intake
});
let emailSuccess = false; let emailSuccess = false;
let shoutrrrSuccess = false; let shoutrrrSuccess = false;
// Send email if enabled for intake reminders // Send email if enabled for intake reminders
if (emailEnabled) { if (emailEnabled) {
const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language); const result = await sendIntakeReminderEmail(
settings.notificationEmail!,
remindersToSend,
language,
isRepeatReminder,
settings.reminderRepeatIntervalMinutes
);
emailSuccess = result.success; emailSuccess = result.success;
if (result.success) { if (result.success) {
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`); logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
@@ -260,8 +413,15 @@ async function checkAndSendIntakeRemindersForUser(
// Send Shoutrrr notification if enabled for intake reminders // Send Shoutrrr notification if enabled for intake reminders
if (shoutrrrEnabled) { if (shoutrrrEnabled) {
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE }); const title = isRepeatReminder
const message = newReminders ? (language === 'de' ? '⚠️ Medikamenten-Erinnerung' : '⚠️ Medication Reminder')
: t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
const repeatNote = isRepeatReminder && settings.reminderRepeatIntervalMinutes
? `\n\n⚠️ This reminder will be sent every ${settings.reminderRepeatIntervalMinutes} minutes until marked as taken.`
: '';
const message = remindersToSend
.map((i) => { .map((i) => {
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : ""; const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`; let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
@@ -271,7 +431,7 @@ async function checkAndSendIntakeRemindersForUser(
} }
return `${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`; return `${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
}) })
.join("\n"); .join("\n") + repeatNote;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success; shoutrrrSuccess = result.success;
@@ -284,14 +444,32 @@ async function checkAndSendIntakeRemindersForUser(
// Update state if any notification was sent successfully // Update state if any notification was sent successfully
if (emailSuccess || shoutrrrSuccess) { if (emailSuccess || shoutrrrSuccess) {
const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`); // Update or create entries for sent reminders
for (const intake of remindersToSend) {
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
const existing = state.reminders[key];
// Clean up old entries (older than 24 hours) if (existing) {
const cleanedReminders = cleanOldIntakeReminders(state.sentReminders); // Update existing entry (repeat)
state.reminders[key] = {
firstSentAt: existing.firstSentAt,
lastSentAt: nowMs,
sendCount: existing.sendCount + 1,
};
} else {
// Create new entry (first send)
state.reminders[key] = {
firstSentAt: nowMs,
lastSentAt: nowMs,
sendCount: 1,
};
}
}
saveIntakeReminderState({ // Clean up old entries (remove doses from past days)
sentReminders: [...cleanedReminders, ...newKeys], state.reminders = cleanOldIntakeReminders(state.reminders, tz);
});
saveIntakeReminderState(state);
// Update global reminder state for UI display // Update global reminder state for UI display
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
+15 -2
View File
@@ -107,6 +107,10 @@ async function createSchema(client: Client) {
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7, reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0, repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
max_nagging_reminders integer NOT NULL DEFAULT 5,
low_stock_days integer NOT NULL DEFAULT 30, low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90, normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180, high_stock_days integer NOT NULL DEFAULT 180,
@@ -556,6 +560,9 @@ describe("E2E Tests with Real Routes", () => {
url: "/settings", url: "/settings",
}); });
if (response.statusCode !== 200) {
console.error("GET /settings error:", response.body);
}
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const data = response.json(); const data = response.json();
// Check default values // Check default values
@@ -720,7 +727,10 @@ describe("E2E Tests with Real Routes", () => {
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ status: "ok" }); const json = response.json();
expect(json.status).toBe("ok");
expect(typeof json.smtpConfigured).toBe("boolean");
expect(typeof json.shoutrrrConfigured).toBe("boolean");
}); });
}); });
@@ -1138,7 +1148,10 @@ describe("E2E Tests with Real Routes", () => {
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ status: "ok" }); const json = response.json();
expect(json.status).toBe("ok");
expect(typeof json.smtpConfigured).toBe("boolean");
expect(typeof json.shoutrrrConfigured).toBe("boolean");
}); });
}); });
+851
View File
@@ -0,0 +1,851 @@
/**
* Tests for /export and /import API endpoints.
* Tests export/import functionality with schema-independent format.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
import { randomBytes } from "crypto";
// =============================================================================
// Route Registration (simplified test routes)
// =============================================================================
async function registerExportRoutes(ctx: TestContext) {
const { app, client } = ctx;
const userId = 1; // Test user ID
// Helper to parse blisters from DB
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
const usage = JSON.parse(row.usage_json || "[]") as number[];
const every = JSON.parse(row.every_json || "[]") as number[];
const start = JSON.parse(row.start_json || "[]") as string[];
const len = Math.min(usage.length, every.length, start.length);
return Array.from({ length: len }, (_, i) => ({
usage: usage[i],
every: every[i],
start: start[i],
remind: Boolean(row.intake_reminders_enabled),
}));
}
// GET /export
app.get<{ Querystring: { includeSensitive?: string } }>("/export", async (request, reply) => {
const includeSensitive = request.query.includeSensitive === "true";
// Load medications
const medsResult = await client.execute({
sql: `SELECT * FROM medications WHERE user_id = ? ORDER BY id`,
args: [userId],
});
const medIdToExportId = new Map<number, string>();
const medications = medsResult.rows.map((m, i) => {
const exportId = `med-${i + 1}`;
medIdToExportId.set(m.id as number, exportId);
return {
_exportId: exportId,
name: m.name,
genericName: m.generic_name,
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
inventory: {
packCount: m.pack_count ?? 1,
blistersPerPack: m.blisters_per_pack ?? 1,
pillsPerBlister: m.pills_per_blister ?? 1,
looseTablets: m.loose_tablets ?? 0,
},
pillWeightMg: m.pill_weight_mg,
schedules: parseBlisters(m),
expiryDate: m.expiry_date,
notes: m.notes,
intakeRemindersEnabled: Boolean(m.intake_reminders_enabled),
image: null, // Skip images in test
};
});
// Load dose tracking
const dosesResult = await client.execute({
sql: `SELECT * FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
const doseHistory = dosesResult.rows
.map((d) => {
const parts = (d.dose_id as string).split("-");
if (parts.length < 3) return null;
const medId = parseInt(parts[0], 10);
const exportId = medIdToExportId.get(medId);
if (!exportId) return null;
return {
medicationRef: exportId,
scheduleIndex: parseInt(parts[1], 10),
scheduledTime: new Date(parseInt(parts[2], 10)).toISOString(),
takenAt: d.taken_at ? new Date(d.taken_at as number * 1000).toISOString() : new Date().toISOString(),
markedBy: d.marked_by,
};
})
.filter(Boolean);
// Load settings
const settingsResult = await client.execute({
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
args: [userId],
});
let settings = undefined;
if (settingsResult.rows.length > 0) {
const s = settingsResult.rows[0];
settings = {
emailEnabled: Boolean(s.email_enabled),
notificationEmail: s.notification_email,
emailStockReminders: Boolean(s.email_stock_reminders ?? 1),
emailIntakeReminders: Boolean(s.email_intake_reminders ?? 1),
shoutrrrEnabled: includeSensitive ? Boolean(s.shoutrrr_enabled) : undefined,
shoutrrrUrl: includeSensitive ? s.shoutrrr_url : undefined,
shoutrrrStockReminders: Boolean(s.shoutrrr_stock_reminders ?? 1),
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders ?? 1),
reminderDaysBefore: s.reminder_days_before ?? 7,
repeatDailyReminders: Boolean(s.repeat_daily_reminders),
skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses),
repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled),
reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30,
maxNaggingReminders: s.max_nagging_reminders ?? 5,
lowStockDays: s.low_stock_days ?? 30,
normalStockDays: s.normal_stock_days ?? 90,
highStockDays: s.high_stock_days ?? 180,
language: s.language ?? "en",
stockCalculationMode: s.stock_calculation_mode ?? "automatic",
};
}
// Load share links
const sharesResult = await client.execute({
sql: `SELECT * FROM share_tokens WHERE user_id = ?`,
args: [userId],
});
const shareLinks = sharesResult.rows.map((s) => ({
takenBy: s.taken_by,
scheduleDays: s.schedule_days ?? 30,
expiresAt: s.expires_at ? new Date(s.expires_at as number * 1000).toISOString() : null,
regenerateToken: true,
}));
return {
version: "1.0",
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications,
doseHistory,
settings,
shareLinks,
};
});
// POST /import
app.post<{ Body: any }>("/import", async (request, reply) => {
const importData = request.body as any;
// Basic validation
if (!importData.version) {
return reply.status(400).send({ error: "Invalid import data format" });
}
// Delete existing data
await client.execute({ sql: `DELETE FROM dose_tracking WHERE user_id = ?`, args: [userId] });
await client.execute({ sql: `DELETE FROM share_tokens WHERE user_id = ?`, args: [userId] });
await client.execute({ sql: `DELETE FROM medications WHERE user_id = ?`, args: [userId] });
await client.execute({ sql: `DELETE FROM user_settings WHERE user_id = ?`, args: [userId] });
// Import medications
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications || []) {
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
const takenByJson = JSON.stringify(med.takenBy || []);
const result = await client.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
pill_weight_mg, expiry_date, notes, intake_reminders_enabled,
usage_json, every_json, start_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
userId,
med.name,
med.genericName || null,
takenByJson,
med.inventory?.packCount ?? 1,
med.inventory?.blistersPerPack ?? 1,
med.inventory?.pillsPerBlister ?? 1,
med.inventory?.looseTablets ?? 0,
med.pillWeightMg ?? null,
med.expiryDate || null,
med.notes || null,
med.intakeRemindersEnabled ? 1 : 0,
usageJson,
everyJson,
startJson,
],
});
exportIdToNewId.set(med._exportId, result.rows[0].id as number);
}
// Import dose history
for (const dose of importData.doseHistory || []) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue;
const timestampMs = new Date(dose.scheduledTime).getTime();
const doseId = `${newMedId}-${dose.scheduleIndex}-${timestampMs}`;
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`,
args: [
userId,
doseId,
Math.floor(new Date(dose.takenAt).getTime() / 1000),
dose.markedBy || null,
],
});
}
// Import settings
if (importData.settings) {
const s = importData.settings;
await client.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email,
email_stock_reminders, email_intake_reminders,
shoutrrr_enabled, shoutrrr_url,
shoutrrr_stock_reminders, shoutrrr_intake_reminders,
reminder_days_before, repeat_daily_reminders,
skip_reminders_for_taken_doses, repeat_reminders_enabled,
reminder_repeat_interval_minutes, max_nagging_reminders,
low_stock_days, normal_stock_days, high_stock_days,
language, stock_calculation_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
s.emailEnabled ? 1 : 0,
s.notificationEmail || null,
s.emailStockReminders ?? 1,
s.emailIntakeReminders ?? 1,
s.shoutrrrEnabled ? 1 : 0,
s.shoutrrrUrl || null,
s.shoutrrrStockReminders ?? 1,
s.shoutrrrIntakeReminders ?? 1,
s.reminderDaysBefore ?? 7,
s.repeatDailyReminders ? 1 : 0,
s.skipRemindersForTakenDoses ? 1 : 0,
s.repeatRemindersEnabled ? 1 : 0,
s.reminderRepeatIntervalMinutes ?? 30,
s.maxNaggingReminders ?? 5,
s.lowStockDays ?? 30,
s.normalStockDays ?? 90,
s.highStockDays ?? 180,
s.language ?? "en",
s.stockCalculationMode ?? "automatic",
],
});
}
// Import share links
for (const share of importData.shareLinks || []) {
const token = randomBytes(8).toString("hex");
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
args: [
userId,
token,
share.takenBy,
share.scheduleDays ?? 30,
share.expiresAt ? Math.floor(new Date(share.expiresAt).getTime() / 1000) : null,
],
});
}
return {
success: true,
imported: {
medications: (importData.medications || []).length,
doseHistory: (importData.doseHistory || []).length,
settings: importData.settings ? 1 : 0,
shareLinks: (importData.shareLinks || []).length,
},
};
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Export/Import API", () => {
let ctx: TestContext;
let userId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerExportRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='medications'");
userId = await createTestUser(ctx.client, { username: "testuser" });
});
// ---------------------------------------------------------------------------
// GET /export
// ---------------------------------------------------------------------------
describe("GET /export", () => {
it("should export empty data for new user", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.version).toBe("1.0");
expect(data.exportedAt).toBeDefined();
expect(data.medications).toEqual([]);
expect(data.doseHistory).toEqual([]);
expect(data.shareLinks).toEqual([]);
});
it("should export medications with correct format", async () => {
const startDate = "2025-01-15T08:00:00.000Z";
await createTestMedication(ctx.client, {
userId,
name: "Aspirin",
genericName: "Acetylsalicylic acid",
takenBy: ["Daniel", "Maria"],
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
pillWeightMg: 500,
expiryDate: "2027-06-30",
notes: "Take with food",
intakeRemindersEnabled: true,
blisters: [
{ usage: 1, every: 1, start: startDate },
{ usage: 0.5, every: 7, start: startDate },
],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.medications).toHaveLength(1);
const med = data.medications[0];
expect(med._exportId).toBe("med-1");
expect(med.name).toBe("Aspirin");
expect(med.genericName).toBe("Acetylsalicylic acid");
expect(med.takenBy).toEqual(["Daniel", "Maria"]);
expect(med.inventory).toEqual({
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
});
expect(med.pillWeightMg).toBe(500);
expect(med.expiryDate).toBe("2027-06-30");
expect(med.notes).toBe("Take with food");
expect(med.intakeRemindersEnabled).toBe(true);
expect(med.schedules).toHaveLength(2);
expect(med.schedules[0]).toEqual({
usage: 1,
every: 1,
start: startDate,
remind: true,
});
});
it("should export settings", async () => {
// Create settings
await ctx.client.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, language, low_stock_days
) VALUES (?, 1, 'test@example.com', 'de', 14)`,
args: [userId],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.settings).toBeDefined();
expect(data.settings.emailEnabled).toBe(true);
expect(data.settings.notificationEmail).toBe("test@example.com");
expect(data.settings.language).toBe("de");
expect(data.settings.lowStockDays).toBe(14);
});
it("should exclude sensitive data by default", async () => {
await ctx.client.execute({
sql: `INSERT INTO user_settings (
user_id, shoutrrr_enabled, shoutrrr_url
) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`,
args: [userId],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.includeSensitiveData).toBe(false);
expect(data.settings.shoutrrrEnabled).toBeUndefined();
expect(data.settings.shoutrrrUrl).toBeUndefined();
});
it("should include sensitive data when requested", async () => {
await ctx.client.execute({
sql: `INSERT INTO user_settings (
user_id, shoutrrr_enabled, shoutrrr_url
) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`,
args: [userId],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export?includeSensitive=true",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.includeSensitiveData).toBe(true);
expect(data.settings.shoutrrrEnabled).toBe(true);
expect(data.settings.shoutrrrUrl).toBe("ntfy://user:pass@ntfy.sh/topic");
});
it("should export dose history with medication references", async () => {
const medId = await createTestMedication(ctx.client, {
userId,
name: "Test Med",
});
// Create dose tracking entry
const timestampMs = Date.now();
const doseId = `${medId}-0-${timestampMs}`;
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at) VALUES (?, ?, ?)`,
args: [userId, doseId, Math.floor(Date.now() / 1000)],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doseHistory).toHaveLength(1);
expect(data.doseHistory[0].medicationRef).toBe("med-1");
expect(data.doseHistory[0].scheduleIndex).toBe(0);
expect(data.doseHistory[0].scheduledTime).toBeDefined();
expect(data.doseHistory[0].takenAt).toBeDefined();
});
it("should export share links", async () => {
await ctx.client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`,
args: [userId, "abc123", "Daniel", 30],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.shareLinks).toHaveLength(1);
expect(data.shareLinks[0].takenBy).toBe("Daniel");
expect(data.shareLinks[0].scheduleDays).toBe(30);
expect(data.shareLinks[0].regenerateToken).toBe(true);
});
});
// ---------------------------------------------------------------------------
// POST /import
// ---------------------------------------------------------------------------
describe("POST /import", () => {
it("should import medications", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Med",
genericName: "Generic",
takenBy: ["Alice"],
inventory: {
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
},
pillWeightMg: 250,
schedules: [
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z", remind: true },
],
expiryDate: "2027-12-31",
notes: "Test notes",
intakeRemindersEnabled: true,
},
],
doseHistory: [],
shareLinks: [],
};
const response = await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
expect(response.json().success).toBe(true);
expect(response.json().imported.medications).toBe(1);
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT * FROM medications WHERE user_id = ?`,
args: [userId],
});
expect(result.rows).toHaveLength(1);
expect(result.rows[0].name).toBe("Imported Med");
expect(result.rows[0].generic_name).toBe("Generic");
expect(result.rows[0].pack_count).toBe(2);
expect(result.rows[0].blisters_per_pack).toBe(3);
expect(result.rows[0].pills_per_blister).toBe(10);
expect(result.rows[0].loose_tablets).toBe(5);
});
it("should replace existing data on import", async () => {
// Create existing medication
await createTestMedication(ctx.client, {
userId,
name: "Existing Med",
});
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "New Med",
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }],
},
],
doseHistory: [],
shareLinks: [],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify old med deleted, new one exists
const result = await ctx.client.execute({
sql: `SELECT * FROM medications WHERE user_id = ?`,
args: [userId],
});
expect(result.rows).toHaveLength(1);
expect(result.rows[0].name).toBe("New Med");
});
it("should import dose history with remapped IDs", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Med 1",
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }],
},
],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2025-01-15T08:00:00.000Z",
takenAt: "2025-01-15T08:15:00.000Z",
markedBy: null,
},
],
shareLinks: [],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify dose tracking
const doses = await ctx.client.execute({
sql: `SELECT * FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
expect(doses.rows).toHaveLength(1);
// Dose ID should contain the NEW medication ID
const doseId = doses.rows[0].dose_id as string;
expect(doseId).toMatch(/^\d+-0-\d+$/);
});
it("should import settings", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [],
doseHistory: [],
settings: {
emailEnabled: true,
notificationEmail: "imported@example.com",
language: "de",
lowStockDays: 14,
normalStockDays: 60,
highStockDays: 120,
},
shareLinks: [],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify settings
const settings = await ctx.client.execute({
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
args: [userId],
});
expect(settings.rows).toHaveLength(1);
expect(settings.rows[0].email_enabled).toBe(1);
expect(settings.rows[0].notification_email).toBe("imported@example.com");
expect(settings.rows[0].language).toBe("de");
expect(settings.rows[0].low_stock_days).toBe(14);
});
it("should import share links with new tokens", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [],
doseHistory: [],
shareLinks: [
{
takenBy: "Daniel",
scheduleDays: 60,
regenerateToken: true,
},
],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify share token
const shares = await ctx.client.execute({
sql: `SELECT * FROM share_tokens WHERE user_id = ?`,
args: [userId],
});
expect(shares.rows).toHaveLength(1);
expect(shares.rows[0].taken_by).toBe("Daniel");
expect(shares.rows[0].schedule_days).toBe(60);
expect(shares.rows[0].token).toBeDefined();
expect((shares.rows[0].token as string).length).toBe(16); // 8 bytes = 16 hex chars
});
it("should reject invalid import data", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/import",
payload: { invalid: "data" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid import data format");
});
});
// ---------------------------------------------------------------------------
// Export/Import Roundtrip Tests
// ---------------------------------------------------------------------------
describe("Export/Import Roundtrip", () => {
it("should preserve all data through export/import cycle", async () => {
// Setup: Create medications, doses, settings, shares
const startDate = "2025-01-15T08:00:00.000Z";
const medId = await createTestMedication(ctx.client, {
userId,
name: "Roundtrip Med",
genericName: "Generic Name",
takenBy: ["Daniel", "Maria"],
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
pillWeightMg: 500,
expiryDate: "2027-06-30",
notes: "Test notes",
intakeRemindersEnabled: true,
blisters: [
{ usage: 1, every: 1, start: startDate },
{ usage: 0.5, every: 7, start: startDate },
],
});
// Create dose
const timestampMs = new Date(startDate).getTime();
const doseId = `${medId}-0-${timestampMs}`;
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`,
args: [userId, doseId, Math.floor(Date.now() / 1000), "Daniel"],
});
// Create settings
await ctx.client.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, notification_email, language, low_stock_days) VALUES (?, 1, 'test@example.com', 'de', 14)`,
args: [userId],
});
// Create share
await ctx.client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`,
args: [userId, "original123", "Daniel", 60],
});
// Export
const exportResponse = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(exportResponse.statusCode).toBe(200);
const exportData = exportResponse.json();
// Import (this replaces all data)
const importResponse = await ctx.app.inject({
method: "POST",
url: "/import",
payload: exportData,
});
expect(importResponse.statusCode).toBe(200);
// Export again and compare
const reExportResponse = await ctx.app.inject({
method: "GET",
url: "/export",
});
const reExportData = reExportResponse.json();
// Compare (excluding timestamps and IDs that change)
expect(reExportData.medications).toHaveLength(1);
expect(reExportData.medications[0].name).toBe("Roundtrip Med");
expect(reExportData.medications[0].genericName).toBe("Generic Name");
expect(reExportData.medications[0].takenBy).toEqual(["Daniel", "Maria"]);
expect(reExportData.medications[0].inventory).toEqual({
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
});
expect(reExportData.medications[0].schedules).toHaveLength(2);
expect(reExportData.doseHistory).toHaveLength(1);
expect(reExportData.doseHistory[0].markedBy).toBe("Daniel");
expect(reExportData.settings.emailEnabled).toBe(true);
expect(reExportData.settings.notificationEmail).toBe("test@example.com");
expect(reExportData.settings.language).toBe("de");
expect(reExportData.shareLinks).toHaveLength(1);
expect(reExportData.shareLinks[0].takenBy).toBe("Daniel");
});
it("should handle import with different schema (backward compatibility)", async () => {
// Simulate import from older version without some fields
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Legacy Med",
// Missing: genericName, takenBy, pillWeightMg, etc.
inventory: {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
},
schedules: [
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" },
],
},
],
doseHistory: [],
// Missing: settings, shareLinks
};
const response = await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
expect(response.json().success).toBe(true);
// Verify defaults were applied
const result = await ctx.client.execute({
sql: `SELECT * FROM medications WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].name).toBe("Legacy Med");
expect(result.rows[0].generic_name).toBeNull();
expect(result.rows[0].taken_by_json).toBe("[]");
});
});
});
+4
View File
@@ -104,6 +104,10 @@ async function createSchema(client: Client) {
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7, reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0, repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
max_nagging_reminders integer NOT NULL DEFAULT 5,
low_stock_days integer NOT NULL DEFAULT 30, low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90, normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180, high_stock_days integer NOT NULL DEFAULT 180,
+4
View File
@@ -94,6 +94,10 @@ async function createSchema(client: Client) {
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7, reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0, repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
max_nagging_reminders integer NOT NULL DEFAULT 5,
low_stock_days integer NOT NULL DEFAULT 30, low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90, normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180, high_stock_days integer NOT NULL DEFAULT 180,
+151 -33
View File
@@ -16,6 +16,7 @@ import {
calculateDailyUsage, calculateDailyUsage,
calculateDepletionInfo, calculateDepletionInfo,
getUpcomingIntakes, getUpcomingIntakes,
getTodaysIntakes,
createDefaultReminderState, createDefaultReminderState,
createDefaultIntakeReminderState, createDefaultIntakeReminderState,
parseReminderState, parseReminderState,
@@ -381,6 +382,94 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
expect(result.length).toBeGreaterThanOrEqual(1); expect(result.length).toBeGreaterThanOrEqual(1);
}); });
}); });
describe("getTodaysIntakes", () => {
it("should return all intakes for today", () => {
// Daily medication at 08:00 starting yesterday
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
// Get intakes for 2025-01-02 (today's intake should be at 08:00)
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(1);
const intake = result.find(i => i.intakeTime.getUTCHours() === 8);
expect(intake).toBeDefined();
expect(intake?.medName).toBe("TestMed");
expect(intake?.usage).toBe(1);
});
it("should include past intakes from today", () => {
// Medication at 00:01 today (definitely in the past)
const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 1, 0, 0);
const blisters: Blister[] = [{
usage: 2,
every: 1,
start: todayMidnight.toISOString()
}];
const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC");
expect(result).toHaveLength(1);
expect(result[0].medName).toBe("PastMed");
expect(result[0].usage).toBe(2);
expect(result[0].takenBy).toEqual(["Bob"]);
expect(result[0].pillWeightMg).toBe(250);
});
it("should handle multiple intakes per day", () => {
// Two intakes today: morning and evening
const today = new Date();
const morning = new Date(today);
morning.setUTCHours(8, 0, 0, 0);
const evening = new Date(today);
evening.setUTCHours(20, 0, 0, 0);
const blisters: Blister[] = [
{ usage: 1, every: 1, start: morning.toISOString() },
{ usage: 1, every: 1, start: evening.toISOString() },
];
const result = getTodaysIntakes("MultiMed", blisters, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(2);
});
it("should not include intakes from other days", () => {
// Weekly medication on a different day of week
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
const blisters: Blister[] = [{
usage: 1,
every: 7,
start: lastWeek.toISOString()
}];
// If today is not the same day of week, should return empty
const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC");
// This test might return 0 or 1 depending on the day
expect(Array.isArray(result)).toBe(true);
});
it("should handle timezone correctly", () => {
// 23:00 in Europe/Berlin on a specific date
const blisters: Blister[] = [{
usage: 1,
every: 1,
start: "2025-01-01T22:00:00.000Z" // 23:00 Berlin time
}];
const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin");
expect(Array.isArray(result)).toBe(true);
if (result.length > 0) {
expect(result[0].intakeTimeStr).toContain("23:");
}
});
});
}); });
describe("Scheduler Utils - State Management", () => { describe("Scheduler Utils - State Management", () => {
@@ -399,7 +488,7 @@ describe("Scheduler Utils - State Management", () => {
describe("createDefaultIntakeReminderState", () => { describe("createDefaultIntakeReminderState", () => {
it("should create default intake reminder state", () => { it("should create default intake reminder state", () => {
const state = createDefaultIntakeReminderState(); const state = createDefaultIntakeReminderState();
expect(state.sentReminders).toEqual([]); expect(state.reminders).toEqual({});
}); });
}); });
@@ -439,62 +528,91 @@ describe("Scheduler Utils - State Management", () => {
}); });
describe("parseIntakeReminderState", () => { describe("parseIntakeReminderState", () => {
it("should parse valid JSON", () => { it("should parse valid new format JSON", () => {
const json = JSON.stringify({
reminders: {
"med1:123": { firstSentAt: 1000, lastSentAt: 2000, sendCount: 2 },
"med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 }
}
});
const state = parseIntakeReminderState(json);
expect(Object.keys(state.reminders)).toHaveLength(2);
expect(state.reminders["med1:123"].sendCount).toBe(2);
});
it("should convert old array format to new format", () => {
const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] }); const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] });
const state = parseIntakeReminderState(json); const state = parseIntakeReminderState(json);
expect(state.sentReminders).toEqual(["med1:123", "med2:456"]); expect(Object.keys(state.reminders)).toHaveLength(2);
expect(state.reminders["med1:123"]).toBeDefined();
expect(state.reminders["med1:123"].sendCount).toBe(1);
}); });
it("should return defaults for invalid JSON", () => { it("should return defaults for invalid JSON", () => {
const state = parseIntakeReminderState("invalid"); const state = parseIntakeReminderState("invalid");
expect(state.sentReminders).toEqual([]); expect(state.reminders).toEqual({});
}); });
it("should handle missing sentReminders", () => { it("should handle missing reminders field", () => {
const state = parseIntakeReminderState("{}"); const state = parseIntakeReminderState("{}");
expect(state.sentReminders).toEqual([]); expect(state.reminders).toEqual({});
}); });
}); });
describe("cleanOldIntakeReminders", () => { describe("cleanOldIntakeReminders", () => {
it("should remove entries older than maxAgeMs", () => { it("should remove entries from past days (timezone-aware)", () => {
const now = Date.now(); const tz = "Europe/Berlin";
const oldTimestamp = now - 25 * 60 * 60 * 1000; // 25 hours ago const now = new Date();
const recentTimestamp = now - 1 * 60 * 60 * 1000; // 1 hour ago const today = new Date(now.toLocaleString("en-US", { timeZone: tz }));
today.setHours(12, 0, 0, 0);
const reminders = [ const yesterday = new Date(today);
`med1:${oldTimestamp}`, yesterday.setDate(yesterday.getDate() - 1);
`med2:${recentTimestamp}`,
];
const cleaned = cleanOldIntakeReminders(reminders, 24 * 60 * 60 * 1000); const reminders = {
[`med1:${yesterday.getTime()}`]: { firstSentAt: yesterday.getTime(), lastSentAt: yesterday.getTime(), sendCount: 1 },
[`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 },
};
expect(cleaned).toHaveLength(1); const cleaned = cleanOldIntakeReminders(reminders, tz);
expect(cleaned[0]).toContain("med2");
expect(Object.keys(cleaned)).toHaveLength(1);
expect(cleaned[`med2:${today.getTime()}`]).toBeDefined();
}); });
it("should keep all entries if none are old", () => { it("should keep all entries from today", () => {
const now = Date.now(); const tz = "Europe/Berlin";
const reminders = [ const now = new Date();
`med1:${now - 1000}`, const morning = new Date(now.toLocaleString("en-US", { timeZone: tz }));
`med2:${now - 2000}`, morning.setHours(8, 0, 0, 0);
];
const cleaned = cleanOldIntakeReminders(reminders); const evening = new Date(now.toLocaleString("en-US", { timeZone: tz }));
expect(cleaned).toHaveLength(2); evening.setHours(20, 0, 0, 0);
const reminders = {
[`med1:${morning.getTime()}`]: { firstSentAt: morning.getTime(), lastSentAt: morning.getTime(), sendCount: 1 },
[`med2:${evening.getTime()}`]: { firstSentAt: evening.getTime(), lastSentAt: evening.getTime(), sendCount: 1 },
};
const cleaned = cleanOldIntakeReminders(reminders, tz);
expect(Object.keys(cleaned)).toHaveLength(2);
}); });
it("should handle empty array", () => { it("should handle empty reminders", () => {
const cleaned = cleanOldIntakeReminders([]); const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin");
expect(cleaned).toEqual([]); expect(cleaned).toEqual({});
}); });
it("should handle malformed entries (invalid timestamp)", () => { it("should handle malformed entries (invalid timestamp in key)", () => {
const reminders = ["med1:invalid", "med2:notanumber"]; const reminders = {
const cleaned = cleanOldIntakeReminders(reminders); "med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 },
// NaN from parseInt will cause these to be filtered out (0 < cutoff) "med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 }
expect(cleaned).toEqual([]); };
const cleaned = cleanOldIntakeReminders(reminders, "Europe/Berlin");
// NaN from parseInt will cause these to be filtered out (invalid < todayStart)
expect(Object.keys(cleaned)).toHaveLength(0);
}); });
}); });
}); });
+166 -2
View File
@@ -41,6 +41,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
shoutrrrIntakeReminders: true, shoutrrrIntakeReminders: true,
reminderDaysBefore: 7, reminderDaysBefore: 7,
repeatDailyReminders: false, repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30, lowStockDays: 30,
normalStockDays: 90, normalStockDays: 90,
highStockDays: 180, highStockDays: 180,
@@ -62,6 +66,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders), shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders),
reminderDaysBefore: s.reminder_days_before, reminderDaysBefore: s.reminder_days_before,
repeatDailyReminders: Boolean(s.repeat_daily_reminders), repeatDailyReminders: Boolean(s.repeat_daily_reminders),
skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses ?? false),
repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled ?? false),
reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30,
maxNaggingReminders: s.max_nagging_reminders ?? 5,
lowStockDays: s.low_stock_days, lowStockDays: s.low_stock_days,
normalStockDays: s.normal_stock_days, normalStockDays: s.normal_stock_days,
highStockDays: s.high_stock_days, highStockDays: s.high_stock_days,
@@ -84,6 +92,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
shoutrrrIntakeReminders?: boolean; shoutrrrIntakeReminders?: boolean;
reminderDaysBefore?: number; reminderDaysBefore?: number;
repeatDailyReminders?: boolean; repeatDailyReminders?: boolean;
skipRemindersForTakenDoses?: boolean;
repeatRemindersEnabled?: boolean;
reminderRepeatIntervalMinutes?: number;
maxNaggingReminders?: number;
lowStockDays?: number; lowStockDays?: number;
normalStockDays?: number; normalStockDays?: number;
highStockDays?: number; highStockDays?: number;
@@ -111,6 +123,12 @@ async function registerSettingsRoutes(ctx: TestContext) {
if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) { if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) {
return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" }); return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" });
} }
if (body.reminderRepeatIntervalMinutes !== undefined && (body.reminderRepeatIntervalMinutes < 5 || body.reminderRepeatIntervalMinutes > 480)) {
return reply.status(400).send({ error: "reminderRepeatIntervalMinutes must be between 5 and 480" });
}
if (body.maxNaggingReminders !== undefined && (body.maxNaggingReminders < 1 || body.maxNaggingReminders > 20)) {
return reply.status(400).send({ error: "maxNaggingReminders must be between 1 and 20" });
}
// Check if settings exist // Check if settings exist
const existing = await client.execute({ const existing = await client.execute({
@@ -126,10 +144,11 @@ async function registerSettingsRoutes(ctx: TestContext) {
email_stock_reminders, email_intake_reminders, email_stock_reminders, email_intake_reminders,
shoutrrr_enabled, shoutrrr_url, shoutrrr_enabled, shoutrrr_url,
shoutrrr_stock_reminders, shoutrrr_intake_reminders, shoutrrr_stock_reminders, shoutrrr_intake_reminders,
reminder_days_before, repeat_daily_reminders, reminder_days_before, repeat_daily_reminders, skip_reminders_for_taken_doses,
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
low_stock_days, normal_stock_days, high_stock_days, low_stock_days, normal_stock_days, high_stock_days,
expiry_warning_days, language, stock_calculation_mode expiry_warning_days, language, stock_calculation_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [ args: [
userId, userId,
body.emailEnabled ? 1 : 0, body.emailEnabled ? 1 : 0,
@@ -142,6 +161,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.shoutrrrIntakeReminders !== false ? 1 : 0, body.shoutrrrIntakeReminders !== false ? 1 : 0,
body.reminderDaysBefore ?? 7, body.reminderDaysBefore ?? 7,
body.repeatDailyReminders ? 1 : 0, body.repeatDailyReminders ? 1 : 0,
body.skipRemindersForTakenDoses ? 1 : 0,
body.repeatRemindersEnabled ? 1 : 0,
body.reminderRepeatIntervalMinutes ?? 30,
body.maxNaggingReminders ?? 5,
body.lowStockDays ?? 30, body.lowStockDays ?? 30,
body.normalStockDays ?? 90, body.normalStockDays ?? 90,
body.highStockDays ?? 180, body.highStockDays ?? 180,
@@ -164,6 +187,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
shoutrrr_intake_reminders = ?, shoutrrr_intake_reminders = ?,
reminder_days_before = ?, reminder_days_before = ?,
repeat_daily_reminders = ?, repeat_daily_reminders = ?,
skip_reminders_for_taken_doses = ?,
repeat_reminders_enabled = ?,
reminder_repeat_interval_minutes = ?,
max_nagging_reminders = ?,
low_stock_days = ?, low_stock_days = ?,
normal_stock_days = ?, normal_stock_days = ?,
high_stock_days = ?, high_stock_days = ?,
@@ -183,6 +210,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.shoutrrrIntakeReminders !== false ? 1 : 0, body.shoutrrrIntakeReminders !== false ? 1 : 0,
body.reminderDaysBefore ?? 7, body.reminderDaysBefore ?? 7,
body.repeatDailyReminders ? 1 : 0, body.repeatDailyReminders ? 1 : 0,
body.skipRemindersForTakenDoses ? 1 : 0,
body.repeatRemindersEnabled ? 1 : 0,
body.reminderRepeatIntervalMinutes ?? 30,
body.maxNaggingReminders ?? 5,
body.lowStockDays ?? 30, body.lowStockDays ?? 30,
body.normalStockDays ?? 90, body.normalStockDays ?? 90,
body.highStockDays ?? 180, body.highStockDays ?? 180,
@@ -507,4 +538,137 @@ describe("Settings API", () => {
expect(getResponse.json().stockCalculationMode).toBe("automatic"); expect(getResponse.json().stockCalculationMode).toBe("automatic");
}); });
}); });
// ---------------------------------------------------------------------------
// Repeat Reminders & Skip Reminders Settings
// ---------------------------------------------------------------------------
describe("Repeat Reminders Settings", () => {
it("should enable repeat reminders with interval", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 10,
},
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
const settings = getResponse.json();
expect(settings.repeatRemindersEnabled).toBe(true);
expect(settings.reminderRepeatIntervalMinutes).toBe(10);
});
it("should validate repeat interval range", async () => {
let response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 2,
},
});
expect(response.statusCode).toBe(400);
response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 500,
},
});
expect(response.statusCode).toBe(400);
});
it("should validate max nagging reminders range", async () => {
let response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
maxNaggingReminders: 0,
},
});
expect(response.statusCode).toBe(400);
response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
maxNaggingReminders: 25,
},
});
expect(response.statusCode).toBe(400);
// Valid values should work
response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
maxNaggingReminders: 10,
},
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
const settings = getResponse.json();
expect(settings.maxNaggingReminders).toBe(10);
});
});
describe("Skip Reminders for Taken Doses", () => {
it("should enable and disable skip reminders setting", async () => {
let response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
skipRemindersForTakenDoses: true,
},
});
expect(response.statusCode).toBe(200);
response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
skipRemindersForTakenDoses: false,
},
});
expect(response.statusCode).toBe(200);
});
it("should work with repeat reminders enabled", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: {
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 5,
skipRemindersForTakenDoses: true,
},
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
const settings = getResponse.json();
expect(settings.repeatRemindersEnabled).toBe(true);
expect(settings.reminderRepeatIntervalMinutes).toBe(5);
expect(settings.skipRemindersForTakenDoses).toBe(true);
});
});
}); });
+11 -2
View File
@@ -107,6 +107,9 @@ export interface CreateMedicationOptions {
pillsPerBlister?: number; pillsPerBlister?: number;
looseTablets?: number; looseTablets?: number;
pillWeightMg?: number; pillWeightMg?: number;
expiryDate?: string | null;
notes?: string | null;
intakeRemindersEnabled?: boolean;
/** Array of { usage, every, start } for each blister schedule */ /** Array of { usage, every, start } for each blister schedule */
blisters?: Array<{ usage: number; every: number; start: string }>; blisters?: Array<{ usage: number; every: number; start: string }>;
} }
@@ -128,6 +131,9 @@ export async function createTestMedication(
pillsPerBlister = 10, pillsPerBlister = 10,
looseTablets = 0, looseTablets = 0,
pillWeightMg = null, pillWeightMg = null,
expiryDate = null,
notes = null,
intakeRemindersEnabled = false,
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }], blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
} = options; } = options;
@@ -141,8 +147,8 @@ export async function createTestMedication(
sql: `INSERT INTO medications ( sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, user_id, name, generic_name, taken_by_json,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
pill_weight_mg, usage_json, every_json, start_json pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [ args: [
userId, userId,
name, name,
@@ -156,6 +162,9 @@ export async function createTestMedication(
usageJson, usageJson,
everyJson, everyJson,
startJson, startJson,
expiryDate,
notes,
intakeRemindersEnabled ? 1 : 0,
], ],
}); });
+106 -9
View File
@@ -188,6 +188,70 @@ export type UpcomingIntake = {
pillWeightMg: number | null; 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. * Get upcoming intakes that fall within the reminder window.
* Returns intakes that should be notified about right now. * Returns intakes that should be notified about right now.
@@ -277,8 +341,14 @@ export type ReminderState = {
lastNotificationChannel: "email" | "push" | "both" | null; 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 = { export type IntakeReminderState = {
sentReminders: string[]; reminders: Record<string, IntakeReminderEntry>; // key -> entry
}; };
/** Create default reminder state */ /** Create default reminder state */
@@ -295,7 +365,7 @@ export function createDefaultReminderState(): ReminderState {
/** Create default intake reminder state */ /** Create default intake reminder state */
export function createDefaultIntakeReminderState(): IntakeReminderState { export function createDefaultIntakeReminderState(): IntakeReminderState {
return { sentReminders: [] }; return { reminders: {} };
} }
/** Parse reminder state from JSON string */ /** 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 { export function parseIntakeReminderState(json: string): IntakeReminderState {
try { try {
const saved = JSON.parse(json); 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 { return {
sentReminders: saved.sentReminders ?? [], reminders: saved.reminders ?? {},
}; };
} catch { } catch {
return createDefaultIntakeReminderState(); return createDefaultIntakeReminderState();
@@ -328,10 +414,21 @@ export function parseIntakeReminderState(json: string): IntakeReminderState {
} }
/** Clean up old intake reminder entries (older than given milliseconds) */ /** Clean up old intake reminder entries (older than given milliseconds) */
export function cleanOldIntakeReminders(sentReminders: string[], maxAgeMs: number = 24 * 60 * 60 * 1000): string[] { /** Clean up old intake reminder entries (using timezone-aware day check) */
const cutoff = Date.now() - maxAgeMs; export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
return sentReminders.filter(key => { // 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); const timestamp = parseInt(key.split(":").pop() || "0", 10);
return timestamp > cutoff; if (timestamp >= todayStartMs) {
}); cleaned[key] = entry;
}
}
return cleaned;
} }
+10 -10
View File
@@ -1,19 +1,19 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.0.1", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.0.1", "version": "1.0.2",
"dependencies": { "dependencies": {
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.12.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -1713,9 +1713,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.11.0", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -1735,12 +1735,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.11.0", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.11.0" "react-router": "7.12.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.0.2", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -15,7 +15,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.12.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
+308 -8
View File
@@ -289,6 +289,10 @@ function AppContent() {
notificationEmail: "", notificationEmail: "",
reminderDaysBefore: 7, reminderDaysBefore: 7,
repeatDailyReminders: false, repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30, lowStockDays: 30,
normalStockDays: 90, normalStockDays: 90,
highStockDays: 180, highStockDays: 180,
@@ -347,6 +351,12 @@ function AppContent() {
const [shareGenerating, setShareGenerating] = useState(false); const [shareGenerating, setShareGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null); const [shareLink, setShareLink] = useState<string | null>(null);
const [shareCopied, setShareCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false);
// Export/Import state
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [includeSensitiveData, setIncludeSensitiveData] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<any>(null);
// Collapsed days state (manually collapsed days are persisted) // Collapsed days state (manually collapsed days are persisted)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set()); const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
@@ -627,6 +637,10 @@ function AppContent() {
notificationEmail: settings.notificationEmail, notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore, reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders, repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays, lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays, normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays, highStockDays: settings.highStockDays,
@@ -770,6 +784,110 @@ function AppContent() {
setSendingReminderEmail(false); setSendingReminderEmail(false);
} }
// Export data to JSON file
async function handleExport() {
setExporting(true);
try {
const res = await fetch(`/api/export?includeSensitive=${includeSensitiveData}`, {
credentials: "include",
});
if (!res.ok) throw new Error("Export failed");
const data = await res.json();
// Create download
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const dateStr = new Date().toISOString().split("T")[0];
a.href = url;
a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export error:", err);
}
setExporting(false);
}
// Handle file selection for import
function handleImportFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (!data.version || !data.exportedAt) {
alert(t('exportImport.invalidFile'));
return;
}
setPendingImportData(data);
setShowImportConfirm(true);
} catch {
alert(t('exportImport.invalidFile'));
}
};
reader.readAsText(file);
// Reset file input
e.target.value = "";
}
// Confirm and execute import
async function handleImportConfirm() {
if (!pendingImportData) return;
setImporting(true);
setShowImportConfirm(false);
try {
const res = await fetch("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(pendingImportData),
});
if (!res.ok) {
const err = await res.json();
alert(t('exportImport.importError') + ": " + (err.error || "Unknown error"));
return;
}
const result = await res.json();
alert(t('exportImport.importSuccess') + "\n" + t('exportImport.importSuccessDetails', {
medications: result.imported.medications,
doses: result.imported.doseHistory,
shares: result.imported.shareLinks,
}));
// Reload all data
loadMeds();
loadSettings();
loadTakenDoses();
} catch (err) {
console.error("Import error:", err);
alert(t('exportImport.importError'));
}
setPendingImportData(null);
setImporting(false);
}
// Helper function to load taken doses (extracted from useEffect)
async function loadTakenDoses() {
try {
const res = await fetch("/api/doses/taken", { credentials: "include" });
if (res.ok) {
const data = await res.json();
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
}
} catch {
// Silently fail
}
}
async function deleteMed(id: number) { async function deleteMed(id: number) {
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null); await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
if (editingId === id) resetForm(); if (editingId === id) resetForm();
@@ -1927,7 +2045,7 @@ function AppContent() {
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}> <label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={settings.emailStockReminders} checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
disabled={!settings.emailEnabled} disabled={!settings.emailEnabled}
/> />
@@ -1938,7 +2056,7 @@ function AppContent() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}> <label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={settings.shoutrrrStockReminders} checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled} disabled={!settings.shoutrrrEnabled}
/> />
@@ -1952,7 +2070,7 @@ function AppContent() {
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}> <label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={settings.emailIntakeReminders} checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
disabled={!settings.emailEnabled} disabled={!settings.emailEnabled}
/> />
@@ -1963,7 +2081,7 @@ function AppContent() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}> <label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={settings.shoutrrrIntakeReminders} checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled} disabled={!settings.shoutrrrEnabled}
/> />
@@ -1975,16 +2093,94 @@ function AppContent() {
{!settings.emailEnabled && !settings.shoutrrrEnabled && ( {!settings.emailEnabled && !settings.shoutrrrEnabled && (
<p className="hint-text">{t('settings.notifications.enableHint')}</p> <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>
<div className="setting-section"> <div className="setting-section">
<div className="section-header"> <div className="section-header">
<h3>{t('settings.notifications.email')}</h3> <h3>{t('settings.notifications.email')}</h3>
<label className="toggle-switch small"> <label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={settings.emailEnabled} checked={settings.smtpHost ? settings.emailEnabled : false}
onChange={(e) => setSettings({ ...settings, emailEnabled: e.target.checked })} 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> <span className="toggle-slider"></span>
</label> </label>
@@ -2028,7 +2224,14 @@ function AppContent() {
<input <input
type="checkbox" type="checkbox"
checked={settings.shoutrrrEnabled} 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> <span className="toggle-slider"></span>
</label> </label>
@@ -2206,6 +2409,70 @@ function AppContent() {
</div> </div>
</article> </article>
{/* Export/Import Section */}
<article className="card">
<div className="card-head">
<h2>{t('exportImport.title')}</h2>
</div>
<div className="setting-section">
<p className="hint-text" style={{marginBottom: "16px"}}>{t('exportImport.description')}</p>
{/* Export */}
<div className="setting-group" style={{marginBottom: "24px"}}>
<div className="export-controls">
<label className="checkbox-label" style={{marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px"}}>
<input
type="checkbox"
checked={includeSensitiveData}
onChange={(e) => setIncludeSensitiveData(e.target.checked)}
/>
<span>{t('exportImport.includeSensitive')}</span>
</label>
{includeSensitiveData && (
<p className="hint-text warning-text" style={{marginBottom: "12px", color: "var(--warning)", fontSize: "0.85rem"}}>
{t('exportImport.sensitiveWarning')}
</p>
)}
<button
type="button"
className="secondary"
onClick={handleExport}
disabled={exporting}
style={{marginRight: "12px"}}
>
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
</button>
</div>
</div>
{/* Import */}
<div className="setting-group">
<div className="import-controls">
<label className="secondary" style={{
cursor: "pointer",
display: "inline-block",
padding: "0.7rem 1.25rem",
borderRadius: "var(--btn-radius)",
background: "var(--bg-tertiary)",
color: "var(--text-primary)",
border: "1px solid var(--border-secondary)",
fontWeight: 600,
fontSize: "0.9rem"
}}>
{importing ? t('exportImport.importing') : t('exportImport.import')}
<input
type="file"
accept=".json,application/json"
onChange={handleImportFileSelect}
disabled={importing}
style={{display: "none"}}
/>
</label>
</div>
</div>
</div>
</article>
<div className="form-footer"> <div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}> <button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')} {settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
@@ -2213,6 +2480,39 @@ function AppContent() {
</div> </div>
</form> </form>
)} )}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<div className="modal-overlay" onClick={() => setShowImportConfirm(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
<button className="modal-close" onClick={() => { setShowImportConfirm(false); setPendingImportData(null); }}>×</button>
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.confirmImport')}</h2>
<p style={{marginBottom: "12px"}}>{t('exportImport.confirmImportMessage')}</p>
<p className="warning-text" style={{marginBottom: "24px"}}>
{t('exportImport.confirmImportWarning')}
</p>
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
<button
type="button"
className="ghost"
onClick={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
>
{t('exportImport.cancelButton')}
</button>
<button
type="button"
className="danger"
onClick={handleImportConfirm}
>
{t('exportImport.confirmButton')}
</button>
</div>
</div>
</div>
)}
</section> </section>
} /> } />
+27 -1
View File
@@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
{t("auth.register", "Create Account")} {t("auth.register", "Create Account")}
</h2> </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>} {error && <div className="auth-error">{error}</div>}
<div className="form-group"> <div className="form-group">
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")} {loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
</button> </button>
</form> </form>
)}
{onSwitchToLogin && ( {onSwitchToLogin && (
<div className="auth-links"> <div className="auth-links">
+31 -1
View File
@@ -157,7 +157,15 @@
"push": "Push", "push": "Push",
"stockReminders": "Bestands-Erinnerungen", "stockReminders": "Bestands-Erinnerungen",
"intakeReminders": "Einnahme-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": { "email": {
"recipient": "Empfänger", "recipient": "Empfänger",
@@ -340,5 +348,27 @@
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.", "contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
"expiredOn": "Abgelaufen am: {{date}}" "expiredOn": "Abgelaufen am: {{date}}"
} }
},
"exportImport": {
"title": "Daten Export / Import",
"description": "Exportiere deine Daten zur Sicherung oder Übertragung auf ein anderes Gerät. Import ersetzt ALLE deine bestehenden Daten.",
"export": "Daten exportieren",
"exporting": "Exportiere...",
"import": "Daten importieren",
"importing": "Importiere...",
"selectFile": "Datei auswählen",
"includeSensitive": "Sensible Daten einschließen",
"sensitiveWarning": "Warnung: Dies fügt Benachrichtigungs-URLs (können Passwörter enthalten) im Klartext in die Exportdatei ein.",
"confirmImport": "Alle Daten ersetzen?",
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
"confirmButton": "Ja, alles ersetzen",
"cancelButton": "Abbrechen",
"exportSuccess": "Daten erfolgreich exportiert",
"importSuccess": "Daten erfolgreich importiert",
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
"importError": "Daten konnten nicht importiert werden",
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.",
"downloadFilename": "medassist-export"
} }
} }
+31 -1
View File
@@ -159,7 +159,15 @@
"push": "Push", "push": "Push",
"stockReminders": "Stock Reminders", "stockReminders": "Stock Reminders",
"intakeReminders": "Intake 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": { "email": {
"recipient": "Recipient", "recipient": "Recipient",
@@ -342,5 +350,27 @@
"contact": "Please contact {{username}} to request a new link.", "contact": "Please contact {{username}} to request a new link.",
"expiredOn": "Expired on: {{date}}" "expiredOn": "Expired on: {{date}}"
} }
},
"exportImport": {
"title": "Data Export / Import",
"description": "Export your data for backup or transfer to another device. Import will replace ALL your existing data.",
"export": "Export Data",
"exporting": "Exporting...",
"import": "Import Data",
"importing": "Importing...",
"selectFile": "Select File",
"includeSensitive": "Include sensitive data",
"sensitiveWarning": "Warning: This will include notification URLs (may contain passwords) in plain text in the export file.",
"confirmImport": "Replace All Data?",
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
"confirmImportWarning": "This action cannot be undone!",
"confirmButton": "Yes, Replace All",
"cancelButton": "Cancel",
"exportSuccess": "Data exported successfully",
"importSuccess": "Data imported successfully",
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
"importError": "Failed to import data",
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.",
"downloadFilename": "medassist-export"
} }
} }