Compare commits

..

35 Commits

Author SHA1 Message Date
Daniel Volz cab0fcbba7 feat: mobile UI improvements, biome linting, and reminder info display (#71)
* fix: make dismissed doses robust against schedule/timezone changes

- Store dismissedUntil date (YYYY-MM-DD) per medication instead of individual dose IDs
- Add POST /medications/dismiss-until endpoint to set dismissed date
- Add DELETE /medications/:id/dismiss-until endpoint to clear dismissed date
- Update frontend to use medication-level dismissedUntil for filtering
- Remove old dismissMissedDoses function from useDoses hook (was using dose IDs)
- Add backward-compatible ALTER TABLE migration for dismissed_until column
- Add 5 integration tests for dismiss-until functionality
- Update test schemas with new column

The old approach stored individual dose IDs which broke when schedule or timezone
settings changed (dose IDs contain timestamps). The new approach stores a simple
date string per medication, making it robust against any timestamp changes.

* chore: add Biome linter and Husky pre-commit hook

* chore: add unified biome config and pre-push hook

- Add root-level biome.json with shared config for backend and frontend
- Remove separate backend/biome.json and frontend/biome.json
- Add .husky/pre-push hook to run backend tests before push
- Update package.json lint-staged config to use root biome config

* feat(db): add reminder info columns to schema

- Add dismissed_until column to medications table
- Add last_reminder_med_name and last_reminder_taken_by to user_settings
- Generate Drizzle migration 0003
- Add backward-compatible ALTER migrations in client.ts

* feat(frontend): add unsaved changes warning

- Add UnsavedChangesContext for tracking unsaved form state
- Add useUnsavedChangesWarning hook for browser close warning
- Wrap App with UnsavedChangesProvider
- Add i18n translations for unsaved changes dialog (en/de)

* style: apply biome formatting across codebase

- Apply consistent formatting to all TypeScript files
- Organize imports alphabetically
- Use double quotes and tabs consistently
- Fix trailing commas (es5 style)
- Remove frontend/biome.json deletion (already deleted)

* fix(tests): add missing columns to test schemas

Add last_reminder_med_name and last_reminder_taken_by columns to
test CREATE TABLE statements in:
- planner.test.ts
- e2e-routes.test.ts
- integration.test.ts

Also improve runDrizzleMigrations to handle duplicate column errors
gracefully (returns warning instead of failing).

* fix(planner): add missing 'as unknown' type cast for request.user

* fix(security): address CodeQL XSS and SSRF warnings

- Escape all user-provided strings in email HTML templates
- Coerce numeric values with Number() to prevent type injection
- Add redirect:error to fetch() to prevent SSRF via redirect
- Document SSRF validation in settings.ts

* fix(security): refactor SSRF mitigation to reconstruct URL from validated components

CodeQL traces taint through validation functions that return the same string.
Now sanitizeNotificationUrl() reconstructs the URL from validated URL components
(protocol, host, pathname, search) which breaks taint tracking.

- Renamed to sanitizeNotificationUrl() to clarify it returns sanitized data
- Returns reconstructed URL built from URL() parsed components
- Extracts auth credentials separately instead of including in URL string
- Added isNtfy flag to avoid re-parsing the sanitized URL

* fix(security): add SSRF suppression comment for validated notification URL

The fetch() uses a URL that has been validated by sanitizeNotificationUrl():
- Only http/https protocols
- Blocks localhost and loopback IPs
- Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x)
- Blocks internal hostnames (.local, .internal, .lan)
- redirect: 'error' prevents redirect bypass

This is an intentional feature: users configure their own notification endpoints.
2026-01-25 18:01:35 +01:00
Daniel Volz ecdb9bcbe0 ci: auto-update test count badges in README (#70) 2026-01-23 22:36:26 +01:00
Daniel Volz 9b0d8037e7 docs: show test counts in README badges (454/454 backend, 611/611 frontend) (#69) 2026-01-23 22:27:27 +01:00
Daniel Volz a4d1dd215a docs: add CI status badges to README (#68) 2026-01-23 22:24:29 +01:00
Daniel Volz 8e2fd0a761 chore: release v1.5.0 (#67)
* chore: release v1.4.0

* feat: timezone-aware locale formatting

- Add TIMEZONE_TO_REGION map for 50+ timezones worldwide
- Combine app language with timezone region (e.g., en + Europe/Berlin → en-DE)
- Fix times displaying in wrong timezone (treated as UTC instead of local)
- Add parseLocalDateTime() to handle ISO strings without UTC conversion
- Users now get regional formatting (24h time, local date format) regardless of app language
- Swedish user with en-SE locale now gets yyyy-mm-dd format and 24h time
- German user with en-DE locale gets dd.mm.yyyy format and 24h time
- Add missing i18n translation key 'lastSent'
- Update all getSystemLocale() calls to pass app language parameter

* chore: release v1.5.0

* fix: timezone-independent test for CI (use 14:00 instead of 22:00)

* fix: make timezone test independent of server timezone
2026-01-23 21:42:57 +01:00
Copilot 0a4f8c5948 [WIP] Increase frontend test coverage to above 80% (#63)
* Initial plan

* refactor: simplify useMedicationForm tests to avoid memory issues

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Changes before error encountered

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* test: add comprehensive tests for SchedulePage, SettingsPage, MedicationsPage, and PlannerPage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* test: add SharedSchedule theme persistence tests

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* test: add comprehensive MobileEditModal tests

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* test: add comprehensive MedDetailModal tests

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* fix: use fixed timestamps in tests for deterministic behavior

Co-authored-by: DanielVolz <3275994+DanielVolz@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-23 07:36:44 +01:00
Daniel Volz fd055a3a2a Feat/frontend tests (#62)
* test(frontend): add vitest test infrastructure

- Add vitest, testing-library, jsdom dependencies
- Configure vitest with jsdom environment
- Add test setup with mocks for fetch, localStorage, matchMedia
- Set 75% coverage threshold

* Add frontend tests (#61)

* Initial plan

* Add frontend tests - utilities, hooks, and components (21% coverage)

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add more component tests (Auth, AboutModal, ExportModal) and useRefill hook tests - 30% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add useMedicationForm utility function tests - 30% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add AppHeader tests and more schedule.ts tests - 32% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Changes before error encountered

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Fix page tests and add more tests - 326 tests passing, 34% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add tests for ProfileModal, UserFilterModal, MedDetailModal - 361 tests, 36% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add SharedSchedule tests - 366 tests, 39% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Expand page tests - 383 tests, 39% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add MobileEditModal tests - 409 tests, 40% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Expand Dashboard and Schedule page tests - 427 tests, 40% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Fix code review issues - remove invalid remindEnabled property

Co-authored-by: DanielVolz <3275994+DanielVolz@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>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-01-22 10:25:11 +01:00
Daniel Volz 8718311876 refactor(frontend): modularize App.tsx into components, pages, hooks, and context (#60)
- Extract App.tsx from 764 lines to ~404 lines
- Create reusable components: MedDetailModal, MobileEditModal, ShareDialog, etc.
- Add AppContext for global state management
- Split pages: DashboardPage, MedicationsPage, SchedulePage, SettingsPage, PlannerPage
- Create custom hooks: useAuth, useMedications, useSettings, useDoses, useSchedule
- Add utility functions in separate modules
- Fix stock status logic (>30 days = green/normal)
- Fix reminder threshold calculation (use reminderDaysBefore not lowStockDays)
- Fix takenBy validation (send [] instead of null)
- Fix datetime format for blister start times (add Z suffix)
- Style 'All OK' status as green/bold

BREAKING: None - all existing functionality preserved
2026-01-22 05:38:34 +01:00
Daniel Volz 89edd74de3 chore: release v1.4.1 (#59)
* chore: release v1.4.0

* chore: release v1.4.1
2026-01-20 19:35:00 +01:00
Daniel Volz 30d72f625d chore: unify data folder and update AI instructions (#58)
- Use single ./data folder for both dev and prod (removes ./backend/data)
- Update docker-compose.dev.yml to use ./data:/app/data
- Remove backend/data/ from .gitignore (only data/ needed)
- Add 'NEVER create PRs without permission' rule to copilot-instructions.md
2026-01-20 19:32:35 +01:00
Daniel Volz cea1a8b119 chore: improve .gitignore and add shared vscode settings (#57)
- Better organized with clear sections
- Added SQLite WAL/SHM files
- Added OS files (Thumbs.db, swap files)
- Added misc caches (.cache/, .turbo/)
- Keep .vscode/settings.json for shared vitest config
- Added root data/ folder (docker-compose mount point)
2026-01-20 19:22:45 +01:00
Copilot 3aa2b608b0 Fix missing Drizzle migrations in production Docker image (#56)
* Initial plan

* fix: Add drizzle migrations folder to production Docker image

Co-authored-by: DanielVolz <3275994+DanielVolz@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-20 18:31:28 +01:00
Daniel Volz e24a540f17 fix: show package size in user medications modal (#54)
The user medications modal (clicking on a 'taken by' badge) was showing
the adjusted stock as total (e.g. 152/152) instead of the package size
(e.g. 152/196).

Changed from getMedTotal() to getPackageSize() for the denominator.
2026-01-18 17:25:47 +01:00
Daniel Volz fae96c9fdd docs: add AI release notes workflow to instructions (#53)
* chore: improve release script for branch protection

- Create PR for version bump instead of direct push to main
- Wait for CI checks before merging
- Auto-merge PR and create signed tag
- Better error handling and gh CLI validation
- Works with GitHub branch protection rules

* chore(ci): create draft releases for manual release notes

Release notes should be descriptive, not auto-generated commit lists.
The workflow now creates a DRAFT release with a template.
User edits the release notes following the style guide, then publishes.

* docs: add AI release notes workflow to instructions
2026-01-18 15:30:07 +01:00
Daniel Volz 11b55fc638 chore: improve release script for branch protection (#52)
* chore: improve release script for branch protection

- Create PR for version bump instead of direct push to main
- Wait for CI checks before merging
- Auto-merge PR and create signed tag
- Better error handling and gh CLI validation
- Works with GitHub branch protection rules

* chore(ci): create draft releases for manual release notes

Release notes should be descriptive, not auto-generated commit lists.
The workflow now creates a DRAFT release with a template.
User edits the release notes following the style guide, then publishes.
2026-01-18 15:20:18 +01:00
Daniel Volz b68c0b0737 chore: release v1.4.0 (#51) 2026-01-18 15:14:55 +01:00
Daniel Volz 1920b47924 feat: Add About section with version info and update check (#50)
* feat: add About section with version info and update check

- Add About menu item in user dropdown
- Show frontend and backend versions separately
- Add 'Check for Updates' feature using GitHub API
- Compare versions using semver logic
- Cache update check results in sessionStorage (1 hour TTL)
- Link to GitHub repository
- Add i18n translations for EN and DE
- Extend health endpoint to return backend version

* fix: correct i18n interpolation in About modal

- Fix copyright year using dynamic interpolation
- Fix update available message (remove duplicate version placeholder)
- Add download link for available updates
- Change license to GPL-3.0

* fix: correct license to MIT

* chore: sync package.json versions to v1.3.1
2026-01-18 15:12:21 +01:00
Daniel Volz 857b1462e3 fix: include stockAdjustment in export/import (#49)
The stockAdjustment and lastStockCorrectionAt fields were not being
exported or imported, causing stock corrections to be lost when
doing an export/import cycle.

Changes:
- Add stockAdjustment to inventory schema in export validation
- Add lastStockCorrectionAt to medication export schema
- Export both fields when generating export data
- Import both fields when restoring from backup
2026-01-18 14:39:39 +01:00
Daniel Volz 813aa0faf9 fix: show package size instead of adjusted total in medications list and modal (#48)
The 'Total' display should show the base package capacity (packs × blisters × pills + loose),
not the corrected stock amount. This is the fixed capacity of a full package.

- Add getPackageSize() helper to calculate base total without stockAdjustment
- Use packageSize in medications list 'Total: X pills'
- Use packageSize in medication detail modal 'Current Stock: X / Y'
- getMedTotal() still includes stockAdjustment for coverage calculations
2026-01-18 14:19:26 +01:00
Daniel Volz 75bb7abebc feat: Stock Correction Modal (#47)
* feat: add stock correction modal with blister-based input

- Add 'Correct Stock' button to medication detail modal
- New modal with Full Blisters + Partial Blister Pills inputs
- Auto-conversion for edge cases (full/negative partial)
- New stockAdjustment field for DB corrections without touching looseTablets
- New lastStockCorrectionAt timestamp to ignore old consumed doses after correction
- Tracking data preserved for future statistics
- Add Drizzle migrations for new columns
- Add translations for en/de

* fix: add stock_adjustment columns to e2e/integration test schemas
2026-01-18 12:53:25 +01:00
Daniel Volz bb46b26ec6 feat: improve export/import UI with modal and integrated success message (#46)
- Replace export checkbox with modal offering 'With Images' or 'Data Only' options
- Replace styled label with proper button for file import
- Replace browser alert() with integrated success banner for import confirmation
- Add i18n translations for new modal texts (EN/DE)

The export modal provides a cleaner UX with clear explanations for each option.
The import success message now displays inline with theme-appropriate styling.
2026-01-18 09:37:25 +01:00
Daniel Volz 8d22669bef fix: export/import dismissed doses and person-specific dose IDs (#45)
- Add 'dismissed' field to dose history export/import
- Add 'takenByPerson' field to handle person-suffixed dose IDs (e.g., 5-0-timestamp-Daniel)
- Update parseDoseId() to extract person suffix from dose ID
- Update buildDoseId() to include optional person suffix

This fixes import losing:
1. Which past doses were marked as taken
2. Which doses were dismissed (cleared missed)
3. Person-specific dose tracking for shared schedules
2026-01-18 09:19:23 +01:00
Daniel Volz fb0b3df794 feat: add option to exclude images from export (#44)
- Add 'Include medication images' checkbox in export section
- Default: enabled (full backup with images)
- Disabled: much smaller export (~50 KB instead of several MB)
- Helpful for quick backups or when importing to another instance
2026-01-18 09:12:12 +01:00
Daniel Volz 48ae48a165 fix: increase body size limit for large imports (#43)
- Increase nginx client_max_body_size from 10MB to 50MB
- Add bodyLimit: 50MB to Fastify import route
- Allows importing exports with many base64-encoded images
2026-01-18 09:05:57 +01:00
Daniel Volz a190667320 fix: improve import error handling and add refill_history table migration (#42)
- Add CREATE TABLE IF NOT EXISTS for refill_history in ALTER migrations
- Improve frontend import error handling to show server errors properly
- Parse response as text first to handle non-JSON error responses
2026-01-18 08:55:48 +01:00
Daniel Volz cfdca04df9 fix: handle invalid date values in export route (#41)
Added robust date handling to prevent 'Invalid time value' errors when
exporting dose history and share links. The code now safely handles:
- Date objects that might be invalid
- Timestamps stored as numbers or strings
- Missing or null values

Falls back to current date if conversion fails.
2026-01-18 08:36:17 +01:00
Daniel Volz a28e3724ae docs: emphasize ALTER migration requirement for new features (#40)
Added prominent warning in copilot-instructions.md that every new feature
touching DB must include ALTER migrations in client.ts, not just schema.ts.
This prevents production 500 errors on existing databases.
2026-01-18 08:27:29 +01:00
Daniel Volz 42d00dd1c0 fix: add stock_calculation_mode ALTER migration for backward compatibility (#39)
Older production databases were missing the stock_calculation_mode column,
causing 500 errors on /export endpoint. Added migration to add column
with default value 'automatic'.
2026-01-18 08:23:35 +01:00
Daniel Volz 8928915947 fix: remove duplicate 'New Medication' button from edit form (#38)
The button was redundant as it already exists in the medication list header.
2026-01-17 23:13:57 +01:00
Daniel Volz cfd37ca526 fix: close medication detail modal before navigating to edit (#37)
When clicking 'Edit' in the medication detail modal, the modal
now properly closes before navigating to the medications page.
Previously the modal remained visible behind the edit form.
2026-01-17 23:08:04 +01:00
Daniel Volz 288e075786 fix: browser back gesture closes modal instead of navigating (#36)
* fix: browser back gesture closes modal instead of navigating

- Push history state when opening medication detail modal
- Handle popstate event to close modal on browser back
- Replace direct setSelectedMed(null) with closeMedDetail() helper
- Improves mobile UX: swiping back closes modal instead of leaving page

* feat: add back-swipe support for all modals

- Add history.pushState/popstate handling for all modal types
- Profile, ShareDialog, EditModal, RefillModal, ImageLightbox,
  ScheduleLightbox, UserFilter now all support browser back button
- Mobile users can now swipe back to close any modal instead of
  navigating away from the app
- ESC key also triggers proper history-based close for all modals
- Fix duplicate openShareDialog function
- Fix recursive call bug in openUserFilter

* fix: prevent past days count from wrapping to new line

- Add flex-wrap: nowrap to .past-days-toggle
- Add white-space: nowrap and flex-shrink: 0 to .past-days-count
- Ensures (7 Tage), (14 Tage) etc. stays on same line as label

* fix: improve schedule row layout for mobile screens

- Stack schedule label and value vertically on small screens (<400px)
- Add word-break for long text values
- Prevents 'Einnahmeprüfung' and '15 Min. vor geplanter Zeit' from overlapping

* feat: add back-swipe support for image lightbox on share page

- Add history.pushState/popstate handling for lightbox in SharedSchedule
- Mobile users can now swipe back to close image instead of navigating away
2026-01-17 23:00:39 +01:00
Daniel Volz 13c6430dee fix(ci): disable provenance/sbom to remove unknown/unknown image (#35) 2026-01-17 22:22:35 +01:00
Daniel Volz ec3793dd05 chore(ci): add path filters to skip CI for docs-only changes (#33)
* chore(ci): add path filters to skip CI for docs-only changes

- test.yml: Only run on changes to backend/**, frontend/**, or the workflow itself
- docker-build.yml: Only run on code/docker changes (tags still trigger unconditionally)

* docs: move screenshots below GIF and add visual indentation
2026-01-17 22:06:34 +01:00
Daniel Volz d5f6ceba19 docs: Update README with new features and screenshots (#32)
- Add Medication Refill and Data Export/Import to features
- Add collapsible screenshots section with 8 new screenshots
- Include desktop views: Dashboard, Medication Detail, Edit Form, Planner, Shared Schedule
- Include mobile views: Dashboard, Medications, Schedule
- Remove old dashboard.png, add properly named screenshots
2026-01-17 21:57:29 +01:00
Daniel Volz 6f0553d7dd docs: update release notes style guide (#31)
- Add structured format with What's New, Features, Where to Find It
- Prefer bold feature names with inline descriptions
- Minimize emoji usage
- Add comprehensive example based on v1.3.0 release
2026-01-17 21:18:10 +01:00
160 changed files with 46299 additions and 22134 deletions
+80 -12
View File
@@ -4,8 +4,11 @@
- **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.
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository.
- **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.
- **Remove old code when re-implementing**: When fixing a bug or re-implementing a feature that didn't work, ALWAYS remove the old/broken code completely. Never leave dead code, unused functions, or obsolete implementations in the codebase.
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. When modifying existing features, update or add tests accordingly. If old tests become obsolete due to code changes, remove or update them.
## Architecture Overview
@@ -190,36 +193,90 @@ gh pr merge --squash --delete-branch
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
### Release Workflow (MANDATORY for minor/major releases)
The `main` branch is protected - releases must go through the automated release script.
**Release Process:**
```bash
# 1. Run release script (creates PR, waits for CI, merges, creates tag)
./scripts/release.sh [patch|minor|major]
# 2. GitHub Actions creates a DRAFT release automatically
# 3. User asks AI to write release notes:
# "Write the release notes for vX.Y.Z"
# 4. AI writes descriptive release notes following the style guide below
# 5. User publishes the draft release with the written notes
```
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
> Do NOT just publish the auto-generated commit list. Follow the process above.
**AI Assistant Release Notes Workflow:**
1. When user asks to write release notes for a version:
- Check commits since previous tag: `git log vPREV..vNEW --oneline`
- Read through the changes to understand what was added/fixed
- Write release notes following the style guide below
- Present the notes to the user for copying to GitHub
### Creating Release Notes
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
> Not just auto-generated commit lists, but a brief descriptive text.
**Keep it short!** Less is more. Users want to know what changed, not implementation details.
**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z".
**Structure of a release text:**
**Keep it informative but concise.** Users want to know what changed and where to find it.
1. **Intro** (1-2 sentences): What's new in plain language
2. **Features** (if needed): Very brief bullet points
3. **Breaking Changes Warning** (if applicable): See below
**Required structure of release notes:**
1. **"What's New"** (1-2 sentences): Brief intro explaining the main change
2. **"New Features" / "Improvements"**: Grouped bullet points with **bold feature names** and descriptions
3. **"Where to Find It"**: Tell users where they can access the new feature
4. **Breaking Changes Warning** (if applicable): See below
**Style guidelines:**
- Use `### Heading` for sections (New Features, Improvements, Security, etc.)
- Use **bold** for feature names in bullet points
- Keep descriptions on the same line as the feature name
- Minimal emoji usage (sparingly, not on every line)
- Always end with "Where to Find It" section
**DO NOT include:**
- ❌ Technical implementation details (new columns, endpoints, etc.)
- ❌ "How it works" step-by-step explanations
- ❌ Technical implementation details (new columns, endpoints, database changes)
- ❌ Number of tests added
- ❌ Internal API changes (unless breaking)
- ❌ Excessive emoji on every bullet point
- ❌ .gitignore changes or other developer-only file changes
- ❌ AI/Copilot instruction updates
- ❌ CI/CD workflow changes (unless affecting users)
- ❌ Code refactoring without user-visible changes
**Only include user-relevant changes** - things that affect what users see or experience in the app.
**Example of good release notes:**
```markdown
## What's New
Added the ability to clear missed dose warnings without affecting medication stock.
A "Clear missed" button now appears next to the "Show past days" toggle.
This release introduces a medication refill tracking feature and improves the mobile user experience.
### Features
- 🧹 Clear missed doses with one click
- ✅ Confirmation dialog to prevent accidents
### New Features
- **Medication Refill**: Track when you refill your medications with a single click. Add full packs or individual pills and view complete refill history.
- **Automatic Stock Updates**: Stock levels are automatically recalculated after each refill.
- **Refill History**: Each medication shows a complete history of all refills with timestamps.
### Mobile Improvements
- **Centered Tooltips**: Info tooltips now display centered on screen for better readability.
- **Touch-friendly**: Tooltips close automatically when scrolling on touch devices.
### Where to Find It
The refill button appears in the medication detail modal and in the edit form for each medication.
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/v1.2.3...v1.3.0
```
### Breaking Changes Warning (CRITICAL!)
@@ -455,6 +512,17 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
> Users upgrade their Docker containers but keep their existing DB.
> The app must NOT crash if old columns are missing.
### ⚠️ MANDATORY for EVERY New Feature
**Before implementing ANY feature that touches user data or settings:**
1. **Check if new DB columns are needed** - Does the feature require storing new data?
2. **If YES → Follow ALL steps below** - Schema.ts + Drizzle migration + ALTER migration + NULL-safe code
3. **NEVER skip the ALTER migration** - This is the #1 cause of production 500 errors!
**Common mistake:** Adding a column to `schema.ts` and forgetting the ALTER migration in `client.ts`.
The Drizzle migration only works for NEW databases. Existing production databases need the ALTER migration!
### Schema Management with Drizzle Kit
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
+7
View File
@@ -3,6 +3,11 @@ name: Build and Push Docker Images
on:
push:
branches: [main]
paths:
- 'backend/**'
- 'frontend/**'
- 'docker-compose*.yml'
- '.github/workflows/docker-build.yml'
tags: ['v*']
workflow_dispatch:
inputs:
@@ -113,6 +118,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
provenance: false
sbom: false
# =============================================================================
# Create GitHub Release (only on tag push)
+43 -21
View File
@@ -16,41 +16,63 @@ jobs:
with:
fetch-depth: 0
- name: Get previous tag
id: prev_tag
- name: Get version info
id: version
run: |
# Get all tags sorted by version, find the one before current
CURRENT_TAG=${GITHUB_REF#refs/tags/}
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
VERSION=${CURRENT_TAG#v}
echo "tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
# If no previous tag found (first release), use empty
# Get previous tag
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
PREV_TAG=""
fi
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
echo "Current tag: $CURRENT_TAG, Previous tag: $PREV_TAG"
- name: Generate changelog
id: changelog
- name: Generate release template
run: |
PREV_TAG="${{ steps.prev_tag.outputs.previous_tag }}"
cat > release_notes.md << 'EOF'
## What's New
if [ -z "$PREV_TAG" ]; then
# First release - get all commits
CHANGES=$(git log --pretty=format:"- %s" HEAD)
else
# Get commits since last tag
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
fi
<!--
Write 1-2 sentences describing the main changes in this release.
Example: This release introduces a medication refill tracking feature and improves the mobile user experience.
-->
# Write to file for multiline support
echo "$CHANGES" > changelog.txt
### New Features
- name: Create Release
<!-- List new features with **bold** names and descriptions -->
- **Feature Name**: Description of the feature
### Improvements
<!-- List improvements and fixes -->
- **Improvement**: Description
### Where to Find It
<!-- Tell users where they can access new features -->
---
## Docker Images
```bash
docker pull ghcr.io/danielvolz/medassist-ng-backend:${{ steps.version.outputs.version }}
docker pull ghcr.io/danielvolz/medassist-ng-frontend:${{ steps.version.outputs.version }}
```
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/${{ steps.version.outputs.previous_tag }}...${{ steps.version.outputs.tag }}
EOF
- name: Create Draft Release
uses: softprops/action-gh-release@v1
with:
body_path: changelog.txt
body_path: release_notes.md
draft: true
generate_release_notes: false
name: "Release ${{ steps.version.outputs.tag }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6
View File
@@ -35,6 +35,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: TypeScript type check
run: npx tsc --noEmit
@@ -75,5 +78,8 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: TypeScript type check & build
run: npm run build
+97
View File
@@ -0,0 +1,97 @@
name: Update Test Badges
on:
push:
branches: [main]
paths:
- 'backend/src/**'
- 'frontend/src/**'
- 'backend/package.json'
- 'frontend/package.json'
permissions:
contents: write
jobs:
update-badges:
name: Update Test Count Badges
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend tests and capture count
id: backend-tests
working-directory: backend
run: |
OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT"
# Extract "Tests X passed" from output
PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
echo "count=$PASSED" >> $GITHUB_OUTPUT
- name: Run frontend tests and capture count
id: frontend-tests
working-directory: frontend
run: |
OUTPUT=$(npm run test -- --run 2>&1) || true
echo "$OUTPUT"
# Extract "Tests X passed" from output
PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
echo "count=$PASSED" >> $GITHUB_OUTPUT
- name: Update README badges
run: |
BACKEND_COUNT="${{ steps.backend-tests.outputs.count }}"
FRONTEND_COUNT="${{ steps.frontend-tests.outputs.count }}"
echo "Backend tests: $BACKEND_COUNT"
echo "Frontend tests: $FRONTEND_COUNT"
# Only update if we got valid counts
if [[ -n "$BACKEND_COUNT" && -n "$FRONTEND_COUNT" ]]; then
# URL encode the slash for shields.io
BACKEND_BADGE="https://img.shields.io/badge/Backend_Tests-${BACKEND_COUNT}%2F${BACKEND_COUNT}-brightgreen?logo=vitest"
FRONTEND_BADGE="https://img.shields.io/badge/Frontend_Tests-${FRONTEND_COUNT}%2F${FRONTEND_COUNT}-brightgreen?logo=vitest"
# Update README using sed
sed -i "s|https://img.shields.io/badge/Backend_Tests-[^\"]*|$BACKEND_BADGE|g" README.md
sed -i "s|https://img.shields.io/badge/Frontend_Tests-[^\"]*|$FRONTEND_BADGE|g" README.md
echo "Updated badges in README.md"
else
echo "Could not extract test counts, skipping update"
exit 0
fi
- name: Check for changes
id: git-check
run: |
git diff --quiet README.md || echo "changed=true" >> $GITHUB_OUTPUT
- name: Commit and push if changed
if: steps.git-check.outputs.changed == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add README.md
git commit -m "chore: update test count badges [skip ci]"
git push
+67 -26
View File
@@ -1,33 +1,74 @@
# Node
# ===================
# Dependencies
# ===================
node_modules/
.pnpm-store/
# ===================
# Build outputs
# ===================
dist/
build/
.tmp/
*.tsbuildinfo
# ===================
# Test & Coverage
# ===================
coverage/
.nyc_output/
# ===================
# Environment
# ===================
.env
.env.*
!.env.example
# ===================
# Database & Data
# ===================
*.db
*.sqlite
*.sqlite3
*.db-journal
*.db-wal
*.db-shm
data/
# ===================
# Logs
# ===================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist/
build/
coverage/
.tmp/
# Env
.env
.env.*
!.env.example
# SQLite
*.db
*.sqlite
*.sqlite3
*.db-journal
backend/data/
# Logs
logs/
*.log
# Editor
.vscode/
.idea/
# ===================
# OS files
# ===================
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# ===================
# IDE / Editor
# ===================
.idea/
*.sublime-project
*.sublime-workspace
# Keep shared VS Code settings
# .vscode/ is NOT ignored - settings.json is useful for the team
# ===================
# Misc
# ===================
*.local
.cache/
.turbo/
docs/TECH_STACK.md
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
echo "Running backend tests before push..."
cd backend && CI=true npm test
if [ $? -ne 0 ]; then
echo "❌ Backend tests failed. Push aborted."
echo "Use 'git push --no-verify' to skip tests if needed."
exit 1
fi
echo "✅ Backend tests passed!"
+5
View File
@@ -0,0 +1,5 @@
{
"vitest.root": "backend",
"vitest.enable": true,
"vitest.commandLine": "npm test --"
}
+91
View File
@@ -17,6 +17,11 @@
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker" alt="Docker" />
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-454%2F454-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-611%2F611-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
> This app was 100% coded with Claude Opus 4.5. Use at your own risk.
@@ -28,6 +33,7 @@
> **Think of this app as a helpful tool, but make all health decisions independently!**
- [Features](#features)
- [Screenshots](#screenshots)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Development](#development)
@@ -38,11 +44,91 @@
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
</p>
<a id="screenshots"></a>
<details>
<summary><strong>Screenshots</strong></summary>
<blockquote>
<details>
<summary>Dashboard</summary>
Overview with stock status, reorder reminders, and upcoming schedules.
<img src="docs/screenshots/dashboard-desktop.png" alt="Dashboard" width="100%" />
</details>
<details>
<summary>Medication Detail</summary>
View medication details, stock information, and intake schedule.
<img src="docs/screenshots/medication-detail-modal.png" alt="Medication Detail Modal" width="100%" />
</details>
<details>
<summary>Medications & Edit Form</summary>
Manage your medications with the edit form and refill feature.
<img src="docs/screenshots/medications-edit-desktop.png" alt="Medications Edit Form" width="100%" />
</details>
<details>
<summary>Demand Calculator (Planner)</summary>
Calculate how many pills you need for a specific date range.
<img src="docs/screenshots/planner-desktop.png" alt="Planner - Demand Calculator" width="100%" />
</details>
<details>
<summary>Shared Schedule</summary>
Share your medication schedule with others via a public link.
<img src="docs/screenshots/share-schedule-desktop.png" alt="Shared Schedule" width="100%" />
</details>
<details>
<summary>Mobile Views</summary>
<table>
<tr>
<td align="center" width="33%">
<strong>Dashboard</strong><br>
<img src="docs/screenshots/dashboard-mobile.png" alt="Mobile Dashboard" width="100%" />
</td>
<td align="center" width="33%">
<strong>Medications</strong><br>
<img src="docs/screenshots/medications-mobile.png" alt="Mobile Medications" width="100%" />
</td>
<td align="center" width="33%">
<strong>Schedule</strong><br>
<img src="docs/screenshots/schedule-mobile.png" alt="Mobile Schedule" width="100%" />
</td>
</tr>
</table>
</details>
</blockquote>
</details>
### Smart Inventory
- Track exact stock: packs, blisters, and loose pills
- Display remaining days of supply
- Automatic calculation based on intake schedule
### Medication Refill
- One-click refill with pack or loose pill options
- Complete refill history per medication
- Automatic stock updates after each refill
### Flexible Schedules
- Daily, weekly, or custom intervals per medication
- Independent schedules for each medication
@@ -60,6 +146,11 @@
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
### Data Export & Import
- Export all your data (medications, dose history, settings) as JSON
- Import previously exported data with automatic ID remapping
- Choose whether to include sensitive data in exports
### Notifications
- Email via SMTP
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
+35
View File
@@ -0,0 +1,35 @@
# Dependencies
node_modules/
# Build outputs
dist/
coverage/
# Development files
*.log
npm-debug.log*
# Test files
src/test/
*.test.ts
vitest.config.ts
# Local data (mounted as volume in production)
data/
# IDE
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose*.yml
+3
View File
@@ -46,6 +46,9 @@ COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# Copy drizzle migrations folder (required for database setup)
COPY drizzle ./drizzle
# Create data directory and set ownership to node user (UID 1000)
RUN mkdir -p /app/data && chown -R node:node /app
@@ -0,0 +1 @@
ALTER TABLE `medications` ADD `stock_adjustment` integer DEFAULT 0 NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `medications` ADD `last_stock_correction_at` integer;
@@ -0,0 +1,3 @@
ALTER TABLE `medications` ADD `dismissed_until` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_reminder_med_name` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_reminder_taken_by` text;
+827
View File
@@ -0,0 +1,827 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bcb60728-38c0-4965-adac-829c02240d89",
"prevId": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+834
View File
@@ -0,0 +1,834 @@
{
"version": "6",
"dialect": "sqlite",
"id": "098ee506-e43d-4ccb-bee5-c387905695ab",
"prevId": "bcb60728-38c0-4965-adac-829c02240d89",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+855
View File
@@ -0,0 +1,855 @@
{
"version": "6",
"dialect": "sqlite",
"id": "4f1d8273-1e60-4da1-9bfc-bd51c2784836",
"prevId": "098ee506-e43d-4ccb-bee5-c387905695ab",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dismissed_until": {
"name": "dismissed_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_med_name": {
"name": "last_reminder_med_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_taken_by": {
"name": "last_reminder_taken_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+21
View File
@@ -8,6 +8,27 @@
"when": 1768600500759,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768734577830,
"tag": "0001_add_stock_adjustment",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1768736677092,
"tag": "0002_add_last_stock_correction_at",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1769354512857,
"tag": "0003_add_reminder_info_columns",
"breakpoints": true
}
]
}
+166 -8
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-backend",
"version": "1.1.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
"version": "1.1.0",
"version": "1.5.0",
"dependencies": {
"@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1",
@@ -26,6 +26,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^2.3.12",
"@types/node": "^22.7.4",
"@types/nodemailer": "^6.4.21",
"@types/supertest": "^6.0.2",
@@ -785,6 +786,169 @@
"node": ">=18"
}
},
"node_modules/@biomejs/biome": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.12.tgz",
"integrity": "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.12",
"@biomejs/cli-darwin-x64": "2.3.12",
"@biomejs/cli-linux-arm64": "2.3.12",
"@biomejs/cli-linux-arm64-musl": "2.3.12",
"@biomejs/cli-linux-x64": "2.3.12",
"@biomejs/cli-linux-x64-musl": "2.3.12",
"@biomejs/cli-win32-arm64": "2.3.12",
"@biomejs/cli-win32-x64": "2.3.12"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.12.tgz",
"integrity": "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.12.tgz",
"integrity": "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.12.tgz",
"integrity": "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.12.tgz",
"integrity": "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.12.tgz",
"integrity": "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.12.tgz",
"integrity": "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.12.tgz",
"integrity": "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.12.tgz",
"integrity": "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -2079,7 +2243,6 @@
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz",
"integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@libsql/core": "^0.10.0",
"@libsql/hrana-client": "^0.6.2",
@@ -4579,7 +4742,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -5776,7 +5938,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6538,7 +6699,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -6602,7 +6762,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6678,7 +6837,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.1.0",
"version": "1.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -10,7 +10,11 @@
"migrate": "tsx src/db/migrate.ts",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"lint": "npx biome check .",
"lint:fix": "npx biome check --write .",
"format": "npx biome format --write .",
"check": "npx biome check . && tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^10.0.1",
@@ -31,6 +35,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^2.3.12",
"@types/node": "^22.7.4",
"@types/nodemailer": "^6.4.21",
"@types/supertest": "^6.0.2",
+53 -10
View File
@@ -1,10 +1,10 @@
import { createClient, Client } from "@libsql/client";
import { accessSync, constants, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
@@ -51,11 +51,19 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
}
/** Run drizzle-kit migrations on the database */
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
export async function runDrizzleMigrations(
database: ReturnType<typeof drizzle>
): Promise<{ success: boolean; error?: string; warning?: string }> {
try {
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: any) {
// If the error is "duplicate column", it means the schema is already up-to-date
// This happens when ALTER migrations in client.ts have already added the columns
// We consider this a success with a warning, not a failure
if (err.message?.includes("duplicate column")) {
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
}
return { success: false, error: err.message };
}
}
@@ -73,6 +81,17 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
// Added in v1.2.3 - dismiss missed doses without deducting stock
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
// Added in v1.3.x - stock calculation mode (automatic/manual)
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
// Added for stock correction - hidden offset that doesn't affect looseTablets
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
// Added for stock correction - timestamp to ignore consumed doses before correction
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
// Added for more detailed reminder info display
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
];
for (const sql of alterMigrations) {
@@ -86,6 +105,30 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
}
}
// Create tables that might be missing (silently fail if already exists)
const createTableMigrations = [
// Added in v1.3.x - refill history tracking
`CREATE TABLE IF NOT EXISTS refill_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
packs_added INTEGER NOT NULL DEFAULT 0,
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
];
for (const sql of createTableMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
// Silently ignore "table already exists" errors
if (!e.message?.includes("already exists")) {
errors.push(e.message);
}
}
}
return { success: errors.length === 0, errors };
}
@@ -98,9 +141,7 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
try {
const result = await client.execute("SELECT id FROM users WHERE id = 1");
if (result.rows.length === 0) {
await client.execute(
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
);
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
return true; // Created
}
return false; // Already exists
@@ -157,6 +198,8 @@ async function runMigrations() {
const migrateResult = await runDrizzleMigrations(db);
if (!migrateResult.success) {
console.error(`[DB] Migration error:`, migrateResult.error);
} else if (migrateResult.warning) {
console.log(`[DB] Migration warning:`, migrateResult.warning);
} else {
console.log(`[DB] Drizzle migrations completed`);
}
@@ -164,7 +207,7 @@ async function runMigrations() {
// Run ALTER TABLE migrations for backward compatibility
const alterResult = await runAlterMigrations(client);
if (alterResult.errors.length > 0) {
alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err));
alterResult.errors.forEach((err) => console.error(`[DB] ALTER migration error:`, err));
}
console.log(`[DB] Tables verified/created`);
+9 -7
View File
@@ -1,9 +1,9 @@
import { createClient, Client } from "@libsql/client";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import dotenv from "dotenv";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
@@ -18,11 +18,13 @@ const migrationsFolder = resolve(__dirname, "../../drizzle");
/** Split SQL string into individual statements (for backwards compatibility with tests) */
export function splitSQLStatements(sql: string): string[] {
return sql.split(';').filter(s => s.trim().length > 0);
return sql.split(";").filter((s) => s.trim().length > 0);
}
/** Execute drizzle migrations on a database */
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
export async function executeMigration(
client: Client
): Promise<{ success: boolean; executed: number; errors: string[] }> {
const errors: string[] = [];
const db = drizzle(client);
@@ -48,7 +50,7 @@ export function getStatementPreview(stmt: string, maxLength: number = 50): strin
if (trimmed.length <= maxLength) {
return trimmed;
}
return trimmed.substring(0, maxLength) + "...";
return `${trimmed.substring(0, maxLength)}...`;
}
// =============================================================================
+29 -9
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// =============================================================================
// Users - Simple auth, no roles (every user is equal)
@@ -22,14 +22,18 @@ export const users = sqliteTable("users", {
// =============================================================================
export const medications = sqliteTable("medications", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
genericName: text("generic_name", { length: 100 }),
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0),
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
pillWeightMg: integer("pill_weight_mg"),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
@@ -38,6 +42,7 @@ export const medications = sqliteTable("medications", {
expiryDate: text("expiry_date"),
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
dismissedUntil: text("dismissed_until"), // ISO date string (e.g. "2026-01-23") - all past doses until this date are dismissed
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -46,7 +51,10 @@ export const medications = sqliteTable("medications", {
// =============================================================================
export const userSettings = sqliteTable("user_settings", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
// Email notifications
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
notificationEmail: text("notification_email"),
@@ -77,6 +85,8 @@ export const userSettings = sqliteTable("user_settings", {
lastAutoEmailSent: text("last_auto_email_sent"),
lastNotificationType: text("last_notification_type"),
lastNotificationChannel: text("last_notification_channel"),
lastReminderMedName: text("last_reminder_med_name"),
lastReminderTakenBy: text("last_reminder_taken_by"),
// Timestamps
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -86,7 +96,9 @@ export const userSettings = sqliteTable("user_settings", {
// =============================================================================
export const refreshTokens = sqliteTable("refresh_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenId: text("token_id", { length: 255 }).notNull().unique(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
@@ -99,7 +111,9 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
// =============================================================================
export const shareTokens = sqliteTable("share_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
@@ -112,7 +126,9 @@ export const shareTokens = sqliteTable("share_tokens", {
// =============================================================================
export const doseTracking = sqliteTable("dose_tracking", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
@@ -124,8 +140,12 @@ export const doseTracking = sqliteTable("dose_tracking", {
// =============================================================================
export const refillHistory = sqliteTable("refill_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
medicationId: integer("medication_id")
.notNull()
.references(() => medications.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
packsAdded: integer("packs_added").notNull().default(0),
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
+76 -3
View File
@@ -1,6 +1,68 @@
// Backend translations for notifications
export type Language = "en" | "de";
/**
* Map timezone to region code (ISO 3166-1 alpha-2).
* This allows combining app language with regional formatting.
*/
const TIMEZONE_TO_REGION: Record<string, string> = {
// Europe
"Europe/Berlin": "DE",
"Europe/Vienna": "AT",
"Europe/Zurich": "CH",
"Europe/London": "GB",
"Europe/Dublin": "IE",
"Europe/Paris": "FR",
"Europe/Madrid": "ES",
"Europe/Rome": "IT",
"Europe/Amsterdam": "NL",
"Europe/Brussels": "BE",
"Europe/Warsaw": "PL",
"Europe/Prague": "CZ",
"Europe/Stockholm": "SE",
"Europe/Oslo": "NO",
"Europe/Copenhagen": "DK",
"Europe/Helsinki": "FI",
"Europe/Athens": "GR",
"Europe/Lisbon": "PT",
"Europe/Moscow": "RU",
"Europe/Kiev": "UA",
"Europe/Kyiv": "UA",
"Europe/Budapest": "HU",
"Europe/Bucharest": "RO",
// Americas
"America/New_York": "US",
"America/Chicago": "US",
"America/Denver": "US",
"America/Los_Angeles": "US",
"America/Phoenix": "US",
"America/Toronto": "CA",
"America/Vancouver": "CA",
"America/Mexico_City": "MX",
"America/Sao_Paulo": "BR",
"America/Buenos_Aires": "AR",
// Asia/Pacific
"Asia/Tokyo": "JP",
"Asia/Shanghai": "CN",
"Asia/Hong_Kong": "HK",
"Asia/Singapore": "SG",
"Asia/Seoul": "KR",
"Asia/Dubai": "AE",
"Asia/Kolkata": "IN",
"Australia/Sydney": "AU",
"Australia/Melbourne": "AU",
"Pacific/Auckland": "NZ",
};
/**
* Get region code from TZ environment variable.
*/
function getRegionFromTimezone(): string | undefined {
const tz = process.env.TZ;
if (!tz) return undefined;
return TIMEZONE_TO_REGION[tz];
}
type TranslationKeys = {
// Stock reminder email
stockReminder: {
@@ -127,7 +189,8 @@ const translations: Record<Language, TranslationKeys> = {
runsOut: "Aufgebraucht",
},
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
repeatDailyNote: "Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
repeatDailyNote:
"Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
},
intakeReminder: {
subject: "MedAssist-ng: Einnahme-Erinnerung - {medications}",
@@ -181,12 +244,22 @@ export function t(template: string, params: Record<string, string | number> = {}
return result;
}
// Get date locale for toLocaleDateString
/**
* Get locale for formatting based on language and timezone region.
* Combines language (en/de) with region from timezone (DE/US/etc.)
* Example: lang=en + TZ=Europe/Berlin en-DE (English text, German format = 24h time)
*/
export function getDateLocale(language: Language): string {
const region = getRegionFromTimezone();
if (region) {
return `${language}-${region}`;
}
// Fallback: use language default
switch (language) {
case "de":
return "de-DE";
case "en":
default:
return "en-US";
}
+20 -20
View File
@@ -1,46 +1,46 @@
import Fastify, { FastifyInstance } from "fastify";
import helmet from "@fastify/helmet";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import fastifyStatic from "@fastify/static";
import { resolve } from "path";
import { existsSync } from "fs";
import { env } from "./plugins/env.js";
import Fastify, { type FastifyInstance } from "fastify";
import { migrationsReady } from "./db/client.js";
import { healthRoutes } from "./routes/health.js";
import { env } from "./plugins/env.js";
import { authRoutes } from "./routes/auth.js";
import { oidcRoutes } from "./routes/oidc.js";
import { medicationRoutes } from "./routes/medications.js";
import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js";
import { shareRoutes } from "./routes/share.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
import { medicationRoutes } from "./routes/medications.js";
import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js";
import { refillRoutes } from "./routes/refills.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { settingsRoutes } from "./routes/settings.js";
import { shareRoutes } from "./routes/share.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
// Re-export utilities from server-config for external use
export {
parseCorsOrigins,
buildAppConfig,
buildBaseCookieOptions,
buildRefreshCookieOptions,
buildAppConfig,
ensureImagesDirectory,
getJwtConfig,
parseCorsOrigins,
} from "./utils/server-config.js";
import {
parseCorsOrigins,
buildAppConfig,
buildBaseCookieOptions,
buildRefreshCookieOptions,
buildAppConfig,
ensureImagesDirectory,
getJwtConfig,
parseCorsOrigins,
} from "./utils/server-config.js";
/** Create and configure Fastify app (without starting) */
@@ -88,7 +88,7 @@ export async function createApp(options?: {
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
await app.register(rateLimit, { max: 100, timeWindow: "1 minute" });
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(cookie, { secret: opts.cookieSecret });
// JWT plugin
+5 -5
View File
@@ -1,8 +1,8 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { env } from "./env.js";
import { count, eq, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { sql, count, eq } from "drizzle-orm";
import { env } from "./env.js";
// =============================================================================
// Anonymous User - Used when AUTH_ENABLED=false
@@ -87,7 +87,7 @@ export interface RequestUser {
/**
* Optional auth - verifies JWT if present, but doesn't require it
*/
export async function optionalAuth(request: FastifyRequest, reply: FastifyReply) {
export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply) {
if (!env.AUTH_ENABLED) {
return;
}
@@ -100,7 +100,7 @@ export async function optionalAuth(request: FastifyRequest, reply: FastifyReply)
try {
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
if (user && user.isActive) {
if (user?.isActive) {
request.user = {
id: user.id,
username: user.username,
+29 -9
View File
@@ -1,11 +1,14 @@
import { z } from "zod";
import dotenv from "dotenv";
import { z } from "zod";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
PORT: z
.string()
.transform((v) => parseInt(v, 10))
.default("3000"),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
@@ -13,31 +16,48 @@ const EnvSchema = z.object({
// Auth Configuration
// ==========================================================================
// Master switch: Enable/disable authentication (default: disabled for easy setup)
AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
AUTH_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
// Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"),
REGISTRATION_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
// Disable local auth when using SSO only
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
REFRESH_SECRET: z.string().min(10).optional(),
COOKIE_SECRET: z.string().min(10).optional(),
// Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"),
REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"),
ACCESS_TOKEN_TTL_MINUTES: z
.string()
.transform((v) => parseInt(v, 10))
.default("15"),
REFRESH_TOKEN_TTL_DAYS: z
.string()
.transform((v) => parseInt(v, 10))
.default("7"),
// ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ==========================================================================
OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"),
OIDC_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_REDIRECT_URI: z.string().url().optional(), // e.g., https://medassist.example.com/api/auth/oidc/callback
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"),
OIDC_AUTO_CREATE_USERS: z
.string()
.transform((v) => v === "true")
.default("true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
});
+83 -58
View File
@@ -1,11 +1,10 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import argon2 from "argon2";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { users, refreshTokens } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { env } from "../plugins/env.js";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { refreshTokens, users } from "../db/schema.js";
import { getAuthState, requireAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
@@ -51,11 +50,13 @@ const sensitiveRateLimitConfig = {
// Validation Schemas
// =============================================================================
const registerSchema = z.object({
username: z.string()
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(50, "Username must be at most 50 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
password: z.string()
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be at most 128 characters"),
});
@@ -68,7 +69,8 @@ const loginSchema = z.object({
const updateProfileSchema = z.object({
currentPassword: z.string().optional(),
newPassword: z.string()
newPassword: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be at most 128 characters")
.optional(),
@@ -84,17 +86,21 @@ export async function authRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/state - Public auth state (needed before login)
// Exempt from rate limit - lightweight state check called frequently
// ---------------------------------------------------------------------------
app.get("/auth/state", async () => {
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
return getAuthState();
});
// ---------------------------------------------------------------------------
// POST /auth/register - User registration
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof registerSchema> }>("/auth/register", {
app.post<{ Body: z.infer<typeof registerSchema> }>(
"/auth/register",
{
config: { rateLimit: sensitiveRateLimitConfig },
}, async (request, reply) => {
},
async (request, reply) => {
// Check auth state
const state = await getAuthState();
@@ -115,7 +121,7 @@ export async function authRoutes(app: FastifyInstance) {
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR"
code: "VALIDATION_ERROR",
});
}
@@ -131,11 +137,14 @@ export async function authRoutes(app: FastifyInstance) {
const passwordHash = await argon2.hash(password, ARGON2_OPTIONS);
// Create user
const [newUser] = await db.insert(users).values({
const [newUser] = await db
.insert(users)
.values({
username,
passwordHash,
authProvider: "local",
}).returning();
})
.returning();
app.log.info(`User registered: ${username}`);
@@ -147,14 +156,18 @@ export async function authRoutes(app: FastifyInstance) {
},
message: "Account created",
});
});
}
);
// ---------------------------------------------------------------------------
// POST /auth/login - User login
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof loginSchema> }>("/auth/login", {
app.post<{ Body: z.infer<typeof loginSchema> }>(
"/auth/login",
{
config: { rateLimit: sensitiveRateLimitConfig },
}, async (request, reply) => {
},
async (request, reply) => {
const state = await getAuthState();
if (!state.authEnabled) {
@@ -169,7 +182,7 @@ export async function authRoutes(app: FastifyInstance) {
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid credentials",
code: "VALIDATION_ERROR"
code: "VALIDATION_ERROR",
});
}
@@ -204,9 +217,7 @@ export async function authRoutes(app: FastifyInstance) {
}
// Update last login
await db.update(users)
.set({ lastLoginAt: new Date(), updatedAt: new Date() })
.where(eq(users.id, user.id));
await db.update(users).set({ lastLoginAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
// Generate tokens
const accessToken = app.jwt.sign(
@@ -249,14 +260,18 @@ export async function authRoutes(app: FastifyInstance) {
avatarUrl: user.avatarUrl,
},
});
});
}
);
// ---------------------------------------------------------------------------
// POST /auth/refresh - Refresh access token
// ---------------------------------------------------------------------------
app.post("/auth/refresh", {
app.post(
"/auth/refresh",
{
config: { rateLimit: authRateLimitConfig },
}, async (request, reply) => {
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
if (!refreshTokenCookie) {
return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" });
@@ -264,14 +279,12 @@ export async function authRoutes(app: FastifyInstance) {
try {
// Verify refresh token
const decoded = app.jwt.verify<{ sub: number; jti: string }>(
refreshTokenCookie,
{ key: app.config.refreshSecret }
);
const decoded = app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
key: app.config.refreshSecret,
});
// Check if token exists and is valid
const [token] = await db.select().from(refreshTokens)
.where(eq(refreshTokens.tokenId, decoded.jti));
const [token] = await db.select().from(refreshTokens).where(eq(refreshTokens.tokenId, decoded.jti));
if (!token || token.revoked || token.expiresAt < new Date()) {
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
@@ -284,7 +297,8 @@ export async function authRoutes(app: FastifyInstance) {
}
// Rotate refresh token (revoke old, create new)
await db.update(refreshTokens)
await db
.update(refreshTokens)
.set({ revoked: true, rotatedAt: new Date() })
.where(eq(refreshTokens.id, token.id));
@@ -312,31 +326,29 @@ export async function authRoutes(app: FastifyInstance) {
.setCookie("access_token", newAccessToken, app.config.cookieOptions)
.setCookie("refresh_token", newRefreshToken, app.config.refreshCookieOptions)
.send({ ok: true });
} catch {
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
}
});
}
);
// ---------------------------------------------------------------------------
// POST /auth/logout - Logout (revoke refresh token)
// ---------------------------------------------------------------------------
app.post("/auth/logout", {
app.post(
"/auth/logout",
{
config: { rateLimit: authRateLimitConfig },
}, async (request, reply) => {
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
if (refreshTokenCookie) {
try {
const decoded = app.jwt.verify<{ jti: string }>(
refreshTokenCookie,
{ key: app.config.refreshSecret }
);
const decoded = app.jwt.verify<{ jti: string }>(refreshTokenCookie, { key: app.config.refreshSecret });
// Revoke the refresh token
await db.update(refreshTokens)
.set({ revoked: true })
.where(eq(refreshTokens.tokenId, decoded.jti));
await db.update(refreshTokens).set({ revoked: true }).where(eq(refreshTokens.tokenId, decoded.jti));
} catch {
// Invalid token, ignore
}
@@ -346,7 +358,8 @@ export async function authRoutes(app: FastifyInstance) {
.clearCookie("access_token", app.config.cookieOptions)
.clearCookie("refresh_token", app.config.refreshCookieOptions)
.send({ ok: true });
});
}
);
// ---------------------------------------------------------------------------
// GET /auth/me - Get current user profile
@@ -375,10 +388,13 @@ export async function authRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// PUT /auth/me - Update current user profile
// ---------------------------------------------------------------------------
app.put<{ Body: z.infer<typeof updateProfileSchema> }>("/auth/me", {
app.put<{ Body: z.infer<typeof updateProfileSchema> }>(
"/auth/me",
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
}, async (request, reply) => {
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
@@ -388,7 +404,7 @@ export async function authRoutes(app: FastifyInstance) {
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR"
code: "VALIDATION_ERROR",
});
}
@@ -424,15 +440,19 @@ export async function authRoutes(app: FastifyInstance) {
await db.update(users).set(updates).where(eq(users.id, user.id));
return { ok: true, message: "Profile updated" };
});
}
);
// ---------------------------------------------------------------------------
// POST /auth/avatar - Upload user avatar
// ---------------------------------------------------------------------------
app.post("/auth/avatar", {
app.post(
"/auth/avatar",
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
}, async (request, reply) => {
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
@@ -454,8 +474,8 @@ export async function authRoutes(app: FastifyInstance) {
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
// Save file
const fs = await import("fs/promises");
const path = await import("path");
const fs = await import("node:fs/promises");
const path = await import("node:path");
const imagesDir = path.join(process.cwd(), "data", "images");
await fs.mkdir(imagesDir, { recursive: true });
@@ -476,15 +496,19 @@ export async function authRoutes(app: FastifyInstance) {
await db.update(users).set({ avatarUrl: filename, updatedAt: new Date() }).where(eq(users.id, authUser.id));
return { ok: true, avatarUrl: filename };
});
}
);
// ---------------------------------------------------------------------------
// DELETE /auth/avatar - Delete user avatar
// ---------------------------------------------------------------------------
app.delete("/auth/avatar", {
app.delete(
"/auth/avatar",
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
}, async (request, reply) => {
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
@@ -496,8 +520,8 @@ export async function authRoutes(app: FastifyInstance) {
}
// Delete file
const fs = await import("fs/promises");
const path = await import("path");
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
} catch {
@@ -508,5 +532,6 @@ export async function authRoutes(app: FastifyInstance) {
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
return { ok: true };
});
}
);
}
+56 -84
View File
@@ -1,9 +1,9 @@
import { FastifyInstance } from "fastify";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, shareTokens } from "../db/schema.js";
import { eq, and, inArray } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
@@ -24,7 +24,7 @@ const dismissDosesSchema = z.object({
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: any, reply: any): Promise<number> {
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
// If auth is disabled, use the anonymous user
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
@@ -45,16 +45,11 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /doses/taken - PROTECTED: Get all taken doses for the user
// ---------------------------------------------------------------------------
app.get(
"/doses/taken",
{ preHandler: requireAuth },
async (request, reply) => {
app.get("/doses/taken", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all taken doses for this user (no time limit)
const doses = await db.select()
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
return {
doses: doses.map((d) => ({
@@ -64,8 +59,7 @@ export async function doseRoutes(app: FastifyInstance) {
dismissed: d.dismissed ?? false,
})),
};
}
);
});
// ---------------------------------------------------------------------------
// POST /doses/taken - PROTECTED: Mark a dose as taken
@@ -86,14 +80,10 @@ export async function doseRoutes(app: FastifyInstance) {
const { doseId } = parsed.data;
// Check if already marked
const [existing] = await db.select()
const [existing] = await db
.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing) {
return { success: true, message: "Already marked" };
@@ -121,12 +111,19 @@ export async function doseRoutes(app: FastifyInstance) {
const { doseId } = request.params;
await db.delete(doseTracking).where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
// The dose stays dismissed, we just acknowledge the undo request
} else {
// Not dismissed - delete the record entirely
await db.delete(doseTracking).where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
}
return { success: true };
}
@@ -154,26 +151,18 @@ export async function doseRoutes(app: FastifyInstance) {
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists (taken or dismissed)
const [existing] = await db.select()
const [existing] = await db
.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing) {
// Already exists - update to dismissed if not already
if (!existing.dismissed) {
await db.update(doseTracking)
await db
.update(doseTracking)
.set({ dismissed: true })
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
dismissedCount++;
}
} else {
@@ -195,46 +184,33 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
// ---------------------------------------------------------------------------
app.delete(
"/doses/dismiss",
{ preHandler: requireAuth },
async (request, reply) => {
app.delete("/doses/dismiss", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
// Delete all dismissed-only records (not taken ones)
// For taken+dismissed, just remove the dismissed flag
const dismissed = await db.select()
const dismissed = await db
.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.dismissed, true)
)
);
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
for (const d of dismissed) {
if (d.markedBy !== null || d.takenAt) {
// This was also marked as taken - just remove dismissed flag
await db.update(doseTracking)
.set({ dismissed: false })
.where(eq(doseTracking.id, d.id));
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
} else {
// This was only dismissed - delete it
await db.delete(doseTracking)
.where(eq(doseTracking.id, d.id));
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
}
}
return { success: true, clearedCount: dismissed.length };
}
);
});
// ---------------------------------------------------------------------------
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>(
"/share/:token/doses",
async (request, reply) => {
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
const { token } = request.params;
// Find share token
@@ -244,9 +220,7 @@ export async function doseRoutes(app: FastifyInstance) {
}
// Get all taken doses for this user (no time limit)
const doses = await db.select()
.from(doseTracking)
.where(eq(doseTracking.userId, share.userId));
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
@@ -256,8 +230,7 @@ export async function doseRoutes(app: FastifyInstance) {
dismissed: d.dismissed ?? false,
})),
};
}
);
});
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
@@ -283,14 +256,10 @@ export async function doseRoutes(app: FastifyInstance) {
}
// Check if already marked
const [existing] = await db.select()
const [existing] = await db
.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, share.userId),
eq(doseTracking.doseId, doseId)
)
);
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing) {
return { success: true, message: "Already marked" };
@@ -310,9 +279,7 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/:doseId",
async (request, reply) => {
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
const { token, doseId } = request.params;
// Find share token
@@ -321,14 +288,19 @@ export async function doseRoutes(app: FastifyInstance) {
return reply.notFound("Share link not found");
}
await db.delete(doseTracking).where(
and(
eq(doseTracking.userId, share.userId),
eq(doseTracking.doseId, doseId)
)
);
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
} else {
// Not dismissed - delete the record entirely
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
}
return { success: true };
}
);
});
}
+132 -40
View File
@@ -1,14 +1,14 @@
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 { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { extname, resolve } from "node:path";
import { eq } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } 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");
@@ -33,6 +33,7 @@ const inventorySchema = z.object({
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
});
const medicationExportSchema = z.object({
@@ -47,6 +48,7 @@ const medicationExportSchema = z.object({
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
image: z.string().nullable().optional(), // base64 data URL or null
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
});
const doseHistorySchema = z.object({
@@ -55,6 +57,8 @@ const doseHistorySchema = z.object({
scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(),
dismissed: z.boolean().default(false),
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
});
const shareLinkSchema = z.object({
@@ -64,7 +68,8 @@ const shareLinkSchema = z.object({
regenerateToken: z.boolean().default(true),
});
const settingsExportSchema = z.object({
const settingsExportSchema = z
.object({
// Email notifications
emailEnabled: z.boolean().default(false),
notificationEmail: z.string().nullable().optional(),
@@ -89,7 +94,8 @@ const settingsExportSchema = z.object({
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
}).optional();
})
.optional();
const importDataSchema = z.object({
version: z.string(),
@@ -131,7 +137,9 @@ function parseTakenByJson(takenByJson: string | null | undefined): string[] {
}
// Parse blisters from DB format to export format
function parseBlistersForExport(row: typeof medications.$inferSelect): Array<{ usage: number; every: number; start: string; remind: boolean }> {
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[];
@@ -204,8 +212,10 @@ function base64ToImage(base64: string, medicationId: number): string | 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 {
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
function parseDoseId(
doseId: string
): { medicationId: number; blisterIndex: number; timestampMs: number; person: string | null } | null {
const parts = doseId.split("-");
if (parts.length < 3) return null;
@@ -213,14 +223,18 @@ function parseDoseId(doseId: string): { medicationId: number; blisterIndex: numb
const blisterIndex = parseInt(parts[1], 10);
const timestampMs = parseInt(parts[2], 10);
if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null;
if (Number.isNaN(medicationId) || Number.isNaN(blisterIndex) || Number.isNaN(timestampMs)) return null;
return { medicationId, blisterIndex, timestampMs };
// Check if there's a person suffix (4th part onwards, could be multi-part name)
const person = parts.length > 3 ? parts.slice(3).join("-") : null;
return { medicationId, blisterIndex, timestampMs, person };
}
// Build dose ID from parts
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number): string {
return `${medicationId}-${blisterIndex}-${timestampMs}`;
// Build dose ID from parts (with optional person suffix)
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number, person?: string | null): string {
const base = `${medicationId}-${blisterIndex}-${timestampMs}`;
return person ? `${base}-${person}` : base;
}
// =============================================================================
@@ -233,11 +247,10 @@ export async function exportRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /export - Export all user data
// ---------------------------------------------------------------------------
app.get<{ Querystring: { includeSensitive?: string } }>(
"/export",
async (request, reply) => {
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>("/export", async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
@@ -248,6 +261,21 @@ export async function exportRoutes(app: FastifyInstance) {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
const d = new Date(med.lastStockCorrectionAt);
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
lastStockCorrectionAtIso = null;
}
}
return {
_exportId: exportId,
name: med.name,
@@ -258,39 +286,70 @@ export async function exportRoutes(app: FastifyInstance) {
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
},
pillWeightMg: med.pillWeightMg,
schedules: parseBlistersForExport(med),
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
image: imageToBase64(med.imageUrl),
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const exportDoseHistory = doses.map((dose) => {
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
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
takenAtIso = new Date().toISOString();
}
} catch {
takenAtIso = new Date().toISOString();
}
// Safely convert scheduled time
let scheduledTimeIso: string;
try {
const d = new Date(parsed.timestampMs);
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} catch {
scheduledTimeIso = new Date().toISOString();
}
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: new Date(parsed.timestampMs).toISOString(),
takenAt: dose.takenAt?.toISOString() ?? new Date().toISOString(),
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
};
}).filter((d): d is NonNullable<typeof d> => d !== null);
})
.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 ? {
const exportSettings = settings
? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
@@ -311,17 +370,35 @@ export async function exportRoutes(app: FastifyInstance) {
highStockDays: settings.highStockDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
} : undefined;
}
: undefined;
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => ({
const exportShareLinks = shares.map((share) => {
// Safely convert expiresAt to ISO string
let expiresAtIso: string | null = null;
if (share.expiresAt) {
try {
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
expiresAtIso = share.expiresAt.toISOString();
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
const d = new Date(share.expiresAt);
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
expiresAtIso = null;
}
}
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt?.toISOString() ?? null,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
}));
};
});
// Build export object
const exportData = {
@@ -340,14 +417,20 @@ export async function exportRoutes(app: FastifyInstance) {
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return exportData;
}
);
});
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
app.post(
"/import",
{
config: {
// Increase body limit to 50MB to handle exports with base64 images
rawBody: true,
},
bodyLimit: 50 * 1024 * 1024, // 50 MB
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -371,7 +454,11 @@ export async function exportRoutes(app: FastifyInstance) {
if (med.imageUrl) {
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
if (existsSync(imagePath)) {
try { unlinkSync(imagePath); } catch { /* ignore */ }
try {
unlinkSync(imagePath);
} catch {
/* ignore */
}
}
}
}
@@ -395,7 +482,9 @@ export async function exportRoutes(app: FastifyInstance) {
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
const [inserted] = await db.insert(medications).values({
const [inserted] = await db
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
@@ -404,6 +493,8 @@ export async function exportRoutes(app: FastifyInstance) {
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
usageJson,
everyJson,
@@ -412,7 +503,8 @@ export async function exportRoutes(app: FastifyInstance) {
notes: med.notes || null,
intakeRemindersEnabled,
imageUrl: null, // Will be set after image is saved
}).returning();
})
.returning();
// Save mapping
exportIdToNewId.set(med._exportId, inserted.id);
@@ -421,9 +513,7 @@ export async function exportRoutes(app: FastifyInstance) {
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
await db.update(medications)
.set({ imageUrl })
.where(eq(medications.id, inserted.id));
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
}
}
}
@@ -435,13 +525,15 @@ export async function exportRoutes(app: FastifyInstance) {
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs);
// Rebuild dose ID with optional person suffix
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
dismissed: dose.dismissed ?? false,
});
}
+13 -2
View File
@@ -1,8 +1,19 @@
import { FastifyInstance } from "fastify";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { FastifyInstance } from "fastify";
// Read version from package.json at startup
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const backendVersion = packageJson.version || "unknown";
export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async () => ({
// Exempt from rate limit - lightweight health check
app.get("/health", { config: { rateLimit: false } }, async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
}));
+182 -30
View File
@@ -1,21 +1,22 @@
import { FastifyInstance } from "fastify";
import { createWriteStream, existsSync, unlinkSync } from "node:fs";
import { extname, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { and, eq, like } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, doseTracking } from "../db/schema.js";
import { eq, and, like, sql } from "drizzle-orm";
import { createWriteStream, existsSync, unlinkSync } from "fs";
import { resolve, extname } from "path";
import { pipeline } from "stream/promises";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { doseTracking, medications } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseLocalDateTime } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
const blisterSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime(),
start: z.string().datetime({ local: true }),
});
const medicationSchema = z.object({
@@ -48,7 +49,7 @@ function parseBlisters(row: typeof medications.$inferSelect) {
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
return zipBlisters(usage, every, start);
} catch (err) {
} catch (_err) {
return [];
}
}
@@ -69,7 +70,7 @@ export async function medicationRoutes(app: FastifyInstance) {
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: any, reply: any): Promise<number> {
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
// If auth is disabled, use the anonymous user
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
@@ -96,12 +97,15 @@ export async function medicationRoutes(app: FastifyInstance) {
blistersPerPack: row.blistersPerPack ?? 1,
pillsPerBlister: row.pillsPerBlister ?? 1,
looseTablets: row.looseTablets ?? 0,
stockAdjustment: row.stockAdjustment ?? 0,
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: row.pillWeightMg,
blisters: parseBlisters(row),
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
notes: row.notes,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
dismissedUntil: row.dismissedUntil ?? null,
updatedAt: row.updatedAt,
}));
});
@@ -111,7 +115,20 @@ export async function medicationRoutes(app: FastifyInstance) {
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { name, genericName, takenBy, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const {
name,
genericName,
takenBy,
packCount,
blistersPerPack,
pillsPerBlister,
looseTablets,
pillWeightMg,
expiryDate,
notes,
intakeRemindersEnabled,
blisters,
} = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
@@ -147,6 +164,8 @@ export async function medicationRoutes(app: FastifyInstance) {
blistersPerPack: inserted.blistersPerPack,
pillsPerBlister: inserted.pillsPerBlister,
looseTablets: inserted.looseTablets,
stockAdjustment: inserted.stockAdjustment ?? 0,
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: inserted.pillWeightMg,
blisters,
imageUrl: inserted.imageUrl,
@@ -166,10 +185,26 @@ export async function medicationRoutes(app: FastifyInstance) {
const userId = await getUserId(req, reply);
// Verify ownership
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
const [existing] = await db
.select()
.from(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const { name, genericName, takenBy, packCount, blistersPerPack, pillsPerBlister, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const {
name,
genericName,
takenBy,
packCount,
blistersPerPack,
pillsPerBlister,
looseTablets,
pillWeightMg,
expiryDate,
notes,
intakeRemindersEnabled,
blisters,
} = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
@@ -201,17 +236,16 @@ export async function medicationRoutes(app: FastifyInstance) {
// Clean up dose tracking entries that are before the earliest start date
// This ensures consistency when the user changes the start date
const earliestStart = Math.min(...blisters.map(b => new Date(b.start).getTime()));
const earliestStart = Math.min(...blisters.map((b) => parseLocalDateTime(b.start).getTime()));
if (!Number.isNaN(earliestStart)) {
// Get all dose tracking entries for this medication and filter out invalid ones
const allDoses = await db.select().from(doseTracking)
.where(and(
eq(doseTracking.userId, userId),
like(doseTracking.doseId, `${idNum}-%`)
));
const allDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), like(doseTracking.doseId, `${idNum}-%`)));
// Find doses with timestamps before the earliest start date
const dosesToDelete = allDoses.filter(dose => {
const dosesToDelete = allDoses.filter((dose) => {
const parts = dose.doseId.split("-");
if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
@@ -235,6 +269,8 @@ export async function medicationRoutes(app: FastifyInstance) {
blistersPerPack: result[0].blistersPerPack,
pillsPerBlister: result[0].pillsPerBlister,
looseTablets: result[0].looseTablets,
stockAdjustment: result[0].stockAdjustment ?? 0,
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: result[0].pillWeightMg,
blisters,
imageUrl: result[0].imageUrl,
@@ -245,6 +281,47 @@ export async function medicationRoutes(app: FastifyInstance) {
};
});
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
"/medications/:id/stock-adjustment",
async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const userId = await getUserId(req, reply);
// Verify ownership
const [existing] = await db
.select()
.from(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const { stockAdjustment } = req.body as { stockAdjustment: number };
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
const result = await db
.update(medications)
.set({
stockAdjustment,
lastStockCorrectionAt: new Date(), // Mark when correction was made
updatedAt: new Date(),
})
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
.returning();
if (!result.length) return reply.notFound();
return {
id: result[0].id,
stockAdjustment: result[0].stockAdjustment ?? 0,
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
updatedAt: result[0].updatedAt,
};
}
);
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
@@ -252,7 +329,10 @@ export async function medicationRoutes(app: FastifyInstance) {
const userId = await getUserId(req, reply);
// Delete associated image if exists (with ownership check)
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
const [existing] = await db
.select()
.from(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
if (existing.imageUrl) {
@@ -260,7 +340,10 @@ export async function medicationRoutes(app: FastifyInstance) {
if (existsSync(imagePath)) unlinkSync(imagePath);
}
const deleted = await db.delete(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))).returning();
const deleted = await db
.delete(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
.returning();
if (!deleted.length) return reply.notFound();
return reply.status(204).send();
});
@@ -271,7 +354,10 @@ export async function medicationRoutes(app: FastifyInstance) {
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const userId = await getUserId(req, reply);
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
const [existing] = await db
.select()
.from(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const data = await req.file();
@@ -294,7 +380,10 @@ export async function medicationRoutes(app: FastifyInstance) {
if (existsSync(oldPath)) unlinkSync(oldPath);
}
await db.update(medications).set({ imageUrl: filename, updatedAt: new Date() }).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
await db
.update(medications)
.set({ imageUrl: filename, updatedAt: new Date() })
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
return { success: true, imageUrl: filename };
});
@@ -305,7 +394,10 @@ export async function medicationRoutes(app: FastifyInstance) {
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const userId = await getUserId(req, reply);
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
const [existing] = await db
.select()
.from(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
if (existing.imageUrl) {
@@ -313,7 +405,10 @@ export async function medicationRoutes(app: FastifyInstance) {
if (existsSync(filepath)) unlinkSync(filepath);
}
await db.update(medications).set({ imageUrl: null, updatedAt: new Date() }).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
await db
.update(medications)
.set({ imageUrl: null, updatedAt: new Date() })
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
return reply.status(204).send();
});
@@ -339,12 +434,13 @@ export async function medicationRoutes(app: FastifyInstance) {
const packCount = row.packCount ?? 1;
const blistersPerPack = row.blistersPerPack ?? 1;
const looseTablets = row.looseTablets ?? 0;
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets;
const stockAdjustment = row.stockAdjustment ?? 0;
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
// Calculate consumption up to now (same logic as frontend)
let consumedUntilNow = 0;
blisters.forEach((blister) => {
const blisterStart = new Date(blister.start);
const blisterStart = parseLocalDateTime(blister.start);
if (Number.isNaN(blisterStart.getTime()) || blisterStart > now) return;
const msPerDay = 86400000;
const period = Math.max(1, blister.every) * msPerDay;
@@ -383,12 +479,68 @@ export async function medicationRoutes(app: FastifyInstance) {
return payload;
});
// ---------------------------------------------------------------------------
// POST /medications/dismiss-until - Set dismissedUntil date for multiple medications
// This is more robust than storing individual dose IDs (which can change with schedule updates)
// ---------------------------------------------------------------------------
const dismissUntilSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1),
until: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"),
});
app.post<{ Body: z.infer<typeof dismissUntilSchema> }>("/medications/dismiss-until", async (req, reply) => {
const parsed = dismissUntilSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" });
}
const userId = await getUserId(req, reply);
const { medicationIds, until } = parsed.data;
// Update dismissedUntil for all specified medications owned by this user
let updatedCount = 0;
for (const medId of medicationIds) {
const result = await db
.update(medications)
.set({ dismissedUntil: until })
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (result.rowsAffected > 0) {
updatedCount++;
}
}
return { success: true, updatedCount };
});
// ---------------------------------------------------------------------------
// DELETE /medications/:id/dismiss-until - Clear dismissedUntil for a medication
// ---------------------------------------------------------------------------
app.delete<{ Params: { id: string } }>("/medications/:id/dismiss-until", async (req, reply) => {
const medId = parseInt(req.params.id, 10);
if (Number.isNaN(medId)) {
return reply.status(400).send({ error: "Invalid medication ID" });
}
const userId = await getUserId(req, reply);
await db
.update(medications)
.set({ dismissedUntil: null })
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
return { success: true };
});
}
function calculateUsageInRange(blisters: Array<{ usage: number; every: number; start: string }>, start: Date, end: Date) {
function calculateUsageInRange(
blisters: Array<{ usage: number; every: number; start: string }>,
start: Date,
end: Date
) {
let total = 0;
blisters.forEach((blister) => {
const blisterStart = new Date(blister.start);
const blisterStart = parseLocalDateTime(blister.start);
if (Number.isNaN(blisterStart.getTime())) return;
// iterate occurrences from blisterStart up to end
for (let dt = new Date(blisterStart); dt < end; dt.setDate(dt.getDate() + blister.every)) {
+29 -38
View File
@@ -1,9 +1,9 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply } from "fastify";
import * as client from "openid-client";
import { randomBytes, createHash } from "crypto";
import { db } from "../db/client.js";
import { users, refreshTokens } from "../db/schema.js";
import { eq, sql } from "drizzle-orm";
import { refreshTokens, users } from "../db/schema.js";
import { env } from "../plugins/env.js";
// =============================================================================
@@ -18,11 +18,7 @@ async function getOIDCConfig(): Promise<client.Configuration> {
throw new Error("OIDC not configured");
}
oidcConfig = await client.discovery(
new URL(env.OIDC_ISSUER_URL),
env.OIDC_CLIENT_ID,
env.OIDC_CLIENT_SECRET
);
oidcConfig = await client.discovery(new URL(env.OIDC_ISSUER_URL), env.OIDC_CLIENT_ID, env.OIDC_CLIENT_SECRET);
return oidcConfig;
}
@@ -55,10 +51,10 @@ function getFrontendUrl(): string {
export async function oidcRoutes(app: FastifyInstance) {
if (!env.OIDC_ENABLED) {
// Register a disabled route that returns an error
app.get("/auth/oidc/login", async (request, reply) => {
app.get("/auth/oidc/login", async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
app.get("/auth/oidc/callback", async (request, reply) => {
app.get("/auth/oidc/callback", async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
return;
@@ -67,7 +63,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/oidc/login - Initiates OIDC flow
// ---------------------------------------------------------------------------
app.get("/auth/oidc/login", async (request, reply) => {
app.get("/auth/oidc/login", async (_request, reply) => {
try {
const config = await getOIDCConfig();
@@ -148,13 +144,17 @@ export async function oidcRoutes(app: FastifyInstance) {
try {
const config = await getOIDCConfig();
const redirectUri = env.OIDC_REDIRECT_URI!;
const _redirectUri = env.OIDC_REDIRECT_URI!;
// Exchange code for tokens
const tokens = await client.authorizationCodeGrant(config, new URL(request.url, `http://${request.headers.host}`), {
const tokens = await client.authorizationCodeGrant(
config,
new URL(request.url, `http://${request.headers.host}`),
{
pkceCodeVerifier: storedVerifier.value,
expectedState: state,
});
}
);
// Get user info
const sub = tokens.claims()?.sub;
@@ -166,7 +166,8 @@ export async function oidcRoutes(app: FastifyInstance) {
// Extract username from configured claim
const usernameClaim = env.OIDC_USERNAME_CLAIM;
let username = (userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
const username =
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
const oidcSubject = userInfo.sub;
if (!username || !oidcSubject) {
@@ -179,16 +180,14 @@ export async function oidcRoutes(app: FastifyInstance) {
reply.clearCookie("oidc_state", { path: "/" });
// Find or create user
let user = await findOrCreateOIDCUser(username, oidcSubject, reply);
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
if (!user) {
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
}
// Update last login
await db.update(users)
.set({ lastLoginAt: new Date() })
.where(eq(users.id, user.id));
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
// Issue JWT tokens (same as local auth)
const accessToken = await generateAccessToken(app, user.id, user.username);
@@ -202,14 +201,15 @@ export async function oidcRoutes(app: FastifyInstance) {
});
// Set cookies (use app's centralized cookie options)
console.log(`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`);
console.log(
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
);
setAuthCookies(app, reply, accessToken, refreshToken);
// Redirect to frontend dashboard
// In dev: CORS_ORIGINS contains the frontend URL
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: any) {
console.error("[OIDC] Callback error:", err);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
@@ -224,30 +224,23 @@ export async function oidcRoutes(app: FastifyInstance) {
async function findOrCreateOIDCUser(
username: string,
oidcSubject: string,
reply: FastifyReply
_reply: FastifyReply
): Promise<{ id: number; username: string } | null> {
// First, try to find user by OIDC subject (most reliable)
const [existingBySubject] = await db.select()
.from(users)
.where(eq(users.oidcSubject, oidcSubject));
const [existingBySubject] = await db.select().from(users).where(eq(users.oidcSubject, oidcSubject));
if (existingBySubject) {
return { id: existingBySubject.id, username: existingBySubject.username };
}
// Check if username already exists (potential collision)
const [existingByUsername] = await db.select()
.from(users)
.where(eq(users.username, username));
const [existingByUsername] = await db.select().from(users).where(eq(users.username, username));
if (existingByUsername) {
// Username collision! Check if it's a local user without OIDC linked
if (existingByUsername.authProvider === "local" && !existingByUsername.oidcSubject) {
// Local user exists without SSO - link this OIDC account to existing user
await db.update(users)
.set({ oidcSubject: oidcSubject })
.where(eq(users.id, existingByUsername.id));
await db.update(users).set({ oidcSubject: oidcSubject }).where(eq(users.id, existingByUsername.id));
console.log(`[OIDC] Linked OIDC to existing local user: ${username}`);
return { id: existingByUsername.id, username: existingByUsername.username };
} else if (existingByUsername.oidcSubject && existingByUsername.oidcSubject !== oidcSubject) {
@@ -264,7 +257,8 @@ async function findOrCreateOIDCUser(
}
// Create new OIDC user
const [newUser] = await db.insert(users)
const [newUser] = await db
.insert(users)
.values({
username,
passwordHash: null,
@@ -282,10 +276,7 @@ async function findOrCreateOIDCUser(
// JWT Token Generation (reused from auth.ts logic)
// =============================================================================
async function generateAccessToken(app: FastifyInstance, userId: number, username: string): Promise<string> {
return app.jwt.sign(
{ sub: userId, username },
{ expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` }
);
return app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
}
async function generateRefreshToken(
+62 -43
View File
@@ -1,22 +1,22 @@
import { FastifyInstance } from "fastify";
import type { FastifyInstance, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
import { getDateLocale, getTranslations, t, type Language } from "../i18n/translations.js";
import type { AuthUser } from "../types/fastify.js";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
// Escape HTML to prevent XSS in email templates
function escapeHtml(text: string): string {
const htmlEscapes: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return text.replace(/[&<>"']/g, char => htmlEscapes[char] || char);
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
}
type PlannerRow = {
@@ -57,11 +57,11 @@ export async function plannerRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
// Helper to get user ID from request
async function getUserId(request: any): Promise<number> {
async function getUserId(request: FastifyRequest): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as AuthUser | null;
const authUser = request.user as unknown as AuthUser | null;
if (!authUser?.id) {
throw new Error("User not authenticated");
}
@@ -78,7 +78,7 @@ export async function plannerRoutes(app: FastifyInstance) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
@@ -95,40 +95,50 @@ export async function plannerRoutes(app: FastifyInstance) {
}
const locale = getDateLocale(language);
// Format dates for display
const fromDate = new Date(from).toLocaleDateString(locale, {
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
const fromDate = escapeHtml(
new Date(from).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
const untilDate = new Date(until).toLocaleDateString(locale, {
})
);
const untilDate = escapeHtml(
new Date(until).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
})
);
// Build HTML table with horizontal scroll for mobile
// Escape/coerce all user-provided values to prevent XSS
const tableRows = rows
.map(
(row) => `
.map((row) => {
const safeName = escapeHtml(row.medicationName);
const safeTotalPills = Number(row.totalPills) || 0;
const safePlannerUsage = Number(row.plannerUsage) || 0;
const safeBlistersNeeded = Number(row.blistersNeeded) || 0;
const safeBlisterSize = Number(row.blisterSize) || 0;
const safeFullBlisters = Number(row.fullBlisters) || 0;
const safeLoosePills = Number(row.loosePills) || 0;
return `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${escapeHtml(row.medicationName)}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.totalPills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.plannerUsage}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.blistersNeeded} × ${row.blisterSize}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.fullBlisters}${row.loosePills > 0 ? ` (+${row.loosePills})` : ""}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safeTotalPills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeBlistersNeeded} × ${safeBlisterSize}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeFullBlisters}${safeLoosePills > 0 ? ` (+${safeLoosePills})` : ""}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
row.enough
? "background: #d1fae5; color: #065f46;"
: "background: #fee2e2; color: #991b1b;"
row.enough ? "background: #d1fae5; color: #065f46;" : "background: #fee2e2; color: #991b1b;"
}">
${row.enough ? "✓ OK" : "✗ Out of Stock"}
</span>
</td>
</tr>
`
)
`;
})
.join("");
const outOfStockCount = rows.filter((r) => !r.enough).length;
@@ -215,7 +225,7 @@ Sent from MedAssist-ng Medication Planner`;
// Reminder notification for low stock medications (supports email and push)
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
const { email, lowStock, language: bodyLanguage } = request.body;
const { email, lowStock } = request.body;
if (!lowStock || lowStock.length === 0) {
return reply.status(400).send({ error: "Missing low stock data" });
@@ -233,15 +243,15 @@ Sent from MedAssist-ng Medication Planner`;
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
// Separate empty from low stock medications
const emptyMeds = lowStock.filter(r => r.medsLeft <= 0);
const lowMeds = lowStock.filter(r => r.medsLeft > 0);
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
const lowMeds = lowStock.filter((r) => r.medsLeft > 0);
// Send email if enabled
if (notificationSettings.emailEnabled && email) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
@@ -291,12 +301,17 @@ Sent from MedAssist-ng Medication Planner`;
const isEmpty = row.medsLeft <= 0;
const statusIcon = isEmpty ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : "white";
// Escape user-provided strings and coerce numbers to prevent XSS
const safeName = escapeHtml(row.name);
const safeMedsLeft = Number(row.medsLeft) || 0;
const safeDaysLeft = Number(row.daysLeft) || 0;
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${escapeHtml(row.name)}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeMedsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : safeDepletionDate}</td>
</tr>`;
};
@@ -411,12 +426,16 @@ Sent from MedAssist-ng Medication Planner`;
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection}:`);
emptyMeds.forEach(r => messageParts.push(`${r.name}`));
emptyMeds.forEach((r) => messageParts.push(`${r.name}`));
}
if (lowMeds.length > 0) {
if (emptyMeds.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowSection}:`);
lowMeds.forEach(r => messageParts.push(`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`));
lowMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
const message = messageParts.join("\n");
@@ -450,7 +469,7 @@ Sent from MedAssist-ng Medication Planner`;
if (sentChannels.length > 0) {
return reply.send({
success: true,
message: `Reminder sent via ${sentChannels.join(" and ")}`
message: `Reminder sent via ${sentChannels.join(" and ")}`,
});
} else if (results.errors.length > 0) {
return reply.status(500).send({ error: results.errors.join("; ") });
+26 -19
View File
@@ -1,25 +1,27 @@
import { FastifyInstance } from "fastify";
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, refillHistory } from "../db/schema.js";
import { eq, and, desc } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
const refillSchema = z.object({
const refillSchema = z
.object({
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
}).refine(data => data.packsAdded > 0 || data.loosePillsAdded > 0, {
})
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
message: "Must add at least one pack or some loose pills",
});
});
export async function refillRoutes(app: FastifyInstance) {
// All refill routes require auth
app.addHook("preHandler", requireAuth);
// Helper to get user ID from request
async function getUserId(request: any, reply: any): Promise<number> {
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
@@ -42,9 +44,10 @@ export async function refillRoutes(app: FastifyInstance) {
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db.select().from(medications).where(
and(eq(medications.id, medId), eq(medications.userId, userId))
);
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded } = parsed.data;
@@ -53,7 +56,8 @@ export async function refillRoutes(app: FastifyInstance) {
const newPackCount = med.packCount + packsAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded;
await db.update(medications)
await db
.update(medications)
.set({
packCount: newPackCount,
looseTablets: newLooseTablets,
@@ -62,7 +66,8 @@ export async function refillRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
// Create refill history entry
const [refill] = await db.insert(refillHistory)
const [refill] = await db
.insert(refillHistory)
.values({
medicationId: medId,
userId,
@@ -73,7 +78,7 @@ export async function refillRoutes(app: FastifyInstance) {
// Calculate pills added for response
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = (packsAdded * pillsPerPack) + loosePillsAdded;
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
return {
success: true,
@@ -100,24 +105,26 @@ export async function refillRoutes(app: FastifyInstance) {
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db.select().from(medications).where(
and(eq(medications.id, medId), eq(medications.userId, userId))
);
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Get refill history, newest first
const refills = await db.select()
const refills = await db
.select()
.from(refillHistory)
.where(eq(refillHistory.medicationId, medId))
.orderBy(desc(refillHistory.refillDate));
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
return refills.map(r => ({
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: (r.packsAdded * pillsPerPack) + r.loosePillsAdded,
totalPillsAdded: r.packsAdded * pillsPerPack + r.loosePillsAdded,
refillDate: r.refillDate,
}));
});
+105 -51
View File
@@ -1,12 +1,12 @@
import { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import type { Language } from "../i18n/translations.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import type { Language } from "../i18n/translations.js";
// Exported type for use in schedulers
export type UserSettings = {
@@ -33,6 +33,8 @@ export type UserSettings = {
lastAutoEmailSent: string | null;
lastNotificationType: string | null;
lastNotificationChannel: string | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
};
type SettingsBody = {
@@ -77,7 +79,7 @@ function envInt(key: string, defaultVal: number): number {
const val = process.env[key];
if (val === undefined) return defaultVal;
const parsed = parseInt(val, 10);
return isNaN(parsed) ? defaultVal : parsed;
return Number.isNaN(parsed) ? defaultVal : parsed;
}
// Default settings for new users - read from ENV with fallbacks
@@ -105,6 +107,8 @@ function getDefaultSettings() {
lastAutoEmailSent: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
};
}
@@ -114,10 +118,13 @@ async function getOrCreateUserSettings(userId: number) {
if (!settings) {
// Create default settings for user (using ENV defaults)
[settings] = await db.insert(userSettings).values({
[settings] = await db
.insert(userSettings)
.values({
userId,
...getDefaultSettings(),
}).returning();
})
.returning();
}
return settings;
@@ -150,13 +157,15 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
};
}
// Get all users with settings for scheduler
export async function getAllUserSettings(): Promise<UserSettings[]> {
const allSettings = await db.select().from(userSettings);
return allSettings.map(settings => ({
return allSettings.map((settings) => ({
userId: settings.userId,
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
@@ -180,6 +189,8 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
}));
}
@@ -232,7 +243,7 @@ export async function settingsRoutes(app: FastifyInstance) {
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
// SMTP settings (from .env - shared/server-configured)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
smtpUser: process.env.SMTP_USER ?? "",
smtpFrom: process.env.SMTP_FROM ?? "",
smtpSecure: process.env.SMTP_SECURE === "true",
@@ -241,6 +252,8 @@ export async function settingsRoutes(app: FastifyInstance) {
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
// Server settings (from .env, read-only)
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
});
@@ -287,9 +300,7 @@ export async function settingsRoutes(app: FastifyInstance) {
};
if (existingSettings.length > 0) {
await db.update(userSettings)
.set(settingsData)
.where(eq(userSettings.userId, userId));
await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId: userId,
@@ -307,7 +318,7 @@ export async function settingsRoutes(app: FastifyInstance) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
@@ -358,7 +369,11 @@ export async function settingsRoutes(app: FastifyInstance) {
}
try {
const result = await sendShoutrrrNotification(url, "MedAssist-ng Test", "This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!");
const result = await sendShoutrrrNotification(
url,
"MedAssist-ng Test",
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
);
if (result.success) {
return reply.send({ success: true, message: "Test notification sent successfully" });
@@ -372,27 +387,29 @@ export async function settingsRoutes(app: FastifyInstance) {
});
}
// Validate URL to prevent SSRF attacks
function isAllowedNotificationUrl(urlStr: string): { allowed: boolean; error?: string } {
// Validate and sanitize URL to prevent SSRF attacks
// Returns a reconstructed URL from validated components to break taint tracking
function sanitizeNotificationUrl(
urlStr: string
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
try {
// Convert ntfy:// to https:// for parsing
const normalizedUrl = urlStr.startsWith("ntfy://")
? urlStr.replace("ntfy://", "https://")
: urlStr;
// Convert ntfy:// to https:// for parsing, track if it was ntfy
const isNtfy = urlStr.startsWith("ntfy://");
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
const parsed = new URL(normalizedUrl);
// Only allow http and https protocols
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { allowed: false, error: "Only HTTP/HTTPS protocols are allowed" };
if (!["http:", "https:"].includes(parsed.protocol)) {
return { error: "Only HTTP/HTTPS protocols are allowed" };
}
// Block private/internal IP addresses
const hostname = parsed.hostname.toLowerCase();
// Block localhost
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return { allowed: false, error: "Localhost URLs are not allowed" };
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return { error: "Localhost URLs are not allowed" };
}
// Block private IP ranges (basic check)
@@ -400,66 +417,104 @@ function isAllowedNotificationUrl(urlStr: string): { allowed: boolean; error?: s
if (ipMatch) {
const [, a, b] = ipMatch.map(Number);
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
if (a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) || (a === 169 && b === 254)) {
return { allowed: false, error: "Private IP addresses are not allowed" };
if (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
) {
return { error: "Private IP addresses are not allowed" };
}
}
// Block common internal hostnames
if (hostname.endsWith('.local') || hostname.endsWith('.internal') ||
hostname.endsWith('.lan') || hostname === 'metadata.google.internal') {
return { allowed: false, error: "Internal hostnames are not allowed" };
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname === "metadata.google.internal"
) {
return { error: "Internal hostnames are not allowed" };
}
return { allowed: true };
// Reconstruct URL from validated components - this breaks taint tracking
// because we're building a new string from validated parts, not passing through user input
const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
// Extract auth credentials separately for ntfy (they're in the URL but not in host)
const auth =
isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined;
return { url: reconstructedUrl, isNtfy, auth };
} catch {
return { allowed: false, error: "Invalid URL format" };
return { error: "Invalid URL format" };
}
}
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> {
export async function sendShoutrrrNotification(
urlStr: string,
title: string,
message: string
): Promise<{ success: boolean; error?: string }> {
try {
// Validate URL to prevent SSRF
const validation = isAllowedNotificationUrl(urlStr);
if (!validation.allowed) {
// Validate and sanitize URL to prevent SSRF - this reconstructs the URL
// from validated components, breaking taint tracking
const validation = sanitizeNotificationUrl(urlStr);
if ("error" in validation) {
return { success: false, error: validation.error };
}
// Use ONLY the reconstructed URL from validation - never the original urlStr
const { url: sanitizedUrl, isNtfy, auth } = validation;
let targetUrl: string;
let method = "POST";
const method = "POST";
let headers: Record<string, string> = {};
let body: string | undefined;
// Remove emojis from title for header compatibility
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]||/gu, "").trim();
const cleanTitle = title
.replace(
/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]||/gu,
""
)
.trim();
if (urlStr.startsWith("ntfy://")) {
const parsed = new URL(urlStr.replace("ntfy://", "https://"));
targetUrl = `https://${parsed.host}${parsed.pathname}`;
headers = { "Title": cleanTitle, "Tags": "pill" };
// Determine notification type based on validation result and URL pattern
const isNtfyUrl = isNtfy || sanitizedUrl.includes("ntfy.sh") || sanitizedUrl.includes("/ntfy/");
if (isNtfyUrl) {
targetUrl = sanitizedUrl;
headers = { Title: cleanTitle, Tags: "pill" };
body = message;
if (parsed.username && parsed.password) {
headers["Authorization"] = "Basic " + Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
// Add auth if present (extracted during sanitization)
if (auth) {
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
}
} else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) {
targetUrl = urlStr;
headers = { "Title": cleanTitle, "Tags": "pill" };
body = message;
} else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
targetUrl = urlStr;
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
targetUrl = sanitizedUrl;
headers = { "Content-Type": "application/json" };
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
} else {
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
}
// SSRF protection: targetUrl is reconstructed from sanitizeNotificationUrl() which validates:
// - Only http/https protocols allowed
// - Blocks localhost (localhost, 127.0.0.1, ::1)
// - Blocks private IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x)
// - Blocks internal hostnames (.local, .internal, .lan, metadata.google.internal)
// - redirect: "error" prevents redirect-based bypass attacks
// This is an intentional feature: users configure their own external notification services
// lgtm [js/request-forgery]
const response = await fetch(targetUrl, {
method,
headers,
body,
redirect: "error", // Don't follow redirects that could bypass validation
});
if (response.ok) {
@@ -473,4 +528,3 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me
return { success: false, error: errorMessage };
}
}
+13 -14
View File
@@ -1,10 +1,10 @@
import { FastifyInstance } from "fastify";
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
@@ -21,7 +21,7 @@ const createShareSchema = z.object({
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: any, reply: any): Promise<number> {
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
// If auth is disabled, use the anonymous user
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
@@ -61,7 +61,7 @@ export async function shareRoutes(app: FastifyInstance) {
if (!share) {
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND"
code: "NOT_FOUND",
});
}
@@ -113,7 +113,8 @@ export async function shareRoutes(app: FastifyInstance) {
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
const totalPills =
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
return {
id: med.id,
name: med.name,
@@ -127,6 +128,7 @@ export async function shareRoutes(app: FastifyInstance) {
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
blisters,
dismissedUntil: med.dismissedUntil,
};
});
@@ -200,14 +202,12 @@ export async function shareRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
app.get(
"/share/people",
{ preHandler: requireAuth },
async (request, reply) => {
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all unique takenBy values for this user (from JSON arrays)
const meds = await db.select({ takenByJson: medications.takenByJson })
const meds = await db
.select({ takenByJson: medications.takenByJson })
.from(medications)
.where(eq(medications.userId, userId));
@@ -221,6 +221,5 @@ export async function shareRoutes(app: FastifyInstance) {
}
return { people: [...allPeople].sort() };
}
);
});
}
+228 -75
View File
@@ -1,27 +1,26 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { and, eq, gte, lte } from "drizzle-orm";
import nodemailer from "nodemailer";
import { eq, and, gte, lte } from "drizzle-orm";
import { db } from "../db/client.js";
import { medications, doseTracking } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { doseTracking, medications } from "../db/schema.js";
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
import { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
// Import shared utilities
import {
getTimezone,
parseBlisters,
parseTakenByJson,
getUpcomingIntakes,
getTodaysIntakes,
parseIntakeReminderState,
createDefaultIntakeReminderState,
cleanOldIntakeReminders,
type Blister,
cleanOldIntakeReminders,
createDefaultIntakeReminderState,
getTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type IntakeReminderState,
parseBlisters,
parseIntakeReminderState,
parseTakenByJson,
type UpcomingIntake,
} from "../utils/scheduler-utils.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
@@ -52,12 +51,14 @@ async function sendIntakeReminderEmail(
intakes: UpcomingIntake[],
language: Language,
isRepeat: boolean = false,
repeatIntervalMinutes?: number
repeatIntervalMinutes?: number,
currentCount?: number,
maxCount?: number
): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
@@ -99,14 +100,31 @@ async function sendIntakeReminderEmail(
)
.join("");
const alertText = intakes.length === 1
const alertText =
intakes.length === 1
? tr.intakeReminder.alertSingle
: 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 });
let description: string;
if (isRepeat && repeatIntervalMinutes && currentCount !== undefined && maxCount !== undefined) {
const remainingReminders = maxCount - currentCount;
if (remainingReminders <= 0) {
description = language === "de" ? "⚠️ Dies ist die letzte Erinnerung." : "⚠️ This is the last reminder.";
} else if (remainingReminders === 1) {
description =
language === "de"
? `️ Eine weitere Erinnerung wird in ${repeatIntervalMinutes} Minuten gesendet.`
: `️ One more reminder will be sent in ${repeatIntervalMinutes} minutes.`;
} else {
description =
language === "de"
? `${remainingReminders} weitere Erinnerungen werden alle ${repeatIntervalMinutes} Minuten gesendet.`
: `${remainingReminders} more reminders will be sent every ${repeatIntervalMinutes} minutes.`;
}
} else {
description = t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE });
}
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
@@ -156,17 +174,19 @@ async function sendIntakeReminderEmail(
${description}
${intakes.map((i) => {
${intakes
.map((i) => {
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`;
}).join("\n")}
})
.join("\n")}
---
${tr.intakeReminder.footer}`;
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(", ") });
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}`
: t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") });
try {
const transporter = nodemailer.createTransport({
@@ -194,7 +214,10 @@ ${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
@@ -219,22 +242,32 @@ async function checkAndSendIntakeRemindersForUser(
const language = settings.language;
const tr = getTranslations(language);
logger.info(`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`);
logger.info(
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
);
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
if (!emailEnabled && !shoutrrrEnabled) {
logger.info(`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
logger.info(
`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
return; // No intake reminder notifications enabled for this user
}
logger.info(`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
logger.info(
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
// Get all medications with intake reminders enabled for this user
const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
@@ -256,46 +289,70 @@ async function checkAndSendIntakeRemindersForUser(
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()}`);
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) {
const blisters = parseBlistersFromRow(med);
const takenByArray = parseTakenByJson(med.takenByJson);
logger.info(`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters`);
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}`);
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)`);
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 => ({
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)`);
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 => ({
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,
})));
}))
);
}
});
}
@@ -309,7 +366,13 @@ async function checkAndSendIntakeRemindersForUser(
// Determine which doses need reminders (new or repeated)
const nowMs = Date.now();
let remindersToSend: typeof allUpcoming = [];
const maxReminders = settings.maxNaggingReminders ?? 5;
type ReminderWithCount = (typeof allUpcoming)[number] & {
currentSendCount: number; // 0 = advance reminder (no counter), 1+ = nagging count
maxReminders: number;
isAdvanceReminder: boolean; // true if this is the 15-min-before reminder
};
let remindersToSend: ReminderWithCount[] = [];
for (const intake of allUpcoming) {
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
@@ -318,21 +381,40 @@ async function checkAndSendIntakeRemindersForUser(
const isIntakePast = intakeTimeMs < nowMs;
if (!existingEntry) {
// 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'})`);
// New dose - send first reminder
if (isIntakePast) {
// Already missed - this is first nagging reminder (count=1)
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
logger.info(
`[IntakeReminder] User ${settings.userId}: First nagging for missed "${intake.medName}" at ${intake.intakeTimeStr} (1/${maxReminders})`
);
} else {
// Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
logger.info(
`[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}`
);
}
} else if (settings.repeatRemindersEnabled && isIntakePast) {
// Repeat reminder - only for intakes that are already past (missed)
// Intake time passed - check if we need to send nagging reminder
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}`);
// If only advance reminder was sent (sendCount=0), first nagging has count=1
// Otherwise increment from current sendCount
const currentNaggingCount = existingEntry.sendCount;
if (currentNaggingCount >= maxReminders) {
// Max nagging reminders reached - stop
logger.info(
`[IntakeReminder] User ${settings.userId}: Max nagging (${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})`);
const nextSendCount = currentNaggingCount + 1;
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
logger.info(
`[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})`
);
}
}
// Else: Already sent and either repeats disabled or intake not yet past - skip
@@ -345,7 +427,10 @@ async function checkAndSendIntakeRemindersForUser(
// 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(
const takenToday = await db
.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, settings.userId),
gte(doseTracking.takenAt, todayStart),
@@ -353,16 +438,16 @@ async function checkAndSendIntakeRemindersForUser(
)
);
const takenDoseIds = new Set(takenToday.map(d => d.doseId));
const takenDoseIds = new Set(takenToday.map((d) => d.doseId));
// Filter out reminders for doses that were already taken
remindersToSend = remindersToSend.filter(intake => {
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 anyTaken = intake.takenBy.some((person) => {
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`;
return takenDoseIds.has(doseId);
});
@@ -385,7 +470,7 @@ async function checkAndSendIntakeRemindersForUser(
// 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 isRepeatReminder = remindersToSend.some((intake) => {
const intakeTimeMs = intake.intakeTime.getTime();
const isIntakePast = intakeTimeMs < nowMs;
return isIntakePast; // Use repeat message for ANY missed intake
@@ -396,12 +481,19 @@ async function checkAndSendIntakeRemindersForUser(
// Send email if enabled for intake reminders
if (emailEnabled) {
// Calculate counts for repeat reminder text
const hasNaggingReminder = remindersToSend.some((r) => !r.isAdvanceReminder);
const highestSendCount = Math.max(...remindersToSend.map((r) => r.currentSendCount));
const maxReminderCount = remindersToSend[0]?.maxReminders ?? 5;
const result = await sendIntakeReminderEmail(
settings.notificationEmail!,
remindersToSend,
language,
isRepeatReminder,
settings.reminderRepeatIntervalMinutes
settings.reminderRepeatIntervalMinutes,
hasNaggingReminder ? highestSendCount : undefined,
hasNaggingReminder ? maxReminderCount : undefined
);
emailSuccess = result.success;
if (result.success) {
@@ -413,17 +505,48 @@ async function checkAndSendIntakeRemindersForUser(
// Send Shoutrrr notification if enabled for intake reminders
if (shoutrrrEnabled) {
const title = isRepeatReminder
? (language === 'de' ? '⚠️ Medikamenten-Erinnerung' : '⚠️ Medication Reminder')
: t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
// Check if any reminder is a nagging reminder (not advance)
const hasNaggingReminder = remindersToSend.some((r) => !r.isAdvanceReminder);
const highestSendCount = Math.max(...remindersToSend.map((r) => r.currentSendCount));
const maxReminderCount = remindersToSend[0]?.maxReminders ?? 5;
const repeatNote = isRepeatReminder && settings.reminderRepeatIntervalMinutes
? `\n\n⚠️ This reminder will be sent every ${settings.reminderRepeatIntervalMinutes} minutes until marked as taken.`
: '';
let title: string;
if (hasNaggingReminder && highestSendCount > 0) {
// Nagging reminder - show counter
const counterStr = `(${highestSendCount}/${maxReminderCount})`;
title = language === "de" ? `⚠️ Medikamenten-Erinnerung ${counterStr}` : `⚠️ Medication Reminder ${counterStr}`;
} else {
// Advance reminder - no counter
title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
}
const message = remindersToSend
// Only show repeat note for nagging reminders, not for advance reminders
let repeatNote = "";
if (hasNaggingReminder && settings.reminderRepeatIntervalMinutes) {
const remainingReminders = maxReminderCount - highestSendCount;
if (remainingReminders <= 0) {
// Last reminder
repeatNote = language === "de" ? "\n\n⚠️ Dies ist die letzte Erinnerung." : "\n\n⚠️ This is the last reminder.";
} else if (remainingReminders === 1) {
// One more reminder
repeatNote =
language === "de"
? `\n\n️ Eine weitere Erinnerung wird in ${settings.reminderRepeatIntervalMinutes} Minuten gesendet.`
: `\n\n️ One more reminder will be sent in ${settings.reminderRepeatIntervalMinutes} minutes.`;
} else {
// Multiple reminders remaining
repeatNote =
language === "de"
? `\n\n${remainingReminders} weitere Erinnerungen werden alle ${settings.reminderRepeatIntervalMinutes} Minuten gesendet.`
: `\n\n${remainingReminders} more reminders will be sent every ${settings.reminderRepeatIntervalMinutes} minutes.`;
}
}
const message =
remindersToSend
.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}`;
if (i.pillWeightMg) {
const totalMg = i.usage * i.pillWeightMg;
@@ -450,21 +573,44 @@ async function checkAndSendIntakeRemindersForUser(
const existing = state.reminders[key];
if (existing) {
// Update existing entry (repeat)
// Update existing entry
if (intake.isAdvanceReminder) {
// Advance reminder - don't increment nagging count
state.reminders[key] = {
...existing,
lastSentAt: nowMs,
advanceSent: true,
};
} else {
// Nagging reminder - increment count
state.reminders[key] = {
firstSentAt: existing.firstSentAt,
lastSentAt: nowMs,
sendCount: existing.sendCount + 1,
advanceSent: existing.advanceSent,
};
}
} else {
// Create new entry
if (intake.isAdvanceReminder) {
// Advance reminder - sendCount stays 0
state.reminders[key] = {
firstSentAt: nowMs,
lastSentAt: nowMs,
sendCount: 0,
advanceSent: true,
};
} else {
// Create new entry (first send)
// First nagging reminder - sendCount starts at 1
state.reminders[key] = {
firstSentAt: nowMs,
lastSentAt: nowMs,
sendCount: 1,
advanceSent: false,
};
}
}
}
// Clean up old entries (remove doses from past days)
state.reminders = cleanOldIntakeReminders(state.reminders, tz);
@@ -476,13 +622,20 @@ async function checkAndSendIntakeRemindersForUser(
updateReminderSentTime("intake", channel);
// Also update user settings in database so frontend can display the info
await updateUserReminderSentTime(settings.userId, "intake", channel);
// Get the first reminder's medication name and taken by for display
const firstReminder = remindersToSend[0];
const medName = firstReminder?.medName;
const takenBy = firstReminder?.takenBy?.length > 0 ? firstReminder.takenBy.join(", ") : undefined;
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
}
}
let intakeCheckInterval: NodeJS.Timeout | null = null;
export function startIntakeReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
export function startIntakeReminderScheduler(logger: {
info: (msg: string) => void;
error: (msg: string) => void;
}): void {
logger.info(`[IntakeReminder] Starting intake reminder scheduler (checks every minute)...`);
// Run immediately on start
+64 -29
View File
@@ -1,26 +1,25 @@
import nodemailer from "nodemailer";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { medications, userSettings } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import { getTranslations, t, type Language } from "../i18n/translations.js";
import { getTranslations, type Language, t } from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
// Import shared utilities
import {
getTimezone,
type Blister,
calculateDepletionInfo,
createDefaultReminderState,
formatInTimezone,
getCurrentHourInTimezone,
getTodayInTimezone,
getNextScheduledTime,
getMsUntilNextCheck,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
parseBlisters,
calculateDailyUsage,
calculateDepletionInfo,
parseReminderState,
createDefaultReminderState,
type Blister,
type ReminderState,
} from "../utils/scheduler-utils.js";
@@ -47,7 +46,10 @@ export function getReminderState(): ReminderState {
return loadReminderState();
}
export function updateReminderSentTime(type: "stock" | "intake" = "stock", channel: "email" | "push" | "both" = "email"): void {
export function updateReminderSentTime(
type: "stock" | "intake" = "stock",
channel: "email" | "push" | "both" = "email"
): void {
const state = loadReminderState();
const today = getTodayInTimezone();
saveReminderState({
@@ -63,14 +65,19 @@ export function updateReminderSentTime(type: "stock" | "intake" = "stock", chann
export async function updateUserReminderSentTime(
userId: number,
type: "stock" | "intake" = "stock",
channel: "email" | "push" | "both" = "email"
channel: "email" | "push" | "both" = "email",
medName?: string,
takenBy?: string
): Promise<void> {
const now = new Date().toISOString();
await db.update(userSettings)
await db
.update(userSettings)
.set({
lastAutoEmailSent: now,
lastNotificationType: type,
lastNotificationChannel: channel,
lastReminderMedName: medName ?? null,
lastReminderTakenBy: takenBy ?? null,
})
.where(eq(userSettings.userId, userId));
}
@@ -86,14 +93,19 @@ type LowStockItem = {
depletionDate: string | null;
};
async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
async function getMedicationsNeedingReminder(
userId: number,
reminderDaysBefore: number,
language: Language
): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const lowStock: LowStockItem[] = [];
for (const row of rows) {
const blisters = parseBlistersFromRow(row);
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
const totalPills =
row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
// Check if medication runs out within reminderDaysBefore days
@@ -110,11 +122,16 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
return lowStock;
}
async function sendReminderEmail(email: string, lowStock: LowStockItem[], language: Language, isRepeatDaily: boolean = false): Promise<{ success: boolean; error?: string }> {
async function sendReminderEmail(
email: string,
lowStock: LowStockItem[],
language: Language,
isRepeatDaily: boolean = false
): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
@@ -136,7 +153,8 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[], langua
)
.join("");
const alertText = lowStock.length === 1
const alertText =
lowStock.length === 1
? tr.stockReminder.alertSingle
: t(tr.stockReminder.alertMultiple, { count: lowStock.length });
@@ -186,7 +204,7 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft
---
${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
const subjectPlural = lowStock.length === 1 ? "" : (language === "de" ? "e" : "s");
const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s";
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
try {
@@ -215,7 +233,10 @@ ${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyN
}
}
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
async function checkAndSendReminder(logger: {
info: (msg: string) => void;
error: (msg: string) => void;
}): Promise<void> {
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
@@ -268,7 +289,12 @@ async function checkAndSendReminderForUser(
// Send email if enabled
if (emailEnabled) {
const result = await sendReminderEmail(settings.notificationEmail!, allLowStock, language, settings.repeatDailyReminders);
const result = await sendReminderEmail(
settings.notificationEmail!,
allLowStock,
language,
settings.repeatDailyReminders
);
emailSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`);
@@ -280,8 +306,8 @@ async function checkAndSendReminderForUser(
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
// Separate empty from low stock medications
const emptyMeds = allLowStock.filter(m => m.medsLeft <= 0);
const lowMeds = allLowStock.filter(m => m.medsLeft > 0);
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
const lowMeds = allLowStock.filter((m) => m.medsLeft > 0);
// Build clear title
const titleParts: string[] = [];
@@ -297,12 +323,16 @@ async function checkAndSendReminderForUser(
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`);
emptyMeds.forEach(m => messageParts.push(`${m.name}`));
emptyMeds.forEach((m) => messageParts.push(`${m.name}`));
}
if (lowMeds.length > 0) {
if (emptyMeds.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`);
lowMeds.forEach(m => messageParts.push(`${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`));
lowMeds.forEach((m) =>
messageParts.push(
`${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
)
);
}
if (settings.repeatDailyReminders) {
@@ -335,7 +365,10 @@ async function checkAndSendReminderForUser(
});
// Also update user settings in database so frontend can display the info
await updateUserReminderSentTime(settings.userId, "stock", channel);
// For stock reminders, show the first medication name
const firstMed = allLowStock[0];
const medNames = allLowStock.length > 1 ? `${firstMed.name} (+${allLowStock.length - 1})` : firstMed?.name;
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
}
}
@@ -352,7 +385,9 @@ function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: s
nextScheduledCheck: nextTime.toISOString(),
});
logger.info(`[Reminder] Next check scheduled for ${formatInTimezone(nextTime)} (${getTimezone()}) (in ${Math.round(msUntilNext / 1000 / 60)} minutes)`);
logger.info(
`[Reminder] Next check scheduled for ${formatInTimezone(nextTime)} (${getTimezone()}) (in ${Math.round(msUntilNext / 1000 / 60)} minutes)`
);
schedulerTimeout = setTimeout(() => {
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
+5 -5
View File
@@ -1,13 +1,13 @@
/**
* E2E Tests for auth routes with AUTH_ENABLED=true
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import Fastify, { FastifyInstance } from "fastify";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -327,7 +327,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
describe("POST /auth/refresh", () => {
it("should refresh access token with valid refresh token", async () => {
// Login first to get tokens
const loginResponse = await app.inject({
const _loginResponse = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
+24 -38
View File
@@ -1,28 +1,24 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { mkdirSync, rmSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { tmpdir } from "os";
import { fileURLToPath } from "url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Import the exported utility functions from client.ts
import {
buildDbUrl,
getDbPaths,
ensureDataDirectory,
runDrizzleMigrations,
runAlterMigrations,
ensureDefaultUser,
getDbPaths,
runAlterMigrations,
runDrizzleMigrations,
} from "../db/client.js";
// Import the exported utility functions from migrate.ts
import {
splitSQLStatements,
executeMigration,
getStatementPreview,
} from "../db/migrate.js";
import { executeMigration, getStatementPreview, splitSQLStatements } from "../db/migrate.js";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
@@ -51,7 +47,7 @@ describe("Migration Script Utilities", () => {
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
const tableNames = tables.rows.map((r) => r.name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
@@ -117,7 +113,7 @@ describe("Migration Script Utilities", () => {
it("should use default maxLength of 50", () => {
const longStmt = "A".repeat(100);
const preview = getStatementPreview(longStmt);
expect(preview).toBe("A".repeat(50) + "...");
expect(preview).toBe(`${"A".repeat(50)}...`);
});
it("should trim whitespace", () => {
@@ -228,7 +224,7 @@ describe("Database Client Utilities", () => {
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
const tableNames = tables.rows.map((r) => r.name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
@@ -348,7 +344,7 @@ describe("Database Client", () => {
it("should have users table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(users)");
const columnNames = columns.rows.map(r => r.name);
const columnNames = columns.rows.map((r) => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("username");
@@ -358,7 +354,7 @@ describe("Database Client", () => {
it("should have medications table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(medications)");
const columnNames = columns.rows.map(r => r.name);
const columnNames = columns.rows.map((r) => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
@@ -370,7 +366,7 @@ describe("Database Client", () => {
it("should have user_settings table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(user_settings)");
const columnNames = columns.rows.map(r => r.name);
const columnNames = columns.rows.map((r) => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
@@ -381,7 +377,7 @@ describe("Database Client", () => {
it("should have refresh_tokens table", async () => {
const columns = await client.execute("PRAGMA table_info(refresh_tokens)");
const columnNames = columns.rows.map(r => r.name);
const columnNames = columns.rows.map((r) => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
@@ -390,7 +386,7 @@ describe("Database Client", () => {
it("should have share_tokens table", async () => {
const columns = await client.execute("PRAGMA table_info(share_tokens)");
const columnNames = columns.rows.map(r => r.name);
const columnNames = columns.rows.map((r) => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("token");
@@ -399,7 +395,7 @@ describe("Database Client", () => {
it("should have dose_tracking table", async () => {
const columns = await client.execute("PRAGMA table_info(dose_tracking)");
const columnNames = columns.rows.map(r => r.name);
const columnNames = columns.rows.map((r) => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("dose_id");
@@ -408,7 +404,7 @@ describe("Database Client", () => {
it("should have refill_history table", async () => {
const columns = await client.execute("PRAGMA table_info(refill_history)");
const columnNames = columns.rows.map(r => r.name);
const columnNames = columns.rows.map((r) => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("medication_id");
@@ -571,9 +567,7 @@ describe("Database Client", () => {
it("should enforce unique constraint on username", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await expect(
client.execute("INSERT INTO users (username) VALUES ('testuser')")
).rejects.toThrow();
await expect(client.execute("INSERT INTO users (username) VALUES ('testuser')")).rejects.toThrow();
});
it("should enforce unique constraint on refresh token_id", async () => {
@@ -583,9 +577,7 @@ describe("Database Client", () => {
);
await expect(
client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
)
client.execute("INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)")
).rejects.toThrow();
});
});
@@ -604,9 +596,7 @@ describe("Database Client", () => {
const result = await client.execute("SELECT id FROM users WHERE id = 1");
if (result.rows.length === 0) {
await client.execute(
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
);
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
}
const user = await client.execute("SELECT * FROM users WHERE id = 1");
@@ -616,17 +606,13 @@ describe("Database Client", () => {
});
it("should not duplicate default user if already exists", async () => {
await client.execute(
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
);
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
// Check if exists before insert (mimics runtime behavior)
const result = await client.execute("SELECT id FROM users WHERE id = 1");
if (result.rows.length === 0) {
await client.execute(
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
);
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
}
// Should still have only one user
+53 -11
View File
@@ -2,15 +2,8 @@
* Tests for /doses/taken API endpoints.
* Tests marking doses as taken, listing taken doses, and unmarking.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { buildTestApp, clearTestData, closeTestApp, createTestUser, type TestContext } from "./setup.js";
// =============================================================================
// Route Registration
@@ -22,7 +15,7 @@ async function registerDoseRoutes(ctx: TestContext) {
const { app, client } = ctx;
// GET /doses/taken - List all taken doses
app.get("/doses/taken", async (request, reply) => {
app.get("/doses/taken", async (_request, _reply) => {
// In test mode, use user ID 1 (will be created in tests)
const userId = 1;
@@ -69,14 +62,26 @@ async function registerDoseRoutes(ctx: TestContext) {
});
// DELETE /doses/taken/:doseId - Unmark a dose
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, reply) => {
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, _reply) => {
const userId = 1;
const { doseId } = request.params;
// Check if this dose was also dismissed
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0 && existing.rows[0].dismissed) {
// Already dismissed - keep the record as-is (don't delete)
// The dose stays dismissed, we just ignore the undo request
} else {
// Not dismissed - delete the record entirely
await client.execute({
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
}
return { success: true };
});
@@ -346,6 +351,43 @@ describe("Dose Tracking API", () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should preserve dismissed status when unmarking a dose", async () => {
const doseId = "1-0-1735344000000";
// First dismiss the dose
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Verify it's dismissed
let result = await ctx.client.execute({
sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].dismissed).toBe(1);
const originalTakenAt = result.rows[0].taken_at;
// Now try to unmark it (undo) - should keep the dismissed record
const response = await ctx.app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify the record still exists and is still dismissed
result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dismissed).toBe(1);
expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged
});
});
// ---------------------------------------------------------------------------
+18 -27
View File
@@ -2,14 +2,14 @@
* E2E Tests using the real routes against in-memory SQLite.
* These tests import the actual route handlers for real coverage.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import Fastify, { FastifyInstance } from "fastify";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import fastifyMultipart from "@fastify/multipart";
import { createClient, Client } from "@libsql/client";
import { drizzle, LibSQLDatabase } from "drizzle-orm/libsql";
import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -85,6 +85,8 @@ async function createSchema(client: Client) {
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
@@ -93,6 +95,7 @@ async function createSchema(client: Client) {
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -122,6 +125,8 @@ async function createSchema(client: Client) {
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -171,7 +176,7 @@ async function clearData(client: Client) {
await client.execute("DELETE FROM sqlite_sequence");
}
async function createUser(client: Client, username: string): Promise<number> {
async function _createUser(client: Client, username: string): Promise<number> {
const result = await client.execute({
sql: `INSERT INTO users (username, auth_provider) VALUES (?, 'local') RETURNING id`,
args: [username],
@@ -179,12 +184,7 @@ async function createUser(client: Client, username: string): Promise<number> {
return result.rows[0].id as number;
}
async function createMedication(
client: Client,
userId: number,
name: string,
takenBy: string[]
): Promise<number> {
async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise<number> {
const result = await client.execute({
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`,
@@ -193,12 +193,7 @@ async function createMedication(
return result.rows[0].id as number;
}
async function createShareToken(
client: Client,
userId: number,
takenBy: string,
token: string
): Promise<void> {
async function createShareToken(client: Client, userId: number, takenBy: string, token: string): Promise<void> {
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)`,
args: [userId, token, takenBy],
@@ -424,9 +419,7 @@ describe("E2E Tests with Real Routes", () => {
expiryDate: "2026-12-31",
notes: "Take with food",
intakeRemindersEnabled: true,
blisters: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
it("should create medication using real route", async () => {
@@ -916,7 +909,7 @@ describe("E2E Tests with Real Routes", () => {
// ---------------------------------------------------------------------------
describe("Real /medications routes - edge cases", () => {
const validMedication = {
const _validMedication = {
name: "Aspirin",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
@@ -1831,12 +1824,10 @@ describe("E2E Tests with Real Routes", () => {
looseTablets: 7,
},
pillWeightMg: 250,
schedules: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z", remind: true }
],
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z", remind: true }],
notes: "Imported notes",
intakeRemindersEnabled: true,
}
},
],
};
@@ -1899,7 +1890,7 @@ describe("E2E Tests with Real Routes", () => {
name: "Imported Med",
inventory: { packCount: 10, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
}
},
],
};
+29 -8
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
// Mock process.exit to prevent tests from exiting
@@ -8,23 +8,44 @@ vi.spyOn(process, "exit").mockImplementation(mockExit as any);
// Re-create the schema from env.ts for testing
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
PORT: z
.string()
.transform((v) => parseInt(v, 10))
.default("3000"),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"),
AUTH_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
REGISTRATION_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
JWT_SECRET: z.string().min(10).optional(),
REFRESH_SECRET: z.string().min(10).optional(),
COOKIE_SECRET: z.string().min(10).optional(),
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"),
REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"),
OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"),
ACCESS_TOKEN_TTL_MINUTES: z
.string()
.transform((v) => parseInt(v, 10))
.default("15"),
REFRESH_TOKEN_TTL_DAYS: z
.string()
.transform((v) => parseInt(v, 10))
.default("7"),
OIDC_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
OIDC_ISSUER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_REDIRECT_URI: z.string().url().optional(),
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"),
OIDC_AUTO_CREATE_USERS: z
.string()
.transform((v) => v === "true")
.default("true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
OIDC_PROVIDER_NAME: z.string().default("SSO"),
});
+13 -21
View File
@@ -2,16 +2,17 @@
* 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 { randomBytes } from "node:crypto";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
closeTestApp,
createTestMedication,
TestContext,
createTestUser,
type TestContext,
} from "./setup.js";
import { randomBytes } from "crypto";
// =============================================================================
// Route Registration (simplified test routes)
@@ -36,7 +37,7 @@ async function registerExportRoutes(ctx: TestContext) {
}
// GET /export
app.get<{ Querystring: { includeSensitive?: string } }>("/export", async (request, reply) => {
app.get<{ Querystring: { includeSensitive?: string } }>("/export", async (request, _reply) => {
const includeSensitive = request.query.includeSensitive === "true";
// Load medications
@@ -86,7 +87,7 @@ async function registerExportRoutes(ctx: TestContext) {
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(),
takenAt: d.taken_at ? new Date((d.taken_at as number) * 1000).toISOString() : new Date().toISOString(),
markedBy: d.marked_by,
};
})
@@ -98,7 +99,7 @@ async function registerExportRoutes(ctx: TestContext) {
args: [userId],
});
let settings = undefined;
let settings;
if (settingsResult.rows.length > 0) {
const s = settingsResult.rows[0];
settings = {
@@ -133,7 +134,7 @@ async function registerExportRoutes(ctx: TestContext) {
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,
expiresAt: s.expires_at ? new Date((s.expires_at as number) * 1000).toISOString() : null,
regenerateToken: true,
}));
@@ -210,12 +211,7 @@ async function registerExportRoutes(ctx: TestContext) {
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,
],
args: [userId, doseId, Math.floor(new Date(dose.takenAt).getTime() / 1000), dose.markedBy || null],
});
}
@@ -518,9 +514,7 @@ describe("Export/Import API", () => {
looseTablets: 5,
},
pillWeightMg: 250,
schedules: [
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z", remind: true },
],
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z", remind: true }],
expiryDate: "2027-12-31",
notes: "Test notes",
intakeRemindersEnabled: true,
@@ -820,9 +814,7 @@ describe("Export/Import API", () => {
pillsPerBlister: 10,
looseTablets: 0,
},
schedules: [
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" },
],
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }],
},
],
doseHistory: [],
+176 -6
View File
@@ -2,14 +2,14 @@
* Integration Tests - Testing interactions between multiple routes/features
* These tests verify critical app behavior that spans multiple components.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import Fastify, { FastifyInstance } from "fastify";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import fastifyMultipart from "@fastify/multipart";
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -80,6 +80,8 @@ async function createSchema(client: Client) {
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
@@ -88,6 +90,7 @@ async function createSchema(client: Client) {
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -117,6 +120,8 @@ async function createSchema(client: Client) {
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -161,7 +166,7 @@ async function clearData(client: Client) {
describe("Integration Tests", () => {
let app: FastifyInstance;
const userId = 999999999;
const _userId = 999999999;
beforeAll(async () => {
await createSchema(testClient);
@@ -934,4 +939,169 @@ describe("Integration Tests", () => {
expect(data[0].enough).toBe(true); // 45 > 10
});
});
// ---------------------------------------------------------------------------
// Dismiss Until (Clear Missed Doses)
// ---------------------------------------------------------------------------
describe("Dismiss Until functionality", () => {
it("should set dismissedUntil for multiple medications", async () => {
// Create two medications
const med1Res = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Med 1",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const med1Id = med1Res.json().id;
const med2Res = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Med 2",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const med2Id = med2Res.json().id;
// Set dismissedUntil for both
const dismissRes = await app.inject({
method: "POST",
url: "/medications/dismiss-until",
payload: {
medicationIds: [med1Id, med2Id],
until: "2025-01-15",
},
});
expect(dismissRes.statusCode).toBe(200);
expect(dismissRes.json().success).toBe(true);
expect(dismissRes.json().updatedCount).toBe(2);
// Verify dismissedUntil is set via GET
const medsRes = await app.inject({
method: "GET",
url: "/medications",
});
const meds = medsRes.json();
const med1 = meds.find((m: any) => m.id === med1Id);
const med2 = meds.find((m: any) => m.id === med2Id);
expect(med1.dismissedUntil).toBe("2025-01-15");
expect(med2.dismissedUntil).toBe("2025-01-15");
});
it("should clear dismissedUntil for a medication", async () => {
// Create medication
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Med to Clear",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createRes.json().id;
// Set dismissedUntil
await app.inject({
method: "POST",
url: "/medications/dismiss-until",
payload: {
medicationIds: [medId],
until: "2025-01-20",
},
});
// Clear it
const clearRes = await app.inject({
method: "DELETE",
url: `/medications/${medId}/dismiss-until`,
});
expect(clearRes.statusCode).toBe(200);
expect(clearRes.json().success).toBe(true);
// Verify it's cleared
const medsRes = await app.inject({
method: "GET",
url: "/medications",
});
const med = medsRes.json().find((m: any) => m.id === medId);
expect(med.dismissedUntil).toBeNull();
});
it("should reject invalid date format", async () => {
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createRes.json().id;
const res = await app.inject({
method: "POST",
url: "/medications/dismiss-until",
payload: {
medicationIds: [medId],
until: "01-15-2025", // Wrong format
},
});
expect(res.statusCode).toBe(400);
});
it("should reject empty medicationIds array", async () => {
const res = await app.inject({
method: "POST",
url: "/medications/dismiss-until",
payload: {
medicationIds: [],
until: "2025-01-15",
},
});
expect(res.statusCode).toBe(400);
});
it("should not update medications belonging to other users", async () => {
// Create medication for user 999999999
const createRes = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "My Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createRes.json().id;
// Try to dismiss a medication that doesn't exist (ID 99999)
const dismissRes = await app.inject({
method: "POST",
url: "/medications/dismiss-until",
payload: {
medicationIds: [99999],
until: "2025-01-15",
},
});
expect(dismissRes.statusCode).toBe(200);
expect(dismissRes.json().updatedCount).toBe(0); // Nothing updated
// Our med should still have no dismissedUntil
const medsRes = await app.inject({
method: "GET",
url: "/medications",
});
const med = medsRes.json().find((m: any) => m.id === medId);
expect(med.dismissedUntil).toBeNull();
});
});
});
+6 -7
View File
@@ -2,14 +2,14 @@
* Tests for /medications API endpoints.
* Tests CRUD operations for medications.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
closeTestApp,
createTestMedication,
TestContext,
createTestUser,
type TestContext,
} from "./setup.js";
// =============================================================================
@@ -20,7 +20,7 @@ async function registerMedicationRoutes(ctx: TestContext) {
const { app, client } = ctx;
// GET /medications - List all medications
app.get("/medications", async (request, reply) => {
app.get("/medications", async (_request, _reply) => {
const userId = 1;
const result = await client.execute({
@@ -664,8 +664,7 @@ describe("Medications API", () => {
const [med] = response.json();
// Total = (2 packs × 3 blisters × 10 pills) + 5 loose = 65
const totalPills =
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
expect(totalPills).toBe(65);
});
});
+23 -31
View File
@@ -1,10 +1,16 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import Fastify, { FastifyInstance } from "fastify";
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// Create test database and mocks before anything else (hoisted)
const { testClient, testDb, mockSendMail, mockSendShoutrrr, mockUpdateReminderSentTime, mockUpdateUserReminderSentTime } = vi.hoisted(() => {
const {
testClient,
testDb,
mockSendMail,
mockSendShoutrrr,
mockUpdateReminderSentTime,
mockUpdateUserReminderSentTime,
} = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
@@ -57,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({
// Mock sendShoutrrrNotification from settings
vi.mock("../routes/settings.js", async (importOriginal) => {
const original = await importOriginal() as any;
const original = (await importOriginal()) as any;
return {
...original,
sendShoutrrrNotification: mockSendShoutrrr,
@@ -107,6 +113,8 @@ async function createSchema(client: Client) {
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -430,9 +438,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
],
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
@@ -458,9 +464,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
],
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
@@ -561,9 +565,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
],
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
@@ -588,9 +590,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
],
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
@@ -617,9 +617,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
],
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
@@ -646,9 +644,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
],
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
@@ -669,9 +665,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null },
],
lowStock: [{ name: "Aspirin", medsLeft: 0, daysLeft: 0, depletionDate: null }],
},
});
@@ -679,7 +673,7 @@ describe("Planner Routes", () => {
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
// Check German translations are used
const [title, message] = mockSendShoutrrr.mock.calls[0].slice(1);
const [title, _message] = mockSendShoutrrr.mock.calls[0].slice(1);
expect(title).toContain("Leer");
});
@@ -696,9 +690,7 @@ describe("Planner Routes", () => {
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" },
],
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
+7 -5
View File
@@ -2,14 +2,14 @@
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
* Tests adding refills to medication stock and retrieving refill history.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
closeTestApp,
createTestMedication,
TestContext,
createTestUser,
type TestContext,
} from "./setup.js";
// Store userId at module level so routes can access it
@@ -35,7 +35,9 @@ async function registerRefillRoutes(ctx: TestContext) {
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
}
if (packsAdded === 0 && loosePillsAdded === 0) {
return reply.status(400).send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
return reply
.status(400)
.send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
}
// Check medication exists and belongs to user
+28 -18
View File
@@ -1,20 +1,20 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import Fastify from "fastify";
import { existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
import cookie from "@fastify/cookie";
import { mkdirSync, rmSync, existsSync } from "fs";
import { resolve } from "path";
import { tmpdir } from "os";
import Fastify from "fastify";
import { afterEach, describe, expect, it } from "vitest";
// Import from utils to avoid index.ts import side effects (server start)
import {
parseCorsOrigins,
buildAppConfig,
buildBaseCookieOptions,
buildRefreshCookieOptions,
buildAppConfig,
ensureImagesDirectory,
getJwtConfig,
parseCorsOrigins,
} from "../utils/server-config.js";
describe("Index.ts Utility Functions", () => {
@@ -247,7 +247,7 @@ describe("Server Bootstrap", () => {
await app.register(cookie, { secret: "test-cookie-secret" });
// Add a test route that sets a cookie
app.get("/set-cookie", async (request, reply) => {
app.get("/set-cookie", async (_request, reply) => {
reply.setCookie("test", "value", { path: "/" });
return { ok: true };
});
@@ -317,7 +317,10 @@ describe("Server Bootstrap", () => {
describe("CORS Origins Parsing", () => {
it("should parse comma-separated origins", () => {
const originsEnv = "http://localhost:5173,http://localhost:4173";
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
const origins = originsEnv
.split(",")
.map((o) => o.trim())
.filter(Boolean);
expect(origins).toHaveLength(2);
expect(origins[0]).toBe("http://localhost:5173");
@@ -326,7 +329,10 @@ describe("Server Bootstrap", () => {
it("should handle single origin", () => {
const originsEnv = "https://myapp.example.com";
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
const origins = originsEnv
.split(",")
.map((o) => o.trim())
.filter(Boolean);
expect(origins).toHaveLength(1);
expect(origins[0]).toBe("https://myapp.example.com");
@@ -334,14 +340,20 @@ describe("Server Bootstrap", () => {
it("should filter out empty strings", () => {
const originsEnv = "http://localhost:5173,,http://localhost:4173,";
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
const origins = originsEnv
.split(",")
.map((o) => o.trim())
.filter(Boolean);
expect(origins).toHaveLength(2);
});
it("should trim whitespace", () => {
const originsEnv = " http://localhost:5173 , http://localhost:4173 ";
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
const origins = originsEnv
.split(",")
.map((o) => o.trim())
.filter(Boolean);
expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]);
});
@@ -398,9 +410,7 @@ describe("Server Bootstrap", () => {
const app = Fastify({ logger: false });
// Try to listen on an invalid port
await expect(
app.listen({ port: -1, host: "127.0.0.1" })
).rejects.toThrow();
await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow();
await app.close();
});
@@ -453,11 +463,11 @@ describe("Cookie Options", () => {
describe("Rate Limiting", () => {
it("should configure rate limit settings", () => {
const rateLimitConfig = {
max: 100,
max: 300,
timeWindow: "1 minute",
};
expect(rateLimitConfig.max).toBe(100);
expect(rateLimitConfig.max).toBe(300);
expect(rateLimitConfig.timeWindow).toBe("1 minute");
});
});
+63 -53
View File
@@ -1,31 +1,25 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
import { resolve } from "path";
import { tmpdir } from "os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Import actual utility functions from scheduler-utils
import {
getTimezone,
formatInTimezone,
getCurrentHourInTimezone,
getTodayInTimezone,
getNextScheduledTime,
getMsUntilNextCheck,
parseBlisters,
parseTakenByJson,
type Blister,
calculateDailyUsage,
calculateDepletionInfo,
getUpcomingIntakes,
getTodaysIntakes,
createDefaultReminderState,
createDefaultIntakeReminderState,
parseReminderState,
parseIntakeReminderState,
cleanOldIntakeReminders,
type Blister,
type ReminderState,
type IntakeReminderState,
type UpcomingIntake,
createDefaultIntakeReminderState,
createDefaultReminderState,
formatInTimezone,
getCurrentHourInTimezone,
getMsUntilNextCheck,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
getTodaysIntakes,
getUpcomingIntakes,
parseBlisters,
parseIntakeReminderState,
parseReminderState,
parseTakenByJson,
} from "../utils/scheduler-utils.js";
describe("Scheduler Utils - Timezone Functions", () => {
@@ -267,7 +261,7 @@ describe("Scheduler Utils - Daily Usage Calculation", () => {
it("should calculate daily usage for weekly dose", () => {
const blisters: Blister[] = [{ usage: 1, every: 7, start: "2025-01-01T08:00" }];
expect(calculateDailyUsage(blisters)).toBeCloseTo(1/7, 5);
expect(calculateDailyUsage(blisters)).toBeCloseTo(1 / 7, 5);
});
it("should calculate daily usage for mixed schedules", () => {
@@ -338,18 +332,19 @@ describe("Scheduler Utils - Depletion Calculation", () => {
describe("Scheduler Utils - Upcoming Intakes", () => {
describe("getUpcomingIntakes", () => {
it("should return empty array when no intakes in window", () => {
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
// Set "now" to a time far from any scheduled intake
const now = new Date("2025-01-01T12:00:00.000Z").getTime();
// With parseLocalDateTime, times are treated as local - use same format for consistency
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00" }];
// Set "now" to a time far from any scheduled intake (12:00 local)
const now = new Date(2025, 0, 1, 12, 0, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
});
it("should find intake within reminder window", () => {
// Schedule intake at 08:00, check at 07:45 (15 minutes before)
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }];
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
// Schedule intake at 08:00 local, check at 07:45 local (15 minutes before)
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00" }];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 500, "en-US", "UTC", now);
@@ -361,20 +356,20 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
});
it("should skip blisters with zero interval", () => {
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00.000Z" }];
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00" }];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
});
it("should handle multiple blisters", () => {
// Two intakes at 08:00 and 08:01
// Two intakes at 08:00 and 08:01 local
const blisters: Blister[] = [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
{ usage: 2, every: 1, start: "2025-01-01T08:01:00.000Z" },
{ usage: 1, every: 1, start: "2025-01-01T08:00:00" },
{ usage: 2, every: 1, start: "2025-01-01T08:01:00" },
];
const now = new Date("2025-01-01T07:45:00.000Z").getTime();
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
@@ -386,13 +381,14 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
describe("getTodaysIntakes", () => {
it("should return all intakes for today", () => {
// Daily medication at 08:00 starting yesterday
// With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time
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)
// Get intakes for today (today's intake should be at 08:00 local)
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(1);
const intake = result.find(i => i.intakeTime.getUTCHours() === 8);
const intake = result.find((i) => i.intakeTime.getHours() === 8);
expect(intake).toBeDefined();
expect(intake?.medName).toBe("TestMed");
expect(intake?.usage).toBe(1);
@@ -403,11 +399,13 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 1, 0, 0);
const blisters: Blister[] = [{
const blisters: Blister[] = [
{
usage: 2,
every: 1,
start: todayMidnight.toISOString()
}];
start: todayMidnight.toISOString(),
},
];
const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC");
@@ -441,11 +439,13 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
const blisters: Blister[] = [{
const blisters: Blister[] = [
{
usage: 1,
every: 7,
start: lastWeek.toISOString()
}];
start: lastWeek.toISOString(),
},
];
// If today is not the same day of week, should return empty
const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC");
@@ -454,19 +454,25 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
expect(Array.isArray(result)).toBe(true);
});
it("should handle timezone correctly", () => {
// 23:00 in Europe/Berlin on a specific date
const blisters: Blister[] = [{
it("should handle local time correctly (ignore Z suffix)", () => {
// With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time
// The intakeTimeStr is then formatted for the target timezone (Europe/Berlin)
// So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time
const blisters: Blister[] = [
{
usage: 1,
every: 1,
start: "2025-01-01T22:00:00.000Z" // 23:00 Berlin time
}];
start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local 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:");
// The intakeTimeStr should be a valid time format (HH:MM)
// Exact value depends on server timezone vs target timezone offset
expect(result[0].intakeTimeStr).toMatch(/^\d{2}:\d{2}$/);
}
});
});
@@ -532,8 +538,8 @@ describe("Scheduler Utils - State Management", () => {
const json = JSON.stringify({
reminders: {
"med1:123": { firstSentAt: 1000, lastSentAt: 2000, sendCount: 2 },
"med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 }
}
"med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 },
},
});
const state = parseIntakeReminderState(json);
@@ -572,7 +578,11 @@ describe("Scheduler Utils - State Management", () => {
yesterday.setDate(yesterday.getDate() - 1);
const reminders = {
[`med1:${yesterday.getTime()}`]: { firstSentAt: yesterday.getTime(), lastSentAt: yesterday.getTime(), sendCount: 1 },
[`med1:${yesterday.getTime()}`]: {
firstSentAt: yesterday.getTime(),
lastSentAt: yesterday.getTime(),
sendCount: 1,
},
[`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 },
};
@@ -608,7 +618,7 @@ describe("Scheduler Utils - State Management", () => {
it("should handle malformed entries (invalid timestamp in key)", () => {
const reminders = {
"med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 },
"med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 }
"med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 },
};
const cleaned = cleanOldIntakeReminders(reminders, "Europe/Berlin");
// NaN from parseInt will cause these to be filtered out (invalid < todayStart)
+8 -5
View File
@@ -2,14 +2,14 @@
* Tests for /settings API endpoints.
* Tests user settings CRUD operations.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
closeTestApp,
createTestUser,
setUserSettings,
TestContext,
type TestContext,
} from "./setup.js";
// =============================================================================
@@ -20,7 +20,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
const { app, client } = ctx;
// GET /settings - Get user settings
app.get("/settings", async (request, reply) => {
app.get("/settings", async (_request, _reply) => {
const userId = 1;
const result = await client.execute({
@@ -123,7 +123,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) {
return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" });
}
if (body.reminderRepeatIntervalMinutes !== undefined && (body.reminderRepeatIntervalMinutes < 5 || body.reminderRepeatIntervalMinutes > 480)) {
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)) {
+13 -39
View File
@@ -2,17 +2,17 @@
* Test setup and utilities for MedAssist backend API tests.
* Uses in-memory SQLite for isolation between test files.
*/
import Fastify, { FastifyInstance } from "fastify";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import fastifyMultipart from "@fastify/multipart";
import { createClient, Client } from "@libsql/client";
import sensible from "@fastify/sensible";
import { type Client, createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { beforeAll, afterAll, beforeEach } from "vitest";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import Fastify, { type FastifyInstance } from "fastify";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
@@ -87,10 +87,7 @@ export interface CreateUserOptions {
/**
* Create a test user and return the ID
*/
export async function createTestUser(
client: Client,
options: CreateUserOptions = {}
): Promise<number> {
export async function createTestUser(client: Client, options: CreateUserOptions = {}): Promise<number> {
const { username = `user_${Date.now()}`, authProvider = "local" } = options;
const result = await client.execute({
@@ -121,10 +118,7 @@ export interface CreateMedicationOptions {
/**
* Create a test medication and return the ID
*/
export async function createTestMedication(
client: Client,
options: CreateMedicationOptions
): Promise<number> {
export async function createTestMedication(client: Client, options: CreateMedicationOptions): Promise<number> {
const {
userId,
name = "Test Medication",
@@ -186,17 +180,8 @@ export interface CreateShareTokenOptions {
/**
* Create a test share token and return the token string
*/
export async function createTestShareToken(
client: Client,
options: CreateShareTokenOptions
): Promise<string> {
const {
userId,
takenBy,
token = `test_token_${Date.now()}`,
scheduleDays = 30,
expiresAt = null,
} = options;
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
@@ -217,16 +202,8 @@ export interface CreateDoseTrackingOptions {
/**
* Create a dose tracking record
*/
export async function createTestDoseTracking(
client: Client,
options: CreateDoseTrackingOptions
): Promise<void> {
const {
userId,
doseId,
markedBy = null,
takenAt = Math.floor(Date.now() / 1000),
} = options;
export async function createTestDoseTracking(client: Client, options: CreateDoseTrackingOptions): Promise<void> {
const { userId, doseId, markedBy = null, takenAt = Math.floor(Date.now() / 1000) } = options;
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, taken_at)
@@ -244,10 +221,7 @@ export interface UpdateUserSettingsOptions {
/**
* Create or update user settings
*/
export async function setUserSettings(
client: Client,
options: UpdateUserSettingsOptions
): Promise<void> {
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options;
// Check if settings exist
+17 -25
View File
@@ -2,15 +2,15 @@
* Tests for share link API endpoints.
* Tests creating share tokens, accessing shared schedules, and marking doses via share links.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
closeTestApp,
createTestMedication,
createTestShareToken,
TestContext,
createTestUser,
type TestContext,
} from "./setup.js";
// =============================================================================
@@ -40,7 +40,7 @@ async function registerShareRoutes(ctx: TestContext) {
});
const hasMatchingMed = meds.rows.some((m) => {
const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]");
const takenByList: string[] = JSON.parse((m.taken_by_json as string) || "[]");
return takenByList.includes(takenBy);
});
@@ -103,13 +103,13 @@ async function registerShareRoutes(ctx: TestContext) {
const medications = medsResult.rows
.filter((m) => {
const takenByList: string[] = JSON.parse(m.taken_by_json as string || "[]");
const takenByList: string[] = JSON.parse((m.taken_by_json as string) || "[]");
return takenByList.includes(share.taken_by as string);
})
.map((m) => {
const usageArr: number[] = JSON.parse(m.usage_json as string || "[]");
const everyArr: number[] = JSON.parse(m.every_json as string || "[]");
const startArr: string[] = JSON.parse(m.start_json as string || "[]");
const usageArr: number[] = JSON.parse((m.usage_json as string) || "[]");
const everyArr: number[] = JSON.parse((m.every_json as string) || "[]");
const startArr: string[] = JSON.parse((m.start_json as string) || "[]");
return {
id: m.id,
@@ -118,15 +118,13 @@ async function registerShareRoutes(ctx: TestContext) {
pillWeightMg: m.pill_weight_mg,
imageUrl: m.image_url,
totalPills:
(m.pack_count as number) *
(m.blisters_per_pack as number) *
(m.pills_per_blister as number) +
(m.pack_count as number) * (m.blisters_per_pack as number) * (m.pills_per_blister as number) +
(m.loose_tablets as number),
packCount: m.pack_count,
blistersPerPack: m.blisters_per_pack,
looseTablets: m.loose_tablets,
pillsPerBlister: m.pills_per_blister,
takenBy: JSON.parse(m.taken_by_json as string || "[]"),
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
blisters: usageArr.map((usage, i) => ({
usage,
every: everyArr[i] || 1,
@@ -184,9 +182,7 @@ async function registerShareRoutes(ctx: TestContext) {
});
// POST /share/:token/doses - Mark dose via share link
app.post<{ Params: { token: string }; Body: { doseId: string } }>(
"/share/:token/doses",
async (request, reply) => {
app.post<{ Params: { token: string }; Body: { doseId: string } }>("/share/:token/doses", async (request, reply) => {
const { token } = request.params;
const { doseId } = request.body || {};
@@ -222,13 +218,10 @@ async function registerShareRoutes(ctx: TestContext) {
});
return { success: true };
}
);
});
// DELETE /share/:token/doses/:doseId - Unmark dose via share link
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/:doseId",
async (request, reply) => {
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
const { token, doseId } = request.params;
const shareResult = await client.execute({
@@ -248,11 +241,10 @@ async function registerShareRoutes(ctx: TestContext) {
});
return { success: true };
}
);
});
// GET /share/people - Get unique takenBy values
app.get("/share/people", async (request, reply) => {
app.get("/share/people", async (_request, _reply) => {
const userId = 1;
const result = await client.execute({
@@ -262,7 +254,7 @@ async function registerShareRoutes(ctx: TestContext) {
const peopleSet = new Set<string>();
for (const row of result.rows) {
const takenByList: string[] = JSON.parse(row.taken_by_json as string || "[]");
const takenByList: string[] = JSON.parse((row.taken_by_json as string) || "[]");
takenByList.forEach((p) => peopleSet.add(p));
}
+13 -26
View File
@@ -2,16 +2,16 @@
* Tests for stock calculation modes (automatic vs manual).
* Tests the /medications/usage endpoint with different settings.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
closeTestApp,
createTestDoseTracking,
createTestMedication,
createTestUser,
setUserSettings,
TestContext,
type TestContext,
} from "./setup.js";
// =============================================================================
@@ -22,9 +22,7 @@ async function registerUsageRoutes(ctx: TestContext) {
const { app, client } = ctx;
// POST /medications/usage - Calculate medication usage for a date range
app.post<{ Body: { startDate: string; endDate: string } }>(
"/medications/usage",
async (request, reply) => {
app.post<{ Body: { startDate: string; endDate: string } }>("/medications/usage", async (request, reply) => {
const userId = 1;
const { startDate, endDate } = request.body || {};
@@ -41,9 +39,7 @@ async function registerUsageRoutes(ctx: TestContext) {
args: [userId],
});
const stockMode =
settingsResult.rows.length > 0
? (settingsResult.rows[0].stock_calculation_mode as string)
: "automatic";
settingsResult.rows.length > 0 ? (settingsResult.rows[0].stock_calculation_mode as string) : "automatic";
// Get all medications
const medsResult = await client.execute({
@@ -55,9 +51,7 @@ async function registerUsageRoutes(ctx: TestContext) {
for (const med of medsResult.rows) {
const totalPills =
(med.pack_count as number) *
(med.blisters_per_pack as number) *
(med.pills_per_blister as number) +
(med.pack_count as number) * (med.blisters_per_pack as number) * (med.pills_per_blister as number) +
(med.loose_tablets as number);
const blisterSize = med.pills_per_blister as number;
@@ -77,7 +71,7 @@ async function registerUsageRoutes(ctx: TestContext) {
const scheduleStart = new Date(startArr[i] || start);
// Count doses from scheduleStart to end within the range
let current = new Date(scheduleStart);
const current = new Date(scheduleStart);
while (current <= end) {
if (current >= start) {
plannerUsage += usage;
@@ -92,11 +86,7 @@ async function registerUsageRoutes(ctx: TestContext) {
WHERE user_id = ?
AND taken_at >= ?
AND taken_at <= ?`,
args: [
userId,
Math.floor(start.getTime() / 1000),
Math.floor(end.getTime() / 1000),
],
args: [userId, Math.floor(start.getTime() / 1000), Math.floor(end.getTime() / 1000)],
});
// Filter to doses for this medication
@@ -133,11 +123,10 @@ async function registerUsageRoutes(ctx: TestContext) {
}
return results;
}
);
});
// GET /medications - List medications (for checking stock)
app.get("/medications", async (request, reply) => {
app.get("/medications", async (_request, _reply) => {
const userId = 1;
const result = await client.execute({
@@ -153,9 +142,7 @@ async function registerUsageRoutes(ctx: TestContext) {
pillsPerBlister: m.pills_per_blister,
looseTablets: m.loose_tablets,
totalPills:
(m.pack_count as number) *
(m.blisters_per_pack as number) *
(m.pills_per_blister as number) +
(m.pack_count as number) * (m.blisters_per_pack as number) * (m.pills_per_blister as number) +
(m.loose_tablets as number),
}));
});
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Tests for translations module
*/
import { describe, it, expect } from "vitest";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
import { describe, expect, it } from "vitest";
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
describe("Translations Module", () => {
describe("getTranslations", () => {
+51 -20
View File
@@ -24,7 +24,7 @@ export function formatInTimezone(date: Date, tz?: string): string {
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
minute: "2-digit",
});
}
@@ -34,7 +34,7 @@ export function getCurrentHourInTimezone(tz?: string): number {
const timeStr = now.toLocaleString("en-US", {
timeZone: tz ?? getTimezone(),
hour: "numeric",
hour12: false
hour12: false,
});
return parseInt(timeStr, 10);
}
@@ -59,11 +59,11 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
hour12: false,
});
const parts = formatter.formatToParts(now);
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "0";
const getPart = (type: string) => parts.find((p) => p.type === type)?.value || "0";
const currentHour = parseInt(getPart("hour"), 10);
const currentMinute = parseInt(getPart("minute"), 10);
@@ -84,7 +84,7 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit"
day: "2-digit",
});
const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);
@@ -96,7 +96,7 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
const checkFormatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "2-digit",
hour12: false
hour12: false,
});
// Adjust based on the difference
@@ -119,6 +119,34 @@ export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
// Blister/medication parsing utilities
// =============================================================================
/**
* Parse an ISO datetime string to local timestamp.
* Extracts date/time components directly from the string to avoid
* timezone conversion issues with Z suffix.
*
* "2026-01-23T20:55:00" treated as local time 20:55
* "2026-01-23T20:55:00.000Z" also treated as local time 20:55 (Z ignored)
*/
export function parseLocalDateTime(isoString: string): Date {
// Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds)
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
if (!match) {
// Fallback to Date parsing if format doesn't match
return new Date(isoString);
}
const [, year, month, day, hour, minute, second] = match;
// Create date using local time interpretation (no UTC conversion)
return new Date(
parseInt(year, 10),
parseInt(month, 10) - 1, // Month is 0-indexed
parseInt(day, 10),
parseInt(hour, 10),
parseInt(minute, 10),
parseInt(second ?? "0", 10)
);
}
/** Parse blister schedules from JSON columns */
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
try {
@@ -213,7 +241,7 @@ export function getTodaysIntakes(
const intakes: UpcomingIntake[] = [];
for (const blister of blisters) {
const startTime = new Date(blister.start).getTime();
const startTime = parseLocalDateTime(blister.start).getTime();
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
@@ -239,7 +267,7 @@ export function getTodaysIntakes(
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone
timeZone: timezone,
}),
takenBy,
pillWeightMg,
@@ -269,15 +297,14 @@ export function getUpcomingIntakes(
const now = nowOverride ?? Date.now();
const timezone = tz ?? getTimezone();
// Window to detect if "now" is the right time to send reminder
// We check if the notify time (intake - minutesBefore) falls within current minute ±1
const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks)
const windowEnd = now + 1 * 60 * 1000; // 1 minute from now
// Get the current minute (truncated to minute boundary for precise matching)
const currentMinuteStart = Math.floor(now / 60000) * 60000;
const currentMinuteEnd = currentMinuteStart + 60000;
const upcoming: UpcomingIntake[] = [];
for (const blister of blisters) {
const startTime = new Date(blister.start).getTime();
const startTime = parseLocalDateTime(blister.start).getTime();
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
@@ -295,10 +322,9 @@ export function getUpcomingIntakes(
// And the next occurrence
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
// If today's occurrence is within the reminder window, use it
// (intake hasn't happened yet, we should remind)
// If today's occurrence notification time falls in current minute and intake hasn't happened
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= windowStart && currentOccurrence > now) {
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
nextTime = currentOccurrence;
} else {
nextTime = nextOccurrence;
@@ -308,7 +334,8 @@ export function getUpcomingIntakes(
// Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000;
if (notifyTime >= windowStart && notifyTime <= windowEnd) {
// Check if notifyTime falls within the current minute (precise matching)
if (notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd) {
const intakeDate = new Date(nextTime);
upcoming.push({
medName,
@@ -317,7 +344,7 @@ export function getUpcomingIntakes(
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone
timeZone: timezone,
}),
takenBy,
pillWeightMg,
@@ -344,7 +371,8 @@ export type ReminderState = {
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
sendCount: number; // How many times NAGGING reminder was sent (not counting advance)
advanceSent?: boolean; // Whether the advance reminder (15 min before) was sent
};
export type IntakeReminderState = {
@@ -415,7 +443,10 @@ export function parseIntakeReminderState(json: string): IntakeReminderState {
/** Clean up old intake reminder entries (older than given milliseconds) */
/** Clean up old intake reminder entries (using timezone-aware day check) */
export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
export function cleanOldIntakeReminders(
reminders: Record<string, IntakeReminderEntry>,
tz: string
): Record<string, IntakeReminderEntry> {
// Get start of today in user's timezone
const now = new Date();
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
+6 -18
View File
@@ -3,8 +3,8 @@
* Exported separately to allow testing without triggering server start.
*/
import { existsSync, mkdirSync } from "fs";
import { resolve } from "path";
import { existsSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";
import type { CookieSerializeOptions } from "@fastify/cookie";
/**
@@ -20,10 +20,7 @@ export function parseCorsOrigins(originsStr: string): string[] {
/**
* Build base cookie options for access token
*/
export function buildBaseCookieOptions(
accessTtlMinutes: number,
isProduction: boolean
): CookieSerializeOptions {
export function buildBaseCookieOptions(accessTtlMinutes: number, isProduction: boolean): CookieSerializeOptions {
return {
httpOnly: true,
secure: isProduction,
@@ -67,14 +64,8 @@ export interface AppConfig {
}
export function buildAppConfig(options: AppConfigOptions): AppConfig {
const cookieOptions = buildBaseCookieOptions(
options.accessTtlMinutes,
options.isProduction
);
const refreshCookieOptions = buildRefreshCookieOptions(
cookieOptions,
options.refreshTtlDays
);
const cookieOptions = buildBaseCookieOptions(options.accessTtlMinutes, options.isProduction);
const refreshCookieOptions = buildRefreshCookieOptions(cookieOptions, options.refreshTtlDays);
return {
accessSecret: options.jwtSecret || "",
@@ -110,10 +101,7 @@ export interface JwtConfig {
}
export function getJwtConfig(authEnabled: boolean, jwtSecret?: string): JwtConfig {
const effectiveSecret =
authEnabled && jwtSecret
? jwtSecret
: "auth-disabled-no-secret-needed";
const effectiveSecret = authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed";
return {
secret: effectiveSecret,
+54
View File
@@ -0,0 +1,54 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"files": {
"includes": ["backend/src/**/*.ts", "frontend/src/**/*.ts", "frontend/src/**/*.tsx", "frontend/src/**/*.css"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off"
},
"suspicious": {
"noExplicitAny": "warn",
"useIterableCallbackReturn": "off",
"noImplicitAnyLet": "warn",
"noArrayIndexKey": "warn",
"noAssignInExpressions": "off"
},
"style": {
"noNonNullAssertion": "off",
"useConst": "error",
"noParameterAssign": "off"
},
"correctness": {
"noUnusedVariables": "warn",
"noUnusedImports": "warn",
"noUnusedFunctionParameters": "warn",
"useExhaustiveDependencies": "warn"
},
"a11y": {
"useKeyWithClickEvents": "warn",
"noSvgWithoutTitle": "off",
"noStaticElementInteractions": "off",
"useButtonType": "off",
"noLabelWithoutControl": "warn"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 120
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "es5"
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ services:
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
- ./backend/data:/app/data
- ./data:/app/data
env_file:
- .env
ports:
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+33
View File
@@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build outputs (rebuilt in Docker)
dist/
coverage/
# Development files
*.log
npm-debug.log*
# Test files
src/test/
*.test.ts
*.test.tsx
vitest.config.ts
# IDE
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose*.yml
+2 -2
View File
@@ -12,8 +12,8 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Allow larger file uploads (for medication images)
client_max_body_size 10M;
# Allow larger file uploads (for medication images and data import/export)
client_max_body_size 50M;
location / {
try_files $uri /index.html;
+1543 -3
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -1,13 +1,18 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.1.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo 'add lint config'"
"lint": "npx biome check .",
"lint:fix": "npx biome check --write .",
"format": "npx biome format --write .",
"check": "npx biome check . && tsc --noEmit",
"test": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"i18next": "^24.2.2",
@@ -19,11 +24,18 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^2.3.12",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.4.0",
"typescript": "^5.5.4",
"vite": "^7.3.0"
"vite": "^7.3.0",
"vitest": "^4.0.17"
}
}
+291 -4635
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
interface UpdateCheckResult {
status: "checking" | "up-to-date" | "update-available" | "error";
latestVersion?: string;
lastChecked?: string;
}
interface AboutModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
const { t } = useTranslation();
const [backendVersion, setBackendVersion] = useState<string | null>(null);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
// Fetch backend version and cached update result on mount
useEffect(() => {
if (!isOpen) return;
// Fetch backend version
fetch("/api/health")
.then((res) => res.json())
.then((data) => setBackendVersion(data.version || "unknown"))
.catch(() => setBackendVersion("unknown"));
// Load cached update check result
const cached = sessionStorage.getItem("updateCheckResult");
if (cached) {
try {
const parsed = JSON.parse(cached);
if (parsed && typeof parsed === "object") {
setUpdateCheckResult(parsed);
}
} catch {
// ignore
}
}
}, [isOpen]);
async function checkForUpdates() {
setUpdateCheckResult({ status: "checking" });
try {
const res = await fetch(`https://api.github.com/repos/DanielVolz/medassist-ng/releases/latest`);
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
const latestVersion = (data.tag_name || "").replace(/^v/, "");
const currentVersion = FRONTEND_VERSION.replace(/^v/, "");
const isUpToDate = latestVersion === currentVersion;
const result: UpdateCheckResult = {
status: isUpToDate ? "up-to-date" : "update-available",
latestVersion,
lastChecked: new Date().toISOString(),
};
setUpdateCheckResult(result);
// Cache the result
sessionStorage.setItem("updateCheckResult", JSON.stringify(result));
} catch {
setUpdateCheckResult({ status: "error" });
}
}
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="about-header">
<div className="about-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z" />
<path d="M12 8v4l2.5 2.5" />
<path d="M9 2h6M12 2v2" />
</svg>
</div>
<h2>{t("about.appName", "MedAssist")}</h2>
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
</div>
<div className="about-versions">
<div className="about-version-row">
<span className="about-version-label">{t("about.frontendVersion", "Frontend")}</span>
<span className="about-version-value">{FRONTEND_VERSION}</span>
</div>
<div className="about-version-row">
<span className="about-version-label">{t("about.backendVersion", "Backend")}</span>
<span className="about-version-value">{backendVersion || "..."}</span>
</div>
</div>
<div className="about-update-section">
<button
className="about-update-btn"
onClick={checkForUpdates}
disabled={updateCheckResult?.status === "checking"}
>
{updateCheckResult?.status === "checking" ? (
<>
<span className="spinner-small"></span>
{t("about.checking", "Checking...")}
</>
) : (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
{t("about.checkForUpdates", "Check for Updates")}
</>
)}
</button>
{updateCheckResult && updateCheckResult.status !== "checking" && (
<div className={`about-update-result ${updateCheckResult.status}`}>
{updateCheckResult.status === "up-to-date" && (
<span className="update-status-text"> {t("about.upToDate", "You are up to date!")}</span>
)}
{updateCheckResult.status === "update-available" && (
<span className="update-status-text">
{t("about.updateAvailable", "Update available")}:{" "}
<strong>v{updateCheckResult.latestVersion}</strong>
<a
href={`${GITHUB_URL}/releases/latest`}
target="_blank"
rel="noopener noreferrer"
className="update-download-link"
>
{t("about.downloadUpdate", "Download")}
</a>
</span>
)}
{updateCheckResult.status === "error" && (
<span className="update-status-text"> {t("about.checkFailed", "Could not check for updates")}</span>
)}
{updateCheckResult.lastChecked && (
<span className="update-last-checked">
{t("about.lastChecked", "Last checked")}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
</span>
)}
</div>
)}
</div>
<div className="about-links">
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="about-link">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{t("about.viewOnGitHub", "View on GitHub")}
</a>
</div>
<div className="about-footer">
<p className="about-copyright">
{t("about.copyright", "© {{year}} Daniel Volz", { year: new Date().getFullYear() })}
</p>
<p className="about-license">{t("about.license", "GPL-3.0 License")}</p>
</div>
</div>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
/**
* AppHeader - Main application header with navigation and user menu
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { useUnsavedChanges } from "../context";
import { useTheme } from "../hooks";
import { useAuth } from "./Auth";
interface AppHeaderProps {
onOpenProfile: () => void;
onOpenAbout: () => void;
}
export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const { user, authState, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { confirmNavigation } = useUnsavedChanges();
// Safe navigation that checks for unsaved changes first
const safeNavigate = async (path: string) => {
if (await confirmNavigation()) {
navigate(path);
}
};
// User dropdown state (for mobile click-based behavior)
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
// Close user dropdown when clicking outside
useEffect(() => {
if (!userDropdownOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".user-menu")) {
setUserDropdownOpen(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [userDropdownOpen]);
// Page titles based on current route
const pageInfo = {
"/dashboard": { eyebrow: t("header.eyebrow.overview"), title: t("nav.dashboard") },
"/medications": { eyebrow: t("header.eyebrow.inventory"), title: t("nav.medications") },
"/planner": { eyebrow: t("header.eyebrow.planner"), title: t("nav.planner") },
"/settings": { eyebrow: t("header.eyebrow.settings"), title: t("nav.settings") },
"/schedule": { eyebrow: t("header.eyebrow.schedule"), title: t("dashboard.schedules.title") },
}[currentPath] || { eyebrow: t("header.eyebrow.overview"), title: t("nav.dashboard") };
return (
<header className="hero">
<div className="hero-title">
<img src="/favicon.svg" alt="MedAssist-ng" className="hero-logo" />
<div>
<p className="eyebrow">{pageInfo.eyebrow}</p>
<h1>{pageInfo.title}</h1>
</div>
</div>
<div className="header-actions">
<div className="tabs">
<button
className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/dashboard")}
>
{t("nav.dashboard")}
</button>
<button
className={currentPath === "/medications" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/medications")}
>
{t("nav.medications")}
</button>
<button
className={currentPath === "/planner" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/planner")}
>
{t("nav.planner")}
</button>
</div>
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
{!authState?.authEnabled && (
<button
className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`}
onClick={() => safeNavigate("/settings")}
title={t("nav.settings")}
>
</button>
)}
<button
className="icon-btn"
onClick={toggleTheme}
title={theme === "dark" ? t("tooltips.lightMode") : t("tooltips.darkMode")}
>
{theme === "dark" ? "☀️" : "🌙"}
</button>
{authState?.authEnabled && user && (
<div className={`user-menu ${userDropdownOpen ? "open" : ""}`}>
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
) : (
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
)}
</button>
<div className="user-dropdown">
<div className="dropdown-header">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
) : (
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
)}
<span className="dropdown-username">{user.username}</span>
</div>
<div className="dropdown-menu">
<button
className="dropdown-item"
onClick={() => {
onOpenProfile();
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{t("auth.profile", "Profile")}
</button>
<button
className="dropdown-item"
onClick={() => {
safeNavigate("/settings");
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{t("nav.settings", "Settings")}
</button>
<button
className="dropdown-item"
onClick={() => {
onOpenAbout();
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
{t("about.title", "About")}
</button>
<button
className="dropdown-item danger"
onClick={() => {
logout();
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
{t("auth.signOut", "Sign Out")}
</button>
</div>
</div>
</div>
)}
</div>
</header>
);
}
+55 -40
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, createContext, useContext, ReactNode, useCallback, useRef } from "react";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
// =============================================================================
@@ -57,24 +57,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
// Fetch auth state on mount
// Track if initial fetch has been done to prevent duplicate calls
const initialFetchDone = useRef(false);
// Fetch auth state on mount (only once)
useEffect(() => {
if (initialFetchDone.current) return;
initialFetchDone.current = true;
fetchAuthState();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Proactively refresh token every 10 minutes to prevent expiration
useEffect(() => {
if (!user || !authState?.authEnabled) return;
const refreshInterval = setInterval(async () => {
const refreshInterval = setInterval(
async () => {
const success = await tryRefreshToken();
if (!success) {
// Refresh failed - check if user is still valid
await refreshUser();
}
}, 10 * 60 * 1000); // 10 minutes (before 15 min access token expires)
},
10 * 60 * 1000
); // 10 minutes (before 15 min access token expires)
return () => clearInterval(refreshInterval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authState?.authEnabled]);
async function fetchAuthState() {
@@ -235,7 +245,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
// Fetch wrapper that automatically refreshes token on 401
const authFetch = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const authFetch = useCallback(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const options: RequestInit = {
...init,
credentials: "include",
@@ -256,10 +267,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
return res;
}, []);
},
[tryRefreshToken]
);
return (
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, uploadAvatar, deleteAvatar, authFetch }}>
<AuthContext.Provider
value={{
user,
authState,
loading,
authError,
login,
register,
logout,
refreshUser,
updateProfile,
uploadAvatar,
deleteAvatar,
authFetch,
}}
>
{children}
</AuthContext.Provider>
);
@@ -268,7 +296,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// =============================================================================
// Login Form
// =============================================================================
export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () => void; onSwitchToRegister?: () => void }) {
export function LoginForm({
onSuccess,
onSwitchToRegister,
}: {
onSuccess?: () => void;
onSwitchToRegister?: () => void;
}) {
const { t } = useTranslation();
const { login, authState } = useAuth();
const [username, setUsername] = useState("");
@@ -304,12 +338,12 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () =>
<button
type="button"
className="btn btn-secondary auth-submit sso-btn"
onClick={() => window.location.href = "/api/auth/oidc/login"}
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"/>
<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>
@@ -335,7 +369,6 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () =>
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus={!authState?.oidcEnabled}
/>
</div>
@@ -353,11 +386,7 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () =>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
<input type="checkbox" checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />
<span>{t("auth.rememberMe", "Remember me")}</span>
</label>
</div>
@@ -417,9 +446,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">💊 MedAssist</h1>
<h2 className="auth-subtitle">
{t("auth.register", "Create Account")}
</h2>
<h2 className="auth-subtitle">{t("auth.register", "Create Account")}</h2>
{/* SSO Login Button - also show on registration */}
{authState?.oidcEnabled && (
@@ -427,12 +454,12 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
<button
type="button"
className="btn btn-secondary auth-submit sso-btn"
onClick={() => window.location.href = "/api/auth/oidc/login"}
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"/>
<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>
@@ -458,7 +485,6 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus
minLength={3}
maxLength={50}
pattern="[a-zA-Z0-9_-]+"
@@ -610,9 +636,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="profile-avatar-img" />
) : (
<div className="profile-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="profile-avatar">{user.username.charAt(0).toUpperCase()}</div>
)}
<input
type="file"
@@ -720,17 +744,8 @@ export function AuthPage() {
}, [authState?.needsSetup]);
if (mode === "register") {
return (
<RegisterForm
onSuccess={() => setMode("login")}
onSwitchToLogin={() => setMode("login")}
/>
);
return <RegisterForm onSuccess={() => setMode("login")} onSwitchToLogin={() => setMode("login")} />;
}
return (
<LoginForm
onSwitchToRegister={authState?.registrationEnabled ? () => setMode("register") : undefined}
/>
);
return <LoginForm onSwitchToRegister={authState?.registrationEnabled ? () => setMode("register") : undefined} />;
}
+47
View File
@@ -0,0 +1,47 @@
// =============================================================================
// ConfirmModal Component - Simple confirmation dialog
// =============================================================================
import type { ReactNode } from "react";
export interface ConfirmModalProps {
title: string;
message: string | ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
isLoading?: boolean;
confirmVariant?: "primary" | "danger" | "success";
}
export function ConfirmModal({
title,
message,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
isLoading = false,
confirmVariant = "primary",
}: ConfirmModalProps) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
<button className="modal-close" onClick={onCancel}>
×
</button>
<h2 style={{ marginBottom: "16px", paddingRight: "2rem" }}>{title}</h2>
<div style={{ marginBottom: "24px" }}>{typeof message === "string" ? <p>{message}</p> : message}</div>
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
<button type="button" className="ghost" onClick={onCancel} disabled={isLoading}>
{cancelLabel}
</button>
<button type="button" className={confirmVariant} onClick={onConfirm} disabled={isLoading}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useTranslation } from "react-i18next";
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
onExport: (includeImages: boolean) => void;
exporting: boolean;
}
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
<button className="modal-close" onClick={onClose}>
×
</button>
<h2 style={{ marginBottom: "16px", paddingRight: "2rem" }}>{t("exportImport.exportOptions")}</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<button
type="button"
className="action-card"
onClick={() => {
onClose();
onExport(true);
}}
disabled={exporting}
style={{ textAlign: "left", cursor: "pointer", border: "1px solid var(--border)", borderRadius: "8px" }}
>
<div className="action-card-content" style={{ flex: 1 }}>
<span className="action-card-title">{t("exportImport.exportWithImages")}</span>
<span className="action-card-desc">{t("exportImport.exportWithImagesDesc")}</span>
</div>
</button>
<button
type="button"
className="action-card"
onClick={() => {
onClose();
onExport(false);
}}
disabled={exporting}
style={{ textAlign: "left", cursor: "pointer", border: "1px solid var(--border)", borderRadius: "8px" }}
>
<div className="action-card-content" style={{ flex: 1 }}>
<span className="action-card-title">{t("exportImport.exportDataOnly")}</span>
<span className="action-card-desc">{t("exportImport.exportDataOnlyDesc")}</span>
</div>
</button>
</div>
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
<button type="button" className="ghost" onClick={onClose}>
{t("exportImport.cancelButton")}
</button>
</div>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
// =============================================================================
// Lightbox Component - Full-screen image viewer
// =============================================================================
import type { MouseEvent } from "react";
export interface LightboxProps {
src: string;
alt: string;
onClose: () => void;
}
export function Lightbox({ src, alt, onClose }: LightboxProps) {
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
return (
<div className="lightbox-overlay" onClick={handleOverlayClick}>
<button className="lightbox-close" onClick={onClose}>
×
</button>
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
</div>
);
}
+532
View File
@@ -0,0 +1,532 @@
/**
* MedDetailModal - Medication detail view with nested modals
* Displays medication information, stock, schedules, and provides refill/edit functionality
*
* Can work in two modes:
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
* 2. Props mode: Accepts all required data as props (for gradual adoption)
*/
import { useTranslation } from "react-i18next";
import { Lightbox, MedicationAvatar } from "../components";
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getStockStatus } from "../utils/schedule";
// =============================================================================
// Local Helper Functions
// =============================================================================
/**
* Calculate blister stock - divides current pills into full blisters and partial
*/
function getBlisterStock(
currentPills: number,
pillsPerBlister: number,
_originalLooseTablets: number,
_originalTotalPills: number
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
}
const fullBlisters = Math.floor(currentPills / pillsPerBlister);
const openBlisterPills = currentPills % pillsPerBlister;
return { fullBlisters, openBlisterPills, loosePills: 0 };
}
/**
* Format full blisters column
*/
function formatFullBlisters(fullBlisters: number, t: (key: string) => string): string {
if (fullBlisters === 0) return "—";
return `${fullBlisters} ${fullBlisters === 1 ? t("common.blister") : t("common.blisters")}`;
}
/**
* Format open blister column
*/
function formatOpenBlisterAndLoose(
openBlisterPills: number,
_loosePills: number,
pillsPerBlister: number,
t: (key: string) => string
): string {
if (openBlisterPills > 0) {
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
}
return "—";
}
// =============================================================================
// Props Interface
// =============================================================================
export interface MedDetailModalProps {
// Required
selectedMed: Medication | null;
coverage: { all: Coverage[] };
settings: StockThresholds;
// Modal state
showImageLightbox: boolean;
showRefillModal: boolean;
showEditStockModal: boolean;
// Modal actions
onClose: () => void;
onOpenImageLightbox: () => void;
onCloseImageLightbox: () => void;
onOpenRefillModal: () => void;
onCloseRefillModal: () => void;
onOpenEditStockModal: () => void;
onCloseEditStockModal: () => void;
// Refill state
refillPacks: number;
onRefillPacksChange: (value: number) => void;
refillLoose: number;
onRefillLooseChange: (value: number) => void;
refillSaving: boolean;
refillHistory: RefillEntry[];
refillHistoryExpanded: boolean;
onRefillHistoryExpandedChange: (value: boolean) => void;
onSubmitRefill: (medId: number) => Promise<void>;
// Edit stock state
editStockFullBlisters: number;
onEditStockFullBlistersChange: (value: number) => void;
editStockPartialBlisterPills: number;
onEditStockPartialBlisterPillsChange: (value: number) => void;
editStockSaving: boolean;
onSubmitStockCorrection: (medId: number) => Promise<void>;
}
export function MedDetailModal({
selectedMed,
coverage,
settings,
showImageLightbox,
showRefillModal,
showEditStockModal,
onClose,
onOpenImageLightbox,
onCloseImageLightbox,
onOpenRefillModal,
onCloseRefillModal,
onOpenEditStockModal,
onCloseEditStockModal,
refillPacks,
onRefillPacksChange,
refillLoose,
onRefillLooseChange,
refillSaving,
refillHistory,
refillHistoryExpanded,
onRefillHistoryExpandedChange,
onSubmitRefill,
editStockFullBlisters,
onEditStockFullBlistersChange,
editStockPartialBlisterPills,
onEditStockPartialBlisterPillsChange,
editStockSaving,
onSubmitStockCorrection,
}: MedDetailModalProps) {
const { t, i18n } = useTranslation();
if (!selectedMed) return null;
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
const packageSize = getPackageSize(selectedMed);
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const textClass =
status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content med-detail-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="med-detail-body">
{/* Header */}
<div className="med-detail-header">
<div
className={`med-detail-avatar-wrapper ${selectedMed.imageUrl ? "clickable" : ""}`}
onClick={() => selectedMed.imageUrl && onOpenImageLightbox()}
>
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
</div>
<div className="med-detail-titles">
<h2>{selectedMed.name}</h2>
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
<span className="med-taken-by">
{t("modal.for")} {selectedMed.takenBy.join(", ")}
</span>
)}
</div>
</div>
{/* Stock Info Section */}
<div className="med-detail-section">
<h3>{t("modal.stockInfo")}</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("table.fullBlisters")}</span>
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("table.openBlister")}</span>
<span className={`med-detail-value ${textClass}`}>
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
selectedMed.pillsPerBlister ?? 1,
t
)}
</span>
</div>
<div className="med-detail-item full-width">
<span className="med-detail-label">{t("modal.currentStock")}</span>
<span className={`med-detail-value ${textClass}`}>
{currentStock} / {packageSize}
</span>
</div>
</div>
</div>
{/* Package Details Section */}
<div className="med-detail-section">
<h3>{t("modal.packageDetails")}</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.packs")}</span>
<span className="med-detail-value">{selectedMed.packCount}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
{selectedMed.pillWeightMg && (
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillWeight")}</span>
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span>
</div>
)}
{selectedMed.expiryDate && (
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.expiryDate")}</span>
<span
className={`med-detail-value ${getExpiryClass(selectedMed.expiryDate, settings.expiryWarningDays)}`}
>
{new Date(selectedMed.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "short",
year: "numeric",
})}
</span>
</div>
)}
</div>
</div>
{/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && (
<div className="med-detail-section">
<h3>
{t("modal.intakeSchedule")}{" "}
{selectedMed.intakeRemindersEnabled && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
🔔
</span>
)}
</h3>
<div className="med-detail-schedules">
{selectedMed.blisters.map((blister, idx) => {
const personCount = Math.max(1, selectedMed.takenBy?.length || 1);
const totalUsage = blister.usage * personCount;
return (
<div key={idx} className="med-schedule-item">
<span className="med-schedule-usage">
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`}
</span>
<span className="med-schedule-freq">
{t("form.blisters.every")} {blister.every}{" "}
{blister.every !== 1 ? t("common.days") : t("common.day")}
</span>
<span className="med-schedule-time">
{t("modal.at")}{" "}
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Coverage Status Section */}
{medCoverage && status && (
<div className="med-detail-section">
<h3 className="section-header-with-badge">
{t("modal.coverageStatus")}
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
</h3>
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.daysLeft")}</span>
<span className="med-detail-value">
{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}
</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t("modal.runsOut")}</span>
<span className="med-detail-value">{medCoverage.depletionDate ?? "—"}</span>
</div>
</div>
</div>
)}
{/* Notes Section */}
{selectedMed.notes && (
<div className="med-detail-section">
<h3>📝 {t("modal.notes")}</h3>
<div className="med-notes-content">{selectedMed.notes}</div>
</div>
)}
{/* Refill History Section */}
{refillHistory.length > 0 && (
<div className="med-detail-section">
<h3
className="section-header-clickable"
onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}
>
{t("refill.history")} ({refillHistory.length})
<span className="expand-arrow">{refillHistoryExpanded ? "▼" : "▶"}</span>
</h3>
{refillHistoryExpanded && (
<div className="refill-history-list">
{refillHistory.map((entry) => (
<div key={entry.id} className="refill-history-item">
<span className="refill-date">
{new Date(entry.refillDate).toLocaleDateString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "short",
year: "numeric",
})}
,{" "}
{new Date(entry.refillDate).toLocaleTimeString(getSystemLocale(i18n.language), {
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span className="refill-amount">
+
{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded}{" "}
{t("common.pills")}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="med-detail-footer">
<button onClick={onClose}>{t("common.close")}</button>
<div className="footer-actions">
<button className="success" onClick={onOpenRefillModal}>
{t("refill.button")}
</button>
<button className="info" onClick={onOpenEditStockModal}>
{t("common.edit")}
</button>
{selectedMed.blisters.length > 0 && (
<button
className="secondary icon-only"
onClick={() => generateICS(selectedMed)}
title={t("modal.exportTooltip")}
>
📅
</button>
)}
</div>
</div>
</div>
{/* Image Lightbox */}
{showImageLightbox && selectedMed.imageUrl && (
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={selectedMed.name} onClose={onCloseImageLightbox} />
)}
{/* Refill Modal */}
{showRefillModal && (
<div
className="modal-overlay"
onClick={(e) => {
e.stopPropagation();
onCloseRefillModal();
}}
>
<div className="modal-content refill-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onCloseRefillModal}>
×
</button>
<h2>{t("refill.title")}</h2>
<p className="refill-med-name">{selectedMed.name}</p>
<div className="refill-form">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</div>
<div className="modal-footer">
<button className="ghost" onClick={onCloseRefillModal}>
{t("common.cancel")}
</button>
<div className="refill-footer-right">
<button
className="success"
onClick={() => onSubmitRefill(selectedMed.id)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose}{" "}
{t("common.pills")}
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* Edit Stock Modal */}
{showEditStockModal && (
<div
className="modal-overlay"
onClick={(e) => {
e.stopPropagation();
onCloseEditStockModal();
}}
>
<div className="modal-content edit-stock-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onCloseEditStockModal}>
×
</button>
<h2>{t("editStock.title")}</h2>
<p className="edit-stock-med-name">{selectedMed.name}</p>
<p className="edit-stock-hint">{t("editStock.hint")}</p>
{(() => {
const dbTotal = getMedTotal(selectedMed);
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
const newTotal = editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
const difference = newTotal - currentTotal;
return (
<>
<div className="edit-stock-form">
<label>
{t("editStock.fullBlisters")}{" "}
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("editStock.partialBlisterPills")}
<input
type="number"
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
}}
/>
</label>
</div>
<div className="edit-stock-summary">
<div className="summary-row">
<span>{t("editStock.currentTotal")}:</span>
<span>
{currentTotal} {t("common.pills")}
</span>
</div>
<div className="summary-row">
<span>{t("editStock.newTotal")}:</span>
<span>
{newTotal} {t("common.pills")}
</span>
</div>
<div
className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}
>
<span>{t("editStock.difference")}:</span>
<span>
{difference > 0 ? "+" : ""}
{difference} {t("common.pills")}
</span>
</div>
</div>
</>
);
})()}
<div className="modal-footer">
<button className="ghost" onClick={onCloseEditStockModal}>
{t("common.cancel")}
</button>
<button
className="info"
onClick={() => onSubmitStockCorrection(selectedMed.id)}
disabled={editStockSaving}
>
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,25 @@
// =============================================================================
// MedicationAvatar Component
// =============================================================================
export type MedicationAvatarProps = {
name: string;
imageUrl?: string | null;
size?: "sm" | "md" | "lg";
};
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
const initials =
name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2) || "?";
const sizeClass = `med-avatar med-avatar-${size}`;
if (imageUrl) {
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
}
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
}
+404
View File
@@ -0,0 +1,404 @@
/**
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
* Handles new medication creation and editing existing medications
*/
import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types";
// Field limits for validation
const FIELD_LIMITS = {
name: { max: 100 },
genericName: { max: 100 },
takenBy: { max: 50 },
notes: { max: 1000 },
};
export interface MobileEditModalProps {
show: boolean;
editingId: number | null;
form: FormState;
onFormChange: (form: FormState) => void;
fieldErrors: FieldErrors;
saving: boolean;
formSaved: boolean;
formChanged: boolean;
hasValidationErrors: boolean;
// TakenBy tag input
takenByInput: string;
onTakenByInputChange: (value: string) => void;
existingPeople: string[];
onAddTakenByPerson: (person: string) => void;
onRemoveTakenByPerson: (person: string) => void;
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
// Blister helpers
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
onAddBlister: () => void;
onRemoveBlister: (idx: number) => void;
// Value change handler for numeric fields
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
// Refill state (for edit mode)
refillPacks: number;
onRefillPacksChange: (value: number) => void;
refillLoose: number;
onRefillLooseChange: (value: number) => void;
refillSaving: boolean;
onSubmitRefill: (medId: number) => Promise<void>;
// Image handling
meds: Medication[];
onUploadMedImage: (medId: number, file: File) => Promise<void>;
onDeleteMedImage: (medId: number) => Promise<void>;
// Actions
onClose: () => void;
onResetForm: () => void;
onSaveMedication: (e: React.FormEvent) => void;
}
function deriveTotal(form: FormState) {
const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}
export function MobileEditModal({
show,
editingId,
form,
onFormChange,
fieldErrors,
saving,
formSaved,
formChanged,
hasValidationErrors,
takenByInput,
onTakenByInputChange,
existingPeople,
onAddTakenByPerson,
onRemoveTakenByPerson,
onTakenByKeyDown,
onSetBlisterValue,
onAddBlister,
onRemoveBlister,
onHandleValueChange,
refillPacks,
onRefillPacksChange,
refillLoose,
onRefillLooseChange,
refillSaving,
onSubmitRefill,
meds,
onUploadMedImage,
onDeleteMedImage,
onClose,
_onResetForm,
onSaveMedication,
}: MobileEditModalProps) {
const { t } = useTranslation();
if (!show) return null;
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="edit-modal-header">
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
</div>
<form
className="form-grid mobile-edit-form"
onSubmit={(e) => {
// Check native HTML5 validation first
const formElement = e.currentTarget;
if (!formElement.checkValidity()) {
// Let browser show native validation messages
formElement.reportValidity();
e.preventDefault();
return;
}
onSaveMedication(e);
}}
>
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
{t("form.commercialName")}
<input
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required
/>
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
</label>
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
</label>
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")}
<div className="tag-input-container">
{form.takenBy.map((person) => (
<span key={person} className="tag">
{person}
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
×
</button>
</span>
))}
<input
value={takenByInput}
onChange={(e) => onTakenByInputChange(e.target.value)}
onKeyDown={onTakenByKeyDown}
onBlur={() => {
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}}
placeholder={
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
}
maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal"
/>
<datalist id="takenby-suggestions-modal">
{existingPeople
.filter((p) => !form.takenBy.includes(p))
.map((person) => (
<option key={person} value={person} />
))}
</datalist>
</div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label>
<label>
{t("form.packs")}
<input
type="number"
min="0"
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="0"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")}
</p>
</div>
<label className="full">
{t("form.pillWeight")}
<input
type="number"
min="0"
step="0.1"
value={form.pillWeightMg}
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
placeholder={t("form.placeholders.weight")}
/>
</label>
<label className="full">
{t("form.expiryDate")}
<input
type="date"
value={form.expiryDate}
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
/>
</label>
{/* Refill section - only shown when editing (mobile) */}
{editingId && (
<div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<button
type="button"
className="success"
onClick={() => onSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
{t("common.pills")}
</span>
)}
</div>
</div>
)}
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
{t("form.notes")}
<textarea
value={form.notes}
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
placeholder={t("form.placeholders.notes")}
rows={2}
maxLength={FIELD_LIMITS.notes.max}
className="auto-resize"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = `${target.scrollHeight}px`;
}}
/>
{form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span>
)}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label>
{editingId && currentMed?.imageUrl ? (
<div className="full image-field">
<span className="field-label">{t("form.medicationImage")}</span>
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
</div>
) : editingId ? (
<label className="full">
{t("form.medicationImage")}
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
</label>
) : null}
<fieldset className="full blister-section">
<legend>
{t("form.blisters.title")}
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
<span className="legend-hint">{t("form.blisters.remind")}</span>
</legend>
{form.blisters.map((b, idx) => (
<div key={idx} className="blister-row">
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<input
type="number"
min="0"
step="0.1"
value={b.usage}
onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<input
type="number"
min="1"
value={b.every}
onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<input
type="date"
value={b.startDate}
onChange={(e) => onSetBlisterValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={b.startTime}
onChange={(e) => onSetBlisterValue(idx, "startTime", e.target.value)}
/>
</label>
{form.blisters.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}>
{t("common.remove")}
</button>
)}
</div>
))}
<button type="button" className="ghost add-blister" onClick={onAddBlister}>
+ {t("form.blisters.addIntake")}
</button>
</fieldset>
<div className="modal-footer">
<button type="button" className="ghost" onClick={onClose}>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
>
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
</button>
</div>
</form>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { UserProfile } from "./Auth";
interface ProfileModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<UserProfile onClose={onClose} />
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* ShareDialog - Modal for generating share links for medication schedules
* Allows sharing schedule view for a specific person
*/
import { useTranslation } from "react-i18next";
export interface ShareDialogProps {
show: boolean;
sharePeople: string[];
shareSelectedPerson: string;
onShareSelectedPersonChange: (person: string) => void;
shareSelectedDays: number;
onShareSelectedDaysChange: (days: number) => void;
shareGenerating: boolean;
shareLink: string | null;
onShareLinkChange: (link: string | null) => void;
shareCopied: boolean;
onShareCopiedChange: (copied: boolean) => void;
onClose: () => void;
onGenerateShareLink: () => Promise<void>;
onCopyShareLink: () => void;
}
export function ShareDialog({
show,
sharePeople,
shareSelectedPerson,
onShareSelectedPersonChange,
shareSelectedDays,
onShareSelectedDaysChange,
shareGenerating,
shareLink,
onShareLinkChange,
shareCopied,
onShareCopiedChange,
onClose,
onGenerateShareLink,
onCopyShareLink,
}: ShareDialogProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="share-dialog-header">
<h2>🔗 {t("share.title")}</h2>
<p className="share-dialog-description">{t("share.description")}</p>
</div>
{sharePeople.length === 0 ? (
<div className="share-dialog-empty">
<p>{t("share.noPeople")}</p>
</div>
) : shareLink ? (
<div className="share-dialog-result">
<p className="share-success">{t("share.linkGenerated")}</p>
<div className="share-link-box">
<input
type="text"
value={shareLink}
readOnly
className="share-link-input"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button className="btn-copy" onClick={onCopyShareLink}>
{shareCopied ? "✓" : "📋"}
</button>
</div>
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
<div className="share-dialog-footer">
<button
className="ghost"
onClick={() => {
onShareLinkChange(null);
onShareCopiedChange(false);
}}
>
{t("share.generateAnother")}
</button>
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
) : (
<div className="share-dialog-form">
<div className="form-group">
<label>{t("share.selectPerson")}</label>
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
{sharePeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</div>
<div className="form-group">
<label>{t("share.selectPeriod")}</label>
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
</div>
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.cancel")}
</button>
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
{shareGenerating ? t("share.generating") : t("share.generateLink")}
</button>
</div>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
// =============================================================================
// TagInput Component - Reusable tag input with suggestions
// =============================================================================
import type { KeyboardEvent } from "react";
export interface TagInputProps {
tags: string[];
inputValue: string;
onInputChange: (value: string) => void;
onAddTag: (tag: string) => void;
onRemoveTag: (tag: string) => void;
suggestions?: string[];
placeholder?: string;
addPlaceholder?: string;
maxLength?: number;
error?: string;
datalistId?: string;
}
export function TagInput({
tags,
inputValue,
onInputChange,
onAddTag,
onRemoveTag,
suggestions = [],
placeholder = "",
addPlaceholder = "",
maxLength,
error,
datalistId = "tag-suggestions",
}: TagInputProps) {
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if ((e.key === "Enter" || e.key === ",") && inputValue.trim()) {
e.preventDefault();
onAddTag(inputValue);
}
if (e.key === "Backspace" && !inputValue && tags.length > 0) {
onRemoveTag(tags[tags.length - 1]);
}
}
return (
<>
<div className="tag-input-container">
{tags.map((tag) => (
<span key={tag} className="tag">
{tag}
<button type="button" className="tag-remove" onClick={() => onRemoveTag(tag)}>
×
</button>
</span>
))}
<input
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (inputValue.trim()) onAddTag(inputValue);
}}
placeholder={tags.length === 0 ? placeholder : addPlaceholder}
maxLength={maxLength}
list={datalistId}
/>
{suggestions.length > 0 && (
<datalist id={datalistId}>
{suggestions
.filter((s) => !tags.includes(s))
.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
)}
</div>
{error && <span className="field-error">{error}</span>}
</>
);
}
@@ -0,0 +1,87 @@
/**
* UserFilterModal - Shows medications for a specific person (takenBy filter)
* Allows clicking through to medication details
*/
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import type { Coverage, Medication, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber } from "../utils";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
selectedUser: string | null;
meds: Medication[];
coverage: { all: Coverage[] };
settings: StockThresholds;
onClose: () => void;
onOpenMedDetail: (med: Medication) => void;
}
export function UserFilterModal({
selectedUser,
meds,
coverage,
settings,
onClose,
onOpenMedDetail,
}: UserFilterModalProps) {
const { t } = useTranslation();
if (!selectedUser) return null;
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
</button>
<div className="user-meds-header">
<div className="user-avatar">{selectedUser.charAt(0).toUpperCase()}</div>
<h2>{t("modal.userMedications", { name: selectedUser })}</h2>
</div>
<div className="user-meds-list">
{userMeds.map((med) => {
const medCoverage = coverage.all.find((c) => c.name === med.name);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const packageSize = getPackageSize(med);
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
return (
<div
key={med.id}
className="user-med-item clickable"
onClick={() => {
onClose();
onOpenMedDetail(med);
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info">
<span className="user-med-name">{med.name}</span>
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
</div>
<div className="user-med-stats">
<span className="user-med-pills">
{currentStock}/{formatNumber(packageSize)} {t("common.pills")}
</span>
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
);
})}
{userMeds.length === 0 && (
<div className="user-meds-empty">{t("modal.noMedsForUser", { name: selectedUser })}</div>
)}
</div>
<div className="user-meds-footer">
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
// Components barrel export
export { default as AboutModal } from "./AboutModal";
export type { ConfirmModalProps } from "./ConfirmModal";
export { ConfirmModal } from "./ConfirmModal";
export { default as ExportModal } from "./ExportModal";
export type { LightboxProps } from "./Lightbox";
export { Lightbox } from "./Lightbox";
export type { MedDetailModalProps } from "./MedDetailModal";
export { MedDetailModal } from "./MedDetailModal";
export type { MedicationAvatarProps } from "./MedicationAvatar";
export { MedicationAvatar } from "./MedicationAvatar";
export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal";
export { default as ProfileModal } from "./ProfileModal";
export type { ShareDialogProps } from "./ShareDialog";
export { ShareDialog } from "./ShareDialog";
export { SharedSchedule } from "./SharedSchedule";
export type { TagInputProps } from "./TagInput";
export { TagInput } from "./TagInput";
export type { UserFilterModalProps } from "./UserFilterModal";
export { UserFilterModal } from "./UserFilterModal";
+886
View File
@@ -0,0 +1,886 @@
import type React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import type { Coverage, Medication, ScheduleEvent } from "../types";
import { getSystemLocale } from "../utils/formatters";
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
// =============================================================================
// Types
// =============================================================================
export type DoseInfo = {
id: string;
timeStr: string;
when: number;
usage: number;
takenBy: string[];
};
export type DayMedEntry = {
medName: string;
total: number;
doses: DoseInfo[];
lastWhen: number;
};
export type GroupedDay = {
dateStr: string;
date: Date;
isPast: boolean;
meds: DayMedEntry[];
};
export interface AppContextValue {
// From useMedications
meds: Medication[];
setMeds: React.Dispatch<React.SetStateAction<Medication[]>>;
loading: boolean;
saving: boolean;
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
uploadingImage: boolean;
loadMeds: () => void;
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
uploadMedImage: (medId: number, file: File) => Promise<void>;
deleteMedImage: (medId: number) => Promise<void>;
// From useSettings (selected fields)
settings: ReturnType<typeof useSettings>["settings"];
setSettings: ReturnType<typeof useSettings>["setSettings"];
savedSettings: ReturnType<typeof useSettings>["savedSettings"];
settingsLoading: boolean;
settingsSaving: boolean;
settingsSaved: boolean;
testingEmail: boolean;
testEmailResult: { success: boolean; message: string } | null;
testingShoutrrr: boolean;
testShoutrrrResult: { success: boolean; message: string } | null;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
// From useDoses
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
dismissedDoses: Set<string>;
clearingMissed: boolean;
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
manuallyExpandedDays: Set<string>;
toggleDayCollapse: (dateStr: string, isCurrentlyExpanded: boolean) => void;
// From useShare
showShareDialog: boolean;
sharePeople: string[];
shareSelectedPerson: string;
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
// From useRefill
showRefillModal: boolean;
setShowRefillModal: React.Dispatch<React.SetStateAction<boolean>>;
refillPacks: number;
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
refillLoose: number;
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
refillSaving: boolean;
refillHistory: ReturnType<typeof useRefill>["refillHistory"];
refillHistoryExpanded: boolean;
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
showEditStockModal: boolean;
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
editStockFullBlisters: number;
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
editStockPartialBlisterPills: number;
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
editStockSaving: boolean;
loadRefillHistory: (medId: number) => Promise<void>;
submitRefill: (
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<any>>,
loadMeds: () => void
) => Promise<void>;
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
openRefillModal: () => void;
closeRefillModal: () => void;
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
closeEditStockModal: () => void;
// Computed values
schedule: { events: ScheduleEvent[] };
coverage: { all: Coverage[]; low: Coverage[] };
coverageByMed: Record<string, Coverage>;
depletionByMed: Record<string, number | null>;
existingPeople: string[];
groupedSchedule: GroupedDay[];
pastDays: GroupedDay[];
todayDay: GroupedDay | null;
futureDays: GroupedDay[];
missedPastDoseIds: string[];
getDayStockStatus: (dayMeds: { medName: string; lastWhen: number }[]) => "success" | "warning" | "danger";
// Schedule UI state
scheduleDays: number;
setScheduleDays: React.Dispatch<React.SetStateAction<number>>;
showPastDays: boolean;
setShowPastDays: React.Dispatch<React.SetStateAction<boolean>>;
showFutureDays: boolean;
setShowFutureDays: React.Dispatch<React.SetStateAction<boolean>>;
// Modal state
selectedMed: Medication | null;
setSelectedMed: React.Dispatch<React.SetStateAction<Medication | null>>;
showImageLightbox: boolean;
setShowImageLightbox: React.Dispatch<React.SetStateAction<boolean>>;
scheduleLightboxImage: string | null;
setScheduleLightboxImage: React.Dispatch<React.SetStateAction<string | null>>;
selectedUser: string | null;
setSelectedUser: React.Dispatch<React.SetStateAction<string | null>>;
// Export/Import state
exporting: boolean;
importing: boolean;
showExportModal: boolean;
setShowExportModal: React.Dispatch<React.SetStateAction<boolean>>;
showImportConfirm: boolean;
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
pendingImportData: unknown;
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
importResult: { medications: number; doses: number; shares: number } | null;
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
handleExport: (includeImages?: boolean) => Promise<void>;
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleImportConfirm: () => Promise<void>;
settingsChanged: boolean;
// Modal helpers
openMedDetail: (med: Medication) => void;
closeMedDetail: () => void;
openImageLightbox: () => void;
closeImageLightbox: () => void;
openScheduleLightbox: (imageUrl: string) => void;
closeScheduleLightbox: () => void;
openUserFilter: (person: string) => void;
closeUserFilter: () => void;
}
// =============================================================================
// Context
// =============================================================================
const AppContext = createContext<AppContextValue | null>(null);
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// =============================================================================
// Provider
// =============================================================================
export function AppProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
const { user } = useAuth();
// Compose hooks
const medications = useMedications();
const settingsHook = useSettings();
const doses = useDoses();
const collapsed = useCollapsedDays(user?.id);
const share = useShare();
const refill = useRefill();
// Schedule UI state
const [scheduleDays, setScheduleDays] = useState<number>(30);
const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false);
// Modal state
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
const [showImageLightbox, setShowImageLightbox] = useState(false);
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
// Export/Import state
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null);
// Load user-specific scheduleDays when user changes
useEffect(() => {
if (typeof window !== "undefined" && user?.id) {
const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays"));
setScheduleDays(storedDays ? Number(storedDays) : 30);
}
}, [user?.id]);
// Load medications and settings when user changes
useEffect(() => {
medications.loadMeds();
settingsHook.loadSettings();
}, [medications.loadMeds, settingsHook.loadSettings]);
// Update selectedMed when meds change (e.g., after refill)
useEffect(() => {
if (selectedMed) {
const updated = medications.meds.find((m) => m.id === selectedMed.id);
if (
updated &&
(updated.packCount !== selectedMed.packCount ||
updated.looseTablets !== selectedMed.looseTablets ||
updated.updatedAt !== selectedMed.updatedAt)
) {
setSelectedMed(updated);
}
}
}, [medications.meds, selectedMed]);
// Computed values - combine app language with timezone region for locale
const systemLocale = getSystemLocale(i18n.language);
const schedule = useMemo(
() => buildSchedulePreview(medications.meds, systemLocale, true),
[medications.meds, systemLocale]
);
const coverage = useMemo(
() =>
calculateCoverage(
medications.meds,
schedule.events,
systemLocale,
settingsHook.settings.reminderDaysBefore,
settingsHook.settings.stockCalculationMode,
doses.takenDoses
),
[
medications.meds,
schedule.events,
systemLocale,
settingsHook.settings.reminderDaysBefore,
settingsHook.settings.stockCalculationMode,
doses.takenDoses,
]
);
const depletionByMed = useMemo(
() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])),
[coverage.all]
);
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
const existingPeople = useMemo(() => {
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
return [...new Set(allPeople)].filter(Boolean).sort();
}, [medications.meds]);
// Get worst stock status for a day's medications
const getDayStockStatus = useCallback(
(dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
const statuses = dayMeds.map((item) => {
const cov = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
// Will be out of stock by this day?
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
return "danger";
}
if (!cov) return "success";
const { daysLeft, medsLeft } = cov;
// Currently out of stock
if (medsLeft <= 0 || daysLeft === 0) return "danger";
// No schedule (can't calculate)
if (daysLeft === null) return "success";
// Low stock: < lowStockDays (warning)
if (daysLeft < settingsHook.settings.lowStockDays) return "warning";
// Normal/High stock
return "success";
});
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
},
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
);
const groupedSchedule = useMemo(() => {
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
schedule.events.slice(0, 2000).forEach((event) => {
const day = days.get(event.dateStr) ?? {
dateStr: event.dateStr,
date: new Date(event.when),
isPast: event.isPast,
meds: new Map(),
};
const medEntry = day.meds.get(event.medName) ?? {
medName: event.medName,
total: 0,
doses: [],
lastWhen: event.when,
};
medEntry.total += event.usage;
medEntry.doses.push({
id: event.id,
timeStr: event.timeStr,
when: event.when,
usage: event.usage,
takenBy: event.takenBy || [],
});
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
day.meds.set(event.medName, medEntry);
days.set(event.dateStr, day);
});
return Array.from(days.values()).map((d) => ({
dateStr: d.dateStr,
date: d.date,
isPast: d.isPast,
meds: Array.from(d.meds.values()),
}));
}, [schedule.events]);
const pastDays = useMemo(() => groupedSchedule.filter((d) => d.isPast), [groupedSchedule]);
// Separate today from future days
const todayDay = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
groupedSchedule.find((d) => {
const dayDate = new Date(d.date);
dayDate.setHours(0, 0, 0, 0);
return dayDate.getTime() === today.getTime();
}) || null
);
}, [groupedSchedule]);
const futureDays = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return groupedSchedule
.filter((d) => {
if (d.isPast) return false;
const dayDate = new Date(d.date);
dayDate.setHours(0, 0, 0, 0);
return dayDate.getTime() > today.getTime();
})
.slice(0, scheduleDays);
}, [groupedSchedule, scheduleDays]);
// Build a map of medId -> dismissedUntil date string from medication records
// This is robust against timestamp changes from schedule updates or timezone fixes
const _dismissedUntilByMed = useMemo(() => {
const map = new Map<string, string>();
for (const med of medications.meds) {
if (med.dismissedUntil) {
map.set(String(med.id), med.dismissedUntil);
}
}
return map;
}, [medications.meds]);
// Helper to check if a dose date is on or before the dismissedUntil date
const isDoseDismissed = useCallback((doseId: string, dismissedUntilDate: string | undefined): boolean => {
if (!dismissedUntilDate) return false;
// Extract timestamp from dose ID (format: medId-blisterIdx-timestamp or medId-blisterIdx-timestamp-person)
const parts = doseId.split("-");
if (parts.length < 3) return false;
const timestamp = parseInt(parts[2], 10);
if (Number.isNaN(timestamp)) return false;
// Compare date strings (YYYY-MM-DD format sorts correctly)
const doseDate = new Date(timestamp);
const doseDateStr = `${doseDate.getFullYear()}-${String(doseDate.getMonth() + 1).padStart(2, "0")}-${String(doseDate.getDate()).padStart(2, "0")}`;
return doseDateStr <= dismissedUntilDate;
}, []);
const missedPastDoseIds = useMemo(() => {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => {
// Find the medication to get its dismissedUntil
const med = medications.meds.find((med) => med.name === m.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return m.doses.flatMap((dose) => {
// Check if this dose is on or before the dismissed date for this medication
if (isDoseDismissed(dose.id, dismissedUntilDate)) {
return [];
}
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p: string) => `${dose.id}-${p}`) : [dose.id];
});
})
);
// Also filter out doses that are marked as taken or individually dismissed (legacy)
return totalPastDoses.filter((id) => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id));
}, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed]);
// Modal helpers with browser history support
const openMedDetail = useCallback(
(med: Medication) => {
setSelectedMed(med);
refill.setRefillHistoryExpanded(false);
refill.loadRefillHistory(med.id);
window.history.pushState({ modal: "medDetail", medId: med.id }, "");
},
[refill]
);
const closeMedDetail = useCallback(() => {
if (selectedMed) {
window.history.back();
}
}, [selectedMed]);
const openImageLightbox = useCallback(() => {
setShowImageLightbox(true);
window.history.pushState({ modal: "imageLightbox" }, "");
}, []);
const closeImageLightbox = useCallback(() => {
if (showImageLightbox) {
window.history.back();
}
}, [showImageLightbox]);
const openScheduleLightbox = useCallback((imageUrl: string) => {
setScheduleLightboxImage(imageUrl);
window.history.pushState({ modal: "scheduleLightbox" }, "");
}, []);
const closeScheduleLightbox = useCallback(() => {
if (scheduleLightboxImage) {
window.history.back();
}
}, [scheduleLightboxImage]);
const openUserFilter = useCallback((person: string) => {
setSelectedUser(person);
window.history.pushState({ modal: "userFilter", person }, "");
}, []);
const closeUserFilter = useCallback(() => {
if (selectedUser) {
window.history.back();
}
}, [selectedUser]);
// Wrapper to pass meds to openShareDialog
const openShareDialog = useCallback(() => {
share.openShareDialog(medications.meds);
}, [share, medications.meds]);
// Get t function for translations
const { t } = useTranslation();
// Export data to JSON file
const handleExport = useCallback(
async (includeImages: boolean = true) => {
setExporting(true);
try {
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
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);
},
[t]
);
// Handle file selection for import
const handleImportFileSelect = useCallback(
(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 = "";
},
[t]
);
// Confirm and execute import
const handleImportConfirm = useCallback(async () => {
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),
});
// Get the response text first to handle non-JSON responses
const text = await res.text();
let data: { error?: string; message?: string; imported?: number } = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
console.error("Import response parse error:", text);
alert(`${t("exportImport.importError")}: Server returned invalid response`);
return;
}
if (!res.ok) {
alert(`${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`);
return;
}
// Show success message in UI instead of browser alert
setImportResult({
medications: data.imported?.medications || 0,
doses: data.imported?.doseHistory || 0,
shares: data.imported?.shareLinks || 0,
});
// Reload all data
medications.loadMeds();
settingsHook.loadSettings();
doses.loadTakenDoses();
} catch (err) {
console.error("Import error:", err);
alert(t("exportImport.importError"));
}
setPendingImportData(null);
setImporting(false);
}, [pendingImportData, t, medications, settingsHook, doses]);
// Compute settingsChanged
const settingsChanged = useMemo(() => {
const settings = settingsHook.settings;
const savedSettings = settingsHook.savedSettings;
return (
settings.emailEnabled !== savedSettings.emailEnabled ||
settings.notificationEmail !== savedSettings.notificationEmail ||
settings.emailStockReminders !== savedSettings.emailStockReminders ||
settings.emailIntakeReminders !== savedSettings.emailIntakeReminders ||
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore ||
settings.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
settings.lowStockDays !== savedSettings.lowStockDays ||
settings.normalStockDays !== savedSettings.normalStockDays ||
settings.highStockDays !== savedSettings.highStockDays ||
settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled ||
settings.shoutrrrUrl !== savedSettings.shoutrrrUrl ||
settings.shoutrrrStockReminders !== savedSettings.shoutrrrStockReminders ||
settings.shoutrrrIntakeReminders !== savedSettings.shoutrrrIntakeReminders ||
settings.skipRemindersForTakenDoses !== savedSettings.skipRemindersForTakenDoses ||
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode
);
}, [settingsHook.settings, settingsHook.savedSettings]);
// New dismissMissedDoses that uses medication-level dismissedUntil dates
// This is robust against timestamp changes from schedule updates or timezone fixes
const [clearingMissedState, setClearingMissedState] = useState(false);
const dismissMissedDoses = useCallback(
async (doseIds: string[]) => {
if (doseIds.length === 0) return;
// Extract unique medication IDs from dose IDs (format: medId-blisterIdx-timestamp[-person])
const medIds = new Set<number>();
for (const doseId of doseIds) {
const parts = doseId.split("-");
if (parts.length >= 1) {
const medId = parseInt(parts[0], 10);
if (!Number.isNaN(medId)) {
medIds.add(medId);
}
}
}
if (medIds.size === 0) return;
// Get today's date in YYYY-MM-DD format
const today = new Date();
const until = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
setClearingMissedState(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ medicationIds: Array.from(medIds), until }),
});
if (res.ok) {
// Reload medications to get updated dismissedUntil values
await medications.loadMeds();
doses.setShowClearMissedConfirm(false);
}
} catch {
// Error - dialog stays open
} finally {
setClearingMissedState(false);
}
},
[medications, doses]
);
// Build context value
const value: AppContextValue = useMemo(
() => ({
// From useMedications
...medications,
// From useSettings
settings: settingsHook.settings,
setSettings: settingsHook.setSettings,
savedSettings: settingsHook.savedSettings,
settingsLoading: settingsHook.settingsLoading,
settingsSaving: settingsHook.settingsSaving,
settingsSaved: settingsHook.settingsSaved,
testingEmail: settingsHook.testingEmail,
testEmailResult: settingsHook.testEmailResult,
testingShoutrrr: settingsHook.testingShoutrrr,
testShoutrrrResult: settingsHook.testShoutrrrResult,
loadSettings: settingsHook.loadSettings,
saveSettings: settingsHook.saveSettings,
testEmail: settingsHook.testEmail,
testShoutrrr: settingsHook.testShoutrrr,
// From useDoses
takenDoses: doses.takenDoses,
setTakenDoses: doses.setTakenDoses,
dismissedDoses: doses.dismissedDoses,
clearingMissed: clearingMissedState,
showClearMissedConfirm: doses.showClearMissedConfirm,
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
getDoseId: doses.getDoseId,
countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken,
undoDoseTaken: doses.undoDoseTaken,
dismissMissedDoses,
// From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
manuallyExpandedDays: collapsed.manuallyExpandedDays,
toggleDayCollapse: collapsed.toggleDayCollapse,
// From useShare
showShareDialog: share.showShareDialog,
sharePeople: share.sharePeople,
shareSelectedPerson: share.shareSelectedPerson,
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
openShareDialog,
generateShareLink: share.generateShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
// From useRefill
showRefillModal: refill.showRefillModal,
setShowRefillModal: refill.setShowRefillModal,
refillPacks: refill.refillPacks,
setRefillPacks: refill.setRefillPacks,
refillLoose: refill.refillLoose,
setRefillLoose: refill.setRefillLoose,
refillSaving: refill.refillSaving,
refillHistory: refill.refillHistory,
refillHistoryExpanded: refill.refillHistoryExpanded,
setRefillHistoryExpanded: refill.setRefillHistoryExpanded,
showEditStockModal: refill.showEditStockModal,
setShowEditStockModal: refill.setShowEditStockModal,
editStockFullBlisters: refill.editStockFullBlisters,
setEditStockFullBlisters: refill.setEditStockFullBlisters,
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
editStockSaving: refill.editStockSaving,
loadRefillHistory: refill.loadRefillHistory,
submitRefill: refill.submitRefill,
submitStockCorrection: refill.submitStockCorrection,
openRefillModal: refill.openRefillModal,
closeRefillModal: refill.closeRefillModal,
openEditStockModal: refill.openEditStockModal,
closeEditStockModal: refill.closeEditStockModal,
// Computed values
schedule,
coverage,
coverageByMed,
depletionByMed,
existingPeople,
groupedSchedule,
pastDays,
todayDay,
futureDays,
missedPastDoseIds,
getDayStockStatus,
// Schedule UI state
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
showFutureDays,
setShowFutureDays,
// Modal state
selectedMed,
setSelectedMed,
showImageLightbox,
setShowImageLightbox,
scheduleLightboxImage,
setScheduleLightboxImage,
selectedUser,
setSelectedUser,
// Modal helpers
openMedDetail,
closeMedDetail,
openImageLightbox,
closeImageLightbox,
openScheduleLightbox,
closeScheduleLightbox,
openUserFilter,
closeUserFilter,
// Export/Import
exporting,
importing,
showExportModal,
setShowExportModal,
showImportConfirm,
setShowImportConfirm,
pendingImportData,
setPendingImportData,
importResult,
setImportResult,
handleExport,
handleImportFileSelect,
handleImportConfirm,
settingsChanged,
}),
[
medications,
settingsHook,
doses,
collapsed,
share,
refill,
schedule,
coverage,
coverageByMed,
depletionByMed,
existingPeople,
groupedSchedule,
pastDays,
todayDay,
futureDays,
missedPastDoseIds,
getDayStockStatus,
scheduleDays,
showPastDays,
showFutureDays,
selectedMed,
showImageLightbox,
scheduleLightboxImage,
selectedUser,
openMedDetail,
closeMedDetail,
openImageLightbox,
closeImageLightbox,
openScheduleLightbox,
closeScheduleLightbox,
openUserFilter,
closeUserFilter,
openShareDialog,
exporting,
importing,
showExportModal,
showImportConfirm,
pendingImportData,
importResult,
handleExport,
handleImportFileSelect,
handleImportConfirm,
settingsChanged,
clearingMissedState,
dismissMissedDoses,
]
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// =============================================================================
// Hook
// =============================================================================
export function useAppContext(): AppContextValue {
const context = useContext(AppContext);
if (!context) {
throw new Error("useAppContext must be used within an AppProvider");
}
return context;
}
@@ -0,0 +1,73 @@
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "../components/ConfirmModal";
interface UnsavedChangesContextValue {
/** Whether there are unsaved changes anywhere in the app */
hasUnsavedChanges: boolean;
/** Register that a component has unsaved changes */
setHasUnsavedChanges: (value: boolean) => void;
/** Check and confirm navigation - returns a promise that resolves to true if navigation should proceed */
confirmNavigation: () => Promise<boolean>;
}
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [pendingResolve, setPendingResolve] = useState<((value: boolean) => void) | null>(null);
const confirmNavigation = useCallback((): Promise<boolean> => {
if (!hasUnsavedChanges) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
setPendingResolve(() => resolve);
setShowConfirmModal(true);
});
}, [hasUnsavedChanges]);
const handleConfirm = useCallback(() => {
setShowConfirmModal(false);
if (pendingResolve) {
pendingResolve(true);
setPendingResolve(null);
}
}, [pendingResolve]);
const handleCancel = useCallback(() => {
setShowConfirmModal(false);
if (pendingResolve) {
pendingResolve(false);
setPendingResolve(null);
}
}, [pendingResolve]);
return (
<UnsavedChangesContext.Provider value={{ hasUnsavedChanges, setHasUnsavedChanges, confirmNavigation }}>
{children}
{showConfirmModal && (
<ConfirmModal
title={t("common.unsavedChanges.title", "Unsaved Changes")}
message={t("common.unsavedChanges.message")}
confirmLabel={t("common.unsavedChanges.leave", "Leave")}
cancelLabel={t("common.unsavedChanges.stay", "Stay")}
onConfirm={handleConfirm}
onCancel={handleCancel}
confirmVariant="danger"
/>
)}
</UnsavedChangesContext.Provider>
);
}
export function useUnsavedChanges() {
const context = useContext(UnsavedChangesContext);
if (!context) {
throw new Error("useUnsavedChanges must be used within UnsavedChangesProvider");
}
return context;
}
+5
View File
@@ -0,0 +1,5 @@
// Context barrel export
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
export { AppProvider, useAppContext } from "./AppContext";
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
+20
View File
@@ -0,0 +1,20 @@
// Hooks barrel export
export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
export { useCollapsedDays } from "./useCollapsedDays";
export type { UseDosesReturn } from "./useDoses";
export { useDoses } from "./useDoses";
export type { UseMedicationFormReturn } from "./useMedicationForm";
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
export type { UseMedicationsReturn } from "./useMedications";
export { useMedications } from "./useMedications";
export type { UseRefillReturn } from "./useRefill";
export { useRefill } from "./useRefill";
export type { Settings, UseSettingsReturn } from "./useSettings";
export { useSettings } from "./useSettings";
export type { UseShareReturn } from "./useShare";
export { useShare } from "./useShare";
export type { Theme, UseThemeReturn } from "./useTheme";
export { useTheme } from "./useTheme";
export type { UseUnsavedChangesWarningReturn } from "./useUnsavedChangesWarning";
export { useUnsavedChangesWarning } from "./useUnsavedChangesWarning";
+67
View File
@@ -0,0 +1,67 @@
// =============================================================================
// useCollapsedDays Hook - Day collapse/expand state management
// =============================================================================
import { useCallback, useEffect, useState } from "react";
import { loadCollapsedDaysFromStorage, userStorageKey } from "../utils/storage";
export interface UseCollapsedDaysReturn {
manuallyCollapsedDays: Set<string>;
manuallyExpandedDays: Set<string>;
toggleDayCollapse: (dateStr: string, isAutoCollapsed: boolean) => void;
}
export function useCollapsedDays(userId: number | undefined): UseCollapsedDaysReturn {
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
// Load collapsed/expanded state from localStorage when user changes
useEffect(() => {
if (typeof window !== "undefined" && userId) {
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
userStorageKey(userId, "collapsedDays"),
userStorageKey(userId, "expandedDays")
);
setManuallyCollapsedDays(collapsed);
setManuallyExpandedDays(expanded);
}
}, [userId]);
// Toggle day collapse/expand
const toggleDayCollapse = useCallback(
(dateStr: string, isAutoCollapsed: boolean) => {
if (isAutoCollapsed) {
// Day is auto-collapsed (all taken) - toggle the expanded override
setManuallyExpandedDays((prev) => {
const next = new Set(prev);
if (next.has(dateStr)) {
next.delete(dateStr);
} else {
next.add(dateStr);
}
if (userId) localStorage.setItem(userStorageKey(userId, "expandedDays"), JSON.stringify([...next]));
return next;
});
} else {
// Day is not auto-collapsed - toggle manual collapse
setManuallyCollapsedDays((prev) => {
const next = new Set(prev);
if (next.has(dateStr)) {
next.delete(dateStr);
} else {
next.add(dateStr);
}
if (userId) localStorage.setItem(userStorageKey(userId, "collapsedDays"), JSON.stringify([...next]));
return next;
});
}
},
[userId]
);
return {
manuallyCollapsedDays,
manuallyExpandedDays,
toggleDayCollapse,
};
}
+142
View File
@@ -0,0 +1,142 @@
// =============================================================================
// useDoses Hook - Dose tracking state and operations
// =============================================================================
import { useCallback, useEffect, useState } from "react";
export interface UseDosesReturn {
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
dismissedDoses: Set<string>;
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
loadTakenDoses: () => Promise<void>;
}
export function useDoses(): UseDosesReturn {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
// Load taken doses from server
const loadTakenDoses = useCallback(async () => {
try {
const res = await fetch("/api/doses/taken", { credentials: "include" });
if (res.ok) {
const data = await res.json();
const taken = new Set<string>();
const dismissed = new Set<string>();
for (const d of data.doses) {
if (d.dismissed) {
dismissed.add(d.doseId);
} else {
taken.add(d.doseId);
}
}
setTakenDoses(taken);
setDismissedDoses(dismissed);
}
// Don't reset on error - keep current state
} catch {
// Don't reset on error - keep current state
}
}, []);
// Poll for taken doses from server (works with or without auth)
useEffect(() => {
loadTakenDoses();
// Poll for updates every 5 seconds (real-time sync with share links)
const interval = setInterval(loadTakenDoses, 5000);
return () => clearInterval(interval);
}, [loadTakenDoses]);
// Get dose ID with optional person suffix
const getDoseId = useCallback((baseDoseId: string, person: string | null): string => {
return person ? `${baseDoseId}-${person}` : baseDoseId;
}, []);
// Count taken doses for a day/item
const countTakenDoses = useCallback(
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
let total = 0;
let taken = 0;
for (const d of doses) {
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null];
for (const person of people) {
total++;
if (takenDoses.has(getDoseId(d.id, person))) taken++;
}
}
return { total, taken };
},
[takenDoses, getDoseId]
);
const markDoseTaken = useCallback(async (doseId: string) => {
// Optimistic update
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
// Send to server
try {
await fetch("/api/doses/taken", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
} catch {
// Revert on error
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
}
}, []);
const undoDoseTaken = useCallback(async (doseId: string) => {
// Optimistic update
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
// Send to server
try {
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
// Revert on error
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
}
}, []);
return {
takenDoses,
setTakenDoses,
dismissedDoses,
showClearMissedConfirm,
setShowClearMissedConfirm,
getDoseId,
countTakenDoses,
markDoseTaken,
undoDoseTaken,
loadTakenDoses,
};
}
+244
View File
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types";
import { FIELD_LIMITS } from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
export const defaultBlister = (): FormBlister => {
const now = new Date();
return {
usage: "1",
every: "1",
startDate: toDateValue(now),
startTime: toTimeValue(now),
};
};
export const defaultForm = (): FormState => ({
name: "",
genericName: "",
takenBy: [],
packCount: "1",
blistersPerPack: "1",
pillsPerBlister: "1",
looseTablets: "0",
pillWeightMg: "",
expiryDate: "",
notes: "",
intakeRemindersEnabled: false,
blisters: [defaultBlister()],
});
export interface UseMedicationFormReturn {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
originalForm: FormState;
setOriginalForm: React.Dispatch<React.SetStateAction<FormState>>;
editingId: number | null;
setEditingId: React.Dispatch<React.SetStateAction<number | null>>;
showEditModal: boolean;
setShowEditModal: React.Dispatch<React.SetStateAction<boolean>>;
fieldErrors: FieldErrors;
formSaved: boolean;
setFormSaved: React.Dispatch<React.SetStateAction<boolean>>;
hasValidationErrors: boolean;
formChanged: boolean;
pendingImage: File | null;
setPendingImage: React.Dispatch<React.SetStateAction<File | null>>;
pendingImagePreview: string | null;
setPendingImagePreview: React.Dispatch<React.SetStateAction<string | null>>;
takenByInput: string;
setTakenByInput: React.Dispatch<React.SetStateAction<string>>;
validateField: (field: keyof FieldErrors, value: string | string[]) => string | undefined;
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
addBlister: () => void;
removeBlister: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void;
resetForm: () => void;
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
addTakenByPerson: (name: string) => void;
removeTakenByPerson: (name: string) => void;
handleTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}
export function useMedicationForm(): UseMedicationFormReturn {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(defaultForm());
const [originalForm, setOriginalForm] = useState<FormState>(defaultForm());
const [editingId, setEditingId] = useState<number | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [formSaved, setFormSaved] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
const [takenByInput, setTakenByInput] = useState("");
// Validate form fields
const validateField = useCallback(
(field: keyof FieldErrors, value: string | string[]): string | undefined => {
const limits = FIELD_LIMITS[field];
// Skip validation for takenBy array (individual items validated on add)
if (field === "takenBy") return undefined;
const strValue = typeof value === "string" ? value : "";
if (field === "name" && (!strValue || strValue.trim().length === 0)) {
return t("common.validation.required");
}
if ("max" in limits && strValue.length > limits.max) {
return t("common.validation.maxLength", { max: limits.max, current: strValue.length });
}
return undefined;
},
[t]
);
// Check if form has any errors
const hasValidationErrors = useMemo(() => {
return Object.values(fieldErrors).some((error) => error !== undefined);
}, [fieldErrors]);
// Check if form has been modified from original state
const formChanged = useMemo(() => {
return JSON.stringify(form) !== JSON.stringify(originalForm);
}, [form, originalForm]);
// Reset formSaved when form changes
useEffect(() => {
if (formChanged) {
setFormSaved(false);
}
}, [formChanged]);
// Validate all fields when form changes
useEffect(() => {
const errors: FieldErrors = {};
(["name", "genericName", "notes"] as const).forEach((f) => {
const error = validateField(f, form[f]);
if (error) errors[f] = error;
});
setFieldErrors(errors);
}, [form.name, form.genericName, form.notes, validateField]);
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
setForm((prev) => {
const next = [...prev.blisters];
next[idx] = { ...next[idx], [field]: value };
return { ...prev, blisters: next };
});
}, []);
const addBlister = useCallback(() => {
setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] }));
}, []);
const removeBlister = useCallback((idx: number) => {
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
}, []);
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
setEditingId(med.id);
setTakenByInput(""); // Clear tag input when starting edit
setFormSaved(true); // Existing medication is already saved
const editForm: FormState = {
name: med.name,
genericName: med.genericName ?? "",
takenBy: med.takenBy || [], // Already an array from API
packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister),
looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
blisters: med.blisters.map((s) => ({
usage: String(s.usage),
every: String(s.every),
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
})),
};
setForm(editForm);
setOriginalForm(editForm);
// Show modal on mobile
if (window.innerWidth <= 768) {
openEditModal();
}
}, []);
const resetForm = useCallback(() => {
setEditingId(null);
setShowEditModal(false);
setPendingImage(null);
setPendingImagePreview(null);
setTakenByInput("");
setFormSaved(false);
const newForm = defaultForm();
setForm(newForm);
setOriginalForm(newForm);
}, []);
const handleValueChange = useCallback(<K extends keyof FormState>(key: K, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
}, []);
// Tag input helpers for "Taken By" field
const addTakenByPerson = useCallback(
(name: string) => {
const trimmed = name.trim();
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
}
setTakenByInput("");
},
[form.takenBy]
);
const removeTakenByPerson = useCallback((name: string) => {
setForm((prev) => ({ ...prev, takenBy: prev.takenBy.filter((p) => p !== name) }));
}, []);
const handleTakenByKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTakenByPerson(takenByInput);
} else if (e.key === "Backspace" && !takenByInput && form.takenBy.length > 0) {
// Remove last tag on backspace when input is empty
removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
}
},
[takenByInput, form.takenBy, addTakenByPerson, removeTakenByPerson]
);
return {
form,
setForm,
originalForm,
setOriginalForm,
editingId,
setEditingId,
showEditModal,
setShowEditModal,
fieldErrors,
formSaved,
setFormSaved,
hasValidationErrors,
formChanged,
pendingImage,
setPendingImage,
pendingImagePreview,
setPendingImagePreview,
takenByInput,
setTakenByInput,
validateField,
setBlisterValue,
addBlister,
removeBlister,
startEdit,
resetForm,
handleValueChange,
addTakenByPerson,
removeTakenByPerson,
handleTakenByKeyDown,
};
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useState } from "react";
import type { Medication } from "../types";
export interface UseMedicationsReturn {
meds: Medication[];
setMeds: React.Dispatch<React.SetStateAction<Medication[]>>;
loading: boolean;
saving: boolean;
setSaving: React.Dispatch<React.SetStateAction<boolean>>;
uploadingImage: boolean;
loadMeds: () => void;
deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise<void>;
uploadMedImage: (medId: number, file: File) => Promise<void>;
deleteMedImage: (medId: number) => Promise<void>;
}
export function useMedications(): UseMedicationsReturn {
const [meds, setMeds] = useState<Medication[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const loadMeds = useCallback(() => {
setLoading(true);
fetch("/api/medications")
.then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([]))
.finally(() => setLoading(false));
}, []);
const deleteMed = useCallback(
async (id: number, editingId: number | null, resetForm: () => void) => {
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
if (editingId === id) resetForm();
loadMeds();
},
[loadMeds]
);
const uploadMedImage = useCallback(
async (medId: number, file: File) => {
setUploadingImage(true);
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch(`/api/medications/${medId}/image`, {
method: "POST",
body: formData,
});
if (res.ok) {
loadMeds();
}
} catch {
// ignore
}
setUploadingImage(false);
},
[loadMeds]
);
const deleteMedImage = useCallback(
async (medId: number) => {
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
loadMeds();
},
[loadMeds]
);
return {
meds,
setMeds,
loading,
saving,
setSaving,
uploadingImage,
loadMeds,
deleteMed,
uploadMedImage,
deleteMedImage,
};
}
+239
View File
@@ -0,0 +1,239 @@
import { useCallback, useState } from "react";
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
import { getMedTotal } from "../types";
export interface UseRefillReturn {
// Refill state
showRefillModal: boolean;
setShowRefillModal: React.Dispatch<React.SetStateAction<boolean>>;
refillPacks: number;
setRefillPacks: React.Dispatch<React.SetStateAction<number>>;
refillLoose: number;
setRefillLoose: React.Dispatch<React.SetStateAction<number>>;
refillSaving: boolean;
refillHistory: RefillEntry[];
refillHistoryExpanded: boolean;
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
// Edit stock (correction) state
showEditStockModal: boolean;
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
editStockFullBlisters: number;
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
editStockPartialBlisterPills: number;
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
editStockSaving: boolean;
// Actions
loadRefillHistory: (medId: number) => Promise<void>;
submitRefill: (
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void
) => Promise<void>;
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
openRefillModal: () => void;
closeRefillModal: () => void;
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
closeEditStockModal: () => void;
}
export function useRefill(): UseRefillReturn {
// Refill state
const [showRefillModal, setShowRefillModal] = useState(false);
const [refillPacks, setRefillPacks] = useState(1);
const [refillLoose, setRefillLoose] = useState(0);
const [refillSaving, setRefillSaving] = useState(false);
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
// Edit stock (correction) state
const [showEditStockModal, setShowEditStockModal] = useState(false);
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
const [editStockSaving, setEditStockSaving] = useState(false);
// Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => {
try {
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
} else {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
}, []);
// Submit a refill
const submitRefill = useCallback(
async (
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void
) => {
if (refillPacks < 1 && refillLoose < 1) return;
setRefillSaving(true);
try {
const res = await fetch(`/api/medications/${medId}/refill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
});
if (res.ok) {
const data = await res.json();
// Update form values if we're in edit mode
if (editingId === medId && data.newStock) {
setForm((f) => ({
...f,
packCount: String(data.newStock.packCount),
looseTablets: String(data.newStock.looseTablets),
}));
}
// Reset refill form
setRefillPacks(1);
setRefillLoose(0);
// Close refill modal via history back for proper back-button support
if (showRefillModal) {
window.history.back();
}
// Reload medications to get updated stock
loadMeds();
// Reload refill history
await loadRefillHistory(medId);
}
} catch {
// ignore
}
setRefillSaving(false);
},
[refillPacks, refillLoose, showRefillModal, loadRefillHistory]
);
// Submit a stock correction - user says how many pills they have RIGHT NOW
const submitStockCorrection = useCallback(
async (medId: number, selectedMed: Medication, loadMeds: () => void) => {
if (!selectedMed) return;
setEditStockSaving(true);
try {
// Auto-convert: handle full blister and negative partial blister
let finalFullBlisters = editStockFullBlisters;
let finalPartialPills = editStockPartialBlisterPills;
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
if (finalPartialPills >= selectedMed.pillsPerBlister) {
finalFullBlisters += 1;
finalPartialPills = 0;
}
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
if (finalPartialPills < 0 && finalFullBlisters > 0) {
finalFullBlisters -= 1;
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
}
// Ensure we don't go negative
if (finalPartialPills < 0) finalPartialPills = 0;
if (finalFullBlisters < 0) finalFullBlisters = 0;
// What the user says they have RIGHT NOW = the new DB total
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
// The "base" from DB structure (without any stockAdjustment)
const baseTotal =
selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
const newStockAdjustment = desiredTotal - baseTotal;
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
});
if (res.ok) {
// Close edit stock modal via history back
if (showEditStockModal) {
window.history.back();
}
// Reload medications to get updated stock
loadMeds();
}
} catch {
// ignore
}
setEditStockSaving(false);
},
[editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]
);
const openRefillModal = useCallback(() => {
setShowRefillModal(true);
window.history.pushState({ modal: "refill" }, "");
}, []);
const closeRefillModal = useCallback(() => {
if (showRefillModal) {
window.history.back();
}
}, [showRefillModal]);
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
if (!selectedMed) return;
// Get current stock from coverage (after consumption)
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
const dbTotal = getMedTotal(selectedMed);
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
// Simply divide into full blisters and partial
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
const partialPills = currentStock % selectedMed.pillsPerBlister;
// Pre-fill with current values
setEditStockFullBlisters(fullBlisters);
setEditStockPartialBlisterPills(partialPills);
setShowEditStockModal(true);
window.history.pushState({ modal: "editStock" }, "");
}, []);
const closeEditStockModal = useCallback(() => {
if (showEditStockModal) {
window.history.back();
}
}, [showEditStockModal]);
return {
showRefillModal,
setShowRefillModal,
refillPacks,
setRefillPacks,
refillLoose,
setRefillLoose,
refillSaving,
refillHistory,
refillHistoryExpanded,
setRefillHistoryExpanded,
showEditStockModal,
setShowEditStockModal,
editStockFullBlisters,
setEditStockFullBlisters,
editStockPartialBlisterPills,
setEditStockPartialBlisterPills,
editStockSaving,
loadRefillHistory,
submitRefill,
submitStockCorrection,
openRefillModal,
closeRefillModal,
openEditStockModal,
closeEditStockModal,
};
}
+293
View File
@@ -0,0 +1,293 @@
// =============================================================================
// useSettings Hook - Settings state and operations
// =============================================================================
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface Settings {
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
maxNaggingReminders: number;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
smtpHost: string;
smtpPort: number;
smtpUser: string;
smtpPass: string;
smtpFrom: string;
smtpSecure: boolean;
hasSmtpPassword: boolean;
lastAutoEmailSent: string | null;
nextScheduledCheck: string | null;
lastNotificationType: "stock" | "intake" | null;
lastNotificationChannel: "email" | "push" | "both" | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
stockCalculationMode: "automatic" | "manual";
expiryWarningDays: number;
}
const defaultSettings: Settings = {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
smtpPass: "",
smtpFrom: "",
smtpSecure: false,
hasSmtpPassword: false,
lastAutoEmailSent: null,
nextScheduledCheck: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
expiryWarningDays: 30,
};
export interface UseSettingsReturn {
settings: Settings;
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
savedSettings: Settings;
settingsLoading: boolean;
settingsSaving: boolean;
settingsSaved: boolean;
testingEmail: boolean;
testEmailResult: { success: boolean; message: string } | null;
setTestEmailResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
testingShoutrrr: boolean;
testShoutrrrResult: { success: boolean; message: string } | null;
setTestShoutrrrResult: React.Dispatch<React.SetStateAction<{ success: boolean; message: string } | null>>;
loadSettings: () => void;
saveSettings: (e: React.FormEvent) => Promise<void>;
testEmail: () => Promise<void>;
testShoutrrr: () => Promise<void>;
hasUnsavedChanges: boolean;
}
export function useSettings(): UseSettingsReturn {
const { i18n } = useTranslation();
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [savedSettings, setSavedSettings] = useState<Settings>(defaultSettings);
const [settingsLoading, setSettingsLoading] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null);
const [testingShoutrrr, setTestingShoutrrr] = useState(false);
const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null);
// Load settings function - exposed for manual refresh (e.g., after auth)
const loadSettings = useCallback(() => {
setSettingsLoading(true);
fetch("/api/settings", { credentials: "include" })
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((data) => {
const newSettings = { ...defaultSettings, ...data, smtpPass: "" };
setSettings(newSettings);
setSavedSettings(newSettings);
setSettingsSaved(false);
})
.catch(() => {})
.finally(() => setSettingsLoading(false));
}, []);
// Load settings on mount
useEffect(() => {
loadSettings();
}, [loadSettings]);
// Auto-refresh reminder status (last sent timestamp) every 30 seconds
useEffect(() => {
const refreshReminderStatus = () => {
fetch("/api/settings", { credentials: "include" })
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((data) => {
// Only update the reminder-related fields without triggering unsaved changes
setSettings((prev) => ({
...prev,
lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent,
lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType,
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
}));
setSavedSettings((prev) => ({
...prev,
lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent,
lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType,
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
}));
})
.catch(() => {});
};
const interval = setInterval(refreshReminderStatus, 30000);
return () => clearInterval(interval);
}, []);
const saveSettings = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// Auto-disable email if no recipient is set
const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim();
// Auto-disable push if no URL is set
const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim();
// Validate email if email notifications are enabled
if (effectiveEmailEnabled && settings.notificationEmail) {
const emailRegex = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
if (!emailRegex.test(settings.notificationEmail)) {
setTestEmailResult({ success: false, message: "Invalid email address" });
return;
}
}
setSettingsSaving(true);
setTestEmailResult(null);
const payload = {
emailEnabled: effectiveEmailEnabled,
notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
shoutrrrEnabled: effectiveShoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode,
language: i18n.language,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
smtpUser: settings.smtpUser,
smtpPass: settings.smtpPass || undefined,
smtpFrom: settings.smtpFrom,
smtpSecure: settings.smtpSecure,
};
await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
const updatedSettings = {
...settings,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled,
};
setSettings(updatedSettings);
setSettingsSaving(false);
setSavedSettings(updatedSettings);
setSettingsSaved(true);
},
[settings, i18n.language]
);
const testEmail = useCallback(async () => {
setTestingEmail(true);
setTestEmailResult(null);
try {
const res = await fetch("/api/settings/test-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: settings.notificationEmail }),
});
const data = await res.json();
setTestEmailResult({
success: res.ok,
message: data.message || (res.ok ? "Email sent!" : "Failed to send email"),
});
} catch {
setTestEmailResult({ success: false, message: "Failed to send test email" });
} finally {
setTestingEmail(false);
}
}, [settings.notificationEmail]);
const testShoutrrr = useCallback(async () => {
setTestingShoutrrr(true);
setTestShoutrrrResult(null);
try {
const res = await fetch("/api/settings/test-shoutrrr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: settings.shoutrrrUrl }),
});
const data = await res.json();
setTestShoutrrrResult({
success: res.ok,
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification"),
});
} catch {
setTestShoutrrrResult({ success: false, message: "Failed to send test notification" });
} finally {
setTestingShoutrrr(false);
}
}, [settings.shoutrrrUrl]);
// Check for unsaved changes
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings);
return {
settings,
setSettings,
savedSettings,
settingsLoading,
settingsSaving,
settingsSaved,
testingEmail,
testEmailResult,
setTestEmailResult,
testingShoutrrr,
testShoutrrrResult,
setTestShoutrrrResult,
loadSettings,
saveSettings,
testEmail,
testShoutrrr,
hasUnsavedChanges,
};
}

Some files were not shown because too many files have changed in this diff Show More