Compare commits

...

15 Commits

Author SHA1 Message Date
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
Daniel Volz 82b2be48cd feat: Add Medication Refill feature with mobile UI improvements (#30)
* feat: Add Medication Refill feature with UI improvements

- Add refill functionality to medications (add packs/loose pills)
- Add refill API endpoint with history tracking
- Add refill section in edit forms (desktop & mobile)
- Add refill modal in medication detail view
- Add refill history display with expand/collapse
- Add schedule lightbox for clicking medication images
- Improve button styling with primary/info/success classes
- Move '+ New entry' button to medication list header
- Lightbox size: 50% desktop, 90% mobile
- Update selectedMed sync after stock changes
- Migrate from schema-sql.ts to Drizzle Kit migrations

* fix: Improve mobile tooltips and refill modal layout

- Center tooltips on screen for mobile devices (fixed position)
- Close tooltips automatically when scrolling on touch devices
- Use click-based tooltip activation instead of hover on mobile
- Fix refill modal buttons to display in two rows on mobile
2026-01-17 20:39:18 +01:00
Daniel Volz 269a549563 docs: update release notes guidelines - less is more (#29)
- Add 'Keep it short' principle
- Add DO NOT include list (no technical details)
- Simplify example release notes
- Remove verbose example with implementation details
2026-01-16 22:04:34 +01:00
Daniel Volz 055c0dfe10 feat: Add Clear Missed Doses feature (#28)
- Add dismissed column to dose_tracking table schema
- Add POST /doses/dismiss endpoint for batch dismissing
- Add DELETE /doses/dismiss endpoint to un-dismiss all
- Add frontend dismissedDoses state and missedPastDoseIds useMemo
- Add Clear missed button with confirmation dialog
- Add CSS styles for .past-days-header and .clear-missed-btn
- Add i18n translations for en/de
- Add 5 tests for dismiss endpoints
- Update test schemas with dismissed column

Allows users to acknowledge missed doses without deducting stock.
Closes #28
2026-01-16 21:56:35 +01:00
Daniel Volz 318f63657b docs: add Pushover and improve push notification documentation (#27)
- Add Pushover to supported services list in UI
- Add Gotify to supported services list
- Add URL placeholder with examples (ntfy, pushover)
- Add link to shoutrrr.dev for all available services
- Change input type from 'url' to 'text' (shoutrrr URLs aren't HTTP URLs)
- Add comprehensive Push Notifications section to README
- Include URL examples for ntfy, Pushover, Gotify, Discord, Telegram

Closes feature request for Pushover support.
2026-01-16 21:05:40 +01:00
Daniel Volz 718157e472 fix: clean up Export/Import UI (#26)
- Fix tooltip visibility (overflow: visible for export card)
- Remove unnecessary 'Include sensitive data' checkbox
- Always export all data including notification URLs
- Remove unused CSS styles for checkbox and warning
2026-01-16 20:50:29 +01:00
Daniel Volz f00f11aa55 docs: add rule to never release without explicit permission (#25) 2026-01-16 20:35:25 +01:00
Daniel Volz 4081e03970 fix(ui): improve Export/Import section layout (#24)
* fix(ui): improve Export/Import section layout and styling

- Redesign as two-column card layout with icons
- Remove CAPSLOCK from labels
- Add proper descriptions for export and import sections
- Improve checkbox and button styling
- Make responsive for mobile

* fix(ui): clean up Export/Import section design

- Remove ugly folder icons
- Replace hint text box with info tooltip on title
- Cleaner h3 styling with uppercase letters
- Better visual hierarchy
2026-01-16 20:32:39 +01:00
Daniel Volz 9cfbf89d46 fix: correct release workflow to find previous tag (#23) 2026-01-16 20:06:29 +01:00
Daniel Volz ffab9ef4da feat: Add data export/import functionality (#22)
* feat: add data export/import functionality

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

* fix: correct JSX structure and TypeScript types

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

* docs: translate copilot-instructions to English

- Add explicit rule that English is the primary language
- Translate all German sections to English
- User may communicate in German, but all project artifacts must be English
2026-01-16 19:59:48 +01:00
Daniel Volz ed707444a2 chore: release v1.1.0 (#19) 2026-01-10 21:29:53 +01:00
40 changed files with 6512 additions and 737 deletions
+206 -96
View File
@@ -2,6 +2,8 @@
## General Rules
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
- **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.
- **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.
@@ -46,25 +48,25 @@ cd backend && npm run test:coverage # Run with coverage report
## Testing (MANDATORY)
> ⚠️ **WICHTIG**: Jede neue Funktionalität MUSS mit Tests abgedeckt werden!
> Pull Requests ohne Tests für neue Features werden nicht akzeptiert.
> ⚠️ **IMPORTANT**: Every new feature MUST be covered by tests!
> Pull Requests without tests for new features will not be accepted.
### Test-Framework
- **Vitest 2.1** mit v8 Coverage
### Test Framework
- **Vitest 2.1** with v8 Coverage
- Tests in `backend/src/test/*.test.ts`
- Coverage-Ziel: Mindestens gleiche oder bessere Coverage nach Änderungen
- Coverage goal: At least equal or better coverage after changes
### Test-Struktur
| Datei | Testet |
|-------|--------|
| `routes.test.ts` | API-Endpunkte (Auth, Medications, Doses, Settings, Share, Planner) |
| `services.test.ts` | Scheduler-Utilities (Timezone, Blisters, Usage-Berechnung) |
| `db.test.ts` | Datenbank-Schema und Operationen |
### Test Structure
| File | Tests |
|------|-------|
| `routes.test.ts` | API endpoints (Auth, Medications, Doses, Settings, Share, Planner) |
| `services.test.ts` | Scheduler utilities (Timezone, Blisters, Usage calculation) |
| `db.test.ts` | Database schema and operations |
### Tests schreiben
### Writing Tests
```typescript
// Backend Test Beispiel (backend/src/test/example.test.ts)
// Backend Test Example (backend/src/test/example.test.ts)
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
@@ -95,24 +97,24 @@ describe('Feature Name', () => {
});
```
### Test-Commands
### Test Commands
```bash
cd backend
CI=true npm test # Tests einmal ausführen (IMMER so ausführen!)
CI=true npm run test:coverage # Mit Coverage-Report
npm test -- --watch # Watch-Mode für manuelle Entwicklung
npm test -- -t "test name" # Einzelnen Test ausführen
CI=true npm test # Run tests once (ALWAYS run this way!)
CI=true npm run test:coverage # With coverage report
npm test -- --watch # Watch mode for manual development
npm test -- -t "test name" # Run single test
```
> ⚠️ **WICHTIG für AI-Agenten**: Tests IMMER mit `CI=true` ausführen!
> Ohne `CI=true` läuft Vitest im Watch-Mode und wartet auf Eingaben.
> ⚠️ **IMPORTANT for AI agents**: ALWAYS run tests with `CI=true`!
> Without `CI=true`, Vitest runs in watch mode and waits for input.
## CI/CD Pipeline (GitHub Actions)
### Workflow-Übersicht
### Workflow Overview
```
Pull Request erstellt
Pull Request created
┌─────────────────────────────────────┐
│ test.yml │
@@ -124,65 +126,161 @@ Pull Request erstellt
│ ├─ npm ci │
│ └─ npm run build │
└─────────────────────────────────────┘
↓ Tests müssen bestehen
PR kann gemerged werden
↓ Tests must pass
PR can be merged
Push to main / Tag erstellt
Push to main / Tag created
┌─────────────────────────────────────┐
│ docker-build.yml │
│ ├─ backend-test (parallel) │
│ ├─ frontend-build (parallel) │
│ └─ build-and-push (nach Tests)
│ ├─ Docker Images bauen
│ └─ Push zu GHCR │
│ └─ build-and-push (after tests) │
│ ├─ Build Docker images │
│ └─ Push to GHCR │
└─────────────────────────────────────┘
```
### Branch Protection
> ⚠️ **WICHTIG**: Der `main` Branch ist geschützt!
> Direktes Pushen nach `main` ist **nicht möglich** - GitHub lehnt den Push ab.
> Alle Änderungen müssen über Pull Requests erfolgen.
> ⚠️ **IMPORTANT**: The `main` branch is protected!
> Direct pushing to `main` is **not possible** - GitHub will reject the push.
> All changes must go through Pull Requests.
- **main** Branch ist geschützt (Repository Rules)
- Direktes Pushen wird von GitHub abgelehnt mit: `GH013: Repository rule violations`
- PRs benötigen:
-`backend-test` Status Check bestanden
-`frontend-build` Status Check bestanden
- Nach erfolgreichem Merge wird der Feature-Branch automatisch gelöscht
- **main** branch is protected (Repository Rules)
- Direct pushing is rejected by GitHub with: `GH013: Repository rule violations`
- PRs require:
-`backend-test` Status Check passed
-`frontend-build` Status Check passed
- After successful merge, the feature branch is automatically deleted
**Workflow für Änderungen:**
**Workflow for changes:**
```bash
# 1. Feature Branch erstellen
git checkout -b feat/mein-feature
# 1. Create feature branch
git checkout -b feat/my-feature
# 2. Änderungen committen und pushen
git add . && git commit -m "feat: Beschreibung"
git push -u origin feat/mein-feature
# 2. Commit and push changes
git add . && git commit -m "feat: Description"
git push -u origin feat/my-feature
# 3. PR erstellen (via GitHub CLI oder Web)
gh pr create --title "Mein Feature" --body "Beschreibung"
# 3. Create PR (via GitHub CLI or Web)
gh pr create --title "My Feature" --body "Description"
# 4. Warten bis CI grün ist, dann mergen
# 4. Wait until CI is green, then merge
gh pr merge --squash --delete-branch
```
### Workflow-Dateien
| Datei | Trigger | Zweck |
|-------|---------|-------|
| `.github/workflows/test.yml` | Pull Requests | Tests ausführen, PR blockieren bei Fehlern |
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Docker Images bauen und pushen |
### Workflow Files
| File | Trigger | Purpose |
|------|---------|--------|
| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images |
### Neuen Code hinzufügen - Checkliste
1.Feature implementieren
2.Tests für das Feature schreiben
3.Lokal `npm run test:coverage` ausführen
4. ✅ Coverage darf nicht sinken
5.Feature Branch erstellen und pushen
6. ✅ Pull Request erstellen
7. ✅ Warten bis CI grün ist
8.PR mergen (Branch wird automatisch gelöscht)
### Adding New Code - Checklist
1.Implement feature
2.Write tests for the feature
3.Run `npm run test:coverage` locally
4. ✅ Coverage must not decrease
5.Create and push feature branch
6. Create Pull Request
7. ✅ Wait until CI is green
8.Merge PR (branch is automatically deleted)
## GitHub Releases
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
### Creating Release Notes
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
> Not just auto-generated commit lists, but a brief descriptive text.
**Keep it informative but concise.** Users want to know what changed and where to find it.
**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, database changes)
- ❌ Number of tests added
- ❌ Internal API changes (unless breaking)
- ❌ Excessive emoji on every bullet point
**Example of good release notes:**
```markdown
## What's New
This release introduces a medication refill tracking feature and improves the mobile user experience.
### 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!)
> ⚠️ **MANDATORY**: If an update breaks existing configurations or stored data, it MUST be prominently warned about in the release notes!
**Breaking Changes include:**
- Database schema changes without automatic migration
- Removed or renamed ENV variables
- Changed API endpoints
- Incompatible `.env` format changes
- Loss of stored data after update
**Format for Breaking Changes:**
```markdown
## ⚠️ BREAKING CHANGES - Please read before updating!
**Database migration required**: This update changes the database schema.
Existing installations need to:
1. Create backup of `data/` folder
2. Stop containers
3. Perform update
4. If issues occur: Rollback using backup
**ENV variables changed**:
- `OLD_VAR` was renamed to `NEW_VAR`
- `REMOVED_VAR` is no longer supported
**Medication data**: Intake schedules with only one time entry will be automatically
migrated. Please verify all times are correct after update.
```
**What is NOT a Breaking Change:**
- ✅ New optional columns with DEFAULT values
- ✅ New ENV variables (with sensible defaults)
- ✅ New features that don't affect existing data
- ✅ Bug fixes that correct behavior
**Rule of thumb**: If a user can simply run `docker compose pull && docker compose up -d`
without adjusting anything → Not a Breaking Change.
## Key Patterns
@@ -371,54 +469,64 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
## Database Schema Changes (WICHTIG: Abwärtskompatibilität!)
## Database Schema Changes (IMPORTANT: Backward Compatibility!)
> ⚠️ **KRITISCH**: Die App MUSS abwärtskompatibel mit älteren Datenbanken bleiben!
> Nutzer upgraden ihre Docker-Container, aber behalten ihre bestehende DB.
> Die App darf NICHT abstürzen wenn alte Spalten fehlen.
> ⚠️ **CRITICAL**: The app MUST remain backward compatible with older databases!
> Users upgrade their Docker containers but keep their existing DB.
> The app must NOT crash if old columns are missing.
### Regeln für neue Spalten
### Schema Management with Drizzle Kit
1. **IMMER mit DEFAULT-Wert**: Neue Spalten müssen `NOT NULL DEFAULT <wert>` haben
2. **NULL-sicher im Code**: Alle Abfragen müssen `?? defaultValue` oder `?? false` verwenden
3. **Schema-SQL aktualisieren**: In diesen Dateien hinzufügen:
- `backend/src/db/schema.ts` - Drizzle Schema
- `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` für neue DBs
- `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` Migration
4. **Test-Schemas updaten**: Alle Test-Dateien mit eigenem Schema:
- `backend/src/test/e2e-routes.test.ts`
- `backend/src/test/integration.test.ts`
- `backend/src/test/planner.test.ts`
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
### Beispiel: Neue Spalte hinzufügen
- **`backend/src/db/schema.ts`** - Drizzle ORM schema definitions (TypeScript)
- **`backend/drizzle/`** - Generated SQL migrations (auto-generated from schema.ts)
```typescript
// 1. schema.ts - Drizzle Definition
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
**DO NOT manually edit migration files!** They are generated from schema.ts.
// 2. schema-sql.ts - Für neue Datenbanken
"max_nagging_reminders integer NOT NULL DEFAULT 5,"
### Adding New Columns
// 3. client.ts - Migration für bestehende DBs (IN ensureTablesExist())
await client.execute(`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`).catch(() => {});
1. **Add to schema.ts** with DEFAULT value:
```typescript
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
```
// 4. Routes - NULL-sicher lesen
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
```
2. **Generate migration**:
```bash
cd backend && npx drizzle-kit generate --name add_column_name
```
### Was NICHT erlaubt ist
3. **Add backward-compatible ALTER migration** in `client.ts` `runAlterMigrations()`:
```typescript
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
```
- ❌ Spalten löschen oder umbenennen (bricht alte DBs)
-`NOT NULL` ohne `DEFAULT` (INSERT schlägt fehl)
- ❌ Spalten ohne Fallback im Code lesen
- ❌ DB löschen als "Lösung" dokumentieren
4. **NULL-safe reading** in routes:
```typescript
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
```
### Wann Abwärtskompatibilität NICHT möglich ist
### Rules for New Columns
Wenn eine Breaking Change unvermeidbar ist:
1. **Explizit kommunizieren**: In Release Notes dokumentieren
2. **Migration-Script**: Automatisches Upgrade-Script bereitstellen
3. **Versionsprüfung**: App sollte DB-Version prüfen und warnen
1. **ALWAYS with DEFAULT value**: New columns must have `NOT NULL DEFAULT <value>`
2. **NULL-safe in code**: All queries must use `?? defaultValue` or `?? false`
3. **Generate migration**: Run `npx drizzle-kit generate` after schema changes
4. **Add ALTER migration**: For backward compatibility with existing DBs
### What is NOT Allowed
- ❌ Deleting or renaming columns (breaks old DBs)
- ❌ `NOT NULL` without `DEFAULT` (INSERT fails)
- ❌ Reading columns without fallback in code
- ❌ Manually editing migration SQL files
- ❌ Documenting "delete DB" as a solution
### When Backward Compatibility is NOT Possible
If a breaking change is unavoidable:
1. **Explicitly communicate**: Document in release notes
2. **Migration script**: Provide automatic upgrade script
3. **Version check**: App should check DB version and warn
## File Locations
@@ -426,6 +534,8 @@ Wenn eine Breaking Change unvermeidbar ist:
|---------|----------|
| Backend entry | `backend/src/index.ts` |
| Database schema | `backend/src/db/schema.ts` |
| Drizzle migrations | `backend/drizzle/*.sql` |
| Drizzle config | `backend/drizzle.config.ts` |
| Backend routes | `backend/src/routes/*.ts` |
| Backend services | `backend/src/services/*.ts` |
| Frontend app | `frontend/src/App.tsx` |
+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)
+17 -3
View File
@@ -16,11 +16,25 @@ jobs:
with:
fetch-depth: 0
- name: Get previous tag
id: prev_tag
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)
# If no previous tag found (first release), use empty
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
run: |
# Get previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
PREV_TAG="${{ steps.prev_tag.outputs.previous_tag }}"
if [ -z "$PREV_TAG" ]; then
# First release - get all commits
@@ -37,6 +51,6 @@ jobs:
uses: softprops/action-gh-release@v1
with:
body_path: changelog.txt
generate_release_notes: true
generate_release_notes: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+135 -1
View File
@@ -28,6 +28,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 +39,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,9 +141,14 @@
- 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, Gotify, Telegram, Discord (Shoutrrr)
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
- Supports both stock warnings and intake reminders
### Privacy & Security
@@ -148,6 +234,54 @@ Generate secrets with: `openssl rand -hex 32`
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
### Push Notifications (Shoutrrr)
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
Configure push notifications in Settings → Push, or set defaults via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
#### URL Examples
**ntfy** (free, self-hostable):
```
ntfy://ntfy.sh/your-topic
ntfy://user:password@your-server.com/topic
```
**Pushover** (free app for iOS/Android):
```
pushover://shoutrrr:API_TOKEN@USER_KEY/
```
Get your keys at [pushover.net](https://pushover.net/):
- **User Key**: Shown on your dashboard (top right)
- **API Token**: Create an application → copy the API Token
**Gotify** (self-hosted):
```
gotify://your-server.com/TOKEN
```
**Discord**:
```
discord://TOKEN@WEBHOOK_ID
```
**Telegram**:
```
telegram://TOKEN@telegram?chats=CHAT_ID
```
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
# Development
```bash
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL || "./data/medassist.db",
},
});
+112
View File
@@ -0,0 +1,112 @@
CREATE TABLE `dose_tracking` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_id` text(255) NOT NULL,
`taken_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`marked_by` text(100),
`dismissed` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `medications` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`generic_name` text(100),
`taken_by_json` text DEFAULT '[]' NOT NULL,
`pack_count` integer DEFAULT 1 NOT NULL,
`blisters_per_pack` integer DEFAULT 1 NOT NULL,
`pills_per_blister` integer DEFAULT 1 NOT NULL,
`loose_tablets` integer DEFAULT 0 NOT NULL,
`pill_weight_mg` integer,
`usage_json` text DEFAULT '[]' NOT NULL,
`every_json` text DEFAULT '[]' NOT NULL,
`start_json` text DEFAULT '[]' NOT NULL,
`image_url` text,
`expiry_date` text,
`notes` text,
`intake_reminders_enabled` integer DEFAULT false NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `refill_history` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`medication_id` integer NOT NULL,
`user_id` integer NOT NULL,
`packs_added` integer DEFAULT 0 NOT NULL,
`loose_pills_added` integer DEFAULT 0 NOT NULL,
`refill_date` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `refresh_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token_id` text(255) NOT NULL,
`expires_at` integer NOT NULL,
`rotated_at` integer,
`revoked` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `refresh_tokens_token_id_unique` ON `refresh_tokens` (`token_id`);--> statement-breakpoint
CREATE TABLE `share_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token` text(64) NOT NULL,
`taken_by` text(100) NOT NULL,
`schedule_days` integer DEFAULT 30 NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`expires_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `share_tokens_token_unique` ON `share_tokens` (`token`);--> statement-breakpoint
CREATE TABLE `user_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`email_enabled` integer DEFAULT false NOT NULL,
`notification_email` text,
`email_stock_reminders` integer DEFAULT true NOT NULL,
`email_intake_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_enabled` integer DEFAULT false NOT NULL,
`shoutrrr_url` text,
`shoutrrr_stock_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_intake_reminders` integer DEFAULT true NOT NULL,
`reminder_days_before` integer DEFAULT 7 NOT NULL,
`repeat_daily_reminders` integer DEFAULT false NOT NULL,
`skip_reminders_for_taken_doses` integer DEFAULT false NOT NULL,
`repeat_reminders_enabled` integer DEFAULT false NOT NULL,
`reminder_repeat_interval_minutes` integer DEFAULT 30 NOT NULL,
`max_nagging_reminders` integer DEFAULT 5 NOT NULL,
`low_stock_days` integer DEFAULT 30 NOT NULL,
`normal_stock_days` integer DEFAULT 90 NOT NULL,
`high_stock_days` integer DEFAULT 180 NOT NULL,
`expiry_warning_days` integer DEFAULT 90 NOT NULL,
`language` text(10) DEFAULT 'en' NOT NULL,
`stock_calculation_mode` text(20) DEFAULT 'automatic' NOT NULL,
`last_auto_email_sent` text,
`last_notification_type` text,
`last_notification_channel` text,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_settings_user_id_unique` ON `user_settings` (`user_id`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text(100) NOT NULL,
`password_hash` text(255),
`avatar_url` text(255),
`auth_provider` text(50) DEFAULT 'local' NOT NULL,
`oidc_subject` text(255),
`is_active` integer DEFAULT true NOT NULL,
`last_login_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+819
View File
@@ -0,0 +1,819 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
"prevId": "00000000-0000-0000-0000-000000000000",
"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
},
"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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1768600500759,
"tag": "0000_init",
"breakpoints": true
}
]
}
+1009 -19
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.0.2",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
@@ -24,7 +24,7 @@
"@libsql/client": "^0.10.0",
"argon2": "^0.40.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.32.2",
"drizzle-orm": "^0.45.1",
"fastify": "^5.0.0",
"nodemailer": "^7.0.11",
"openid-client": "^6.8.1",
@@ -35,6 +35,7 @@
"@types/nodemailer": "^6.4.21",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^4.0.16",
"drizzle-kit": "^0.31.8",
"supertest": "^7.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
+34 -19
View File
@@ -1,12 +1,18 @@
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
import { resolve } from "path";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
import { getTableCreationSQL } from "./schema-sql.js";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// =============================================================================
// Exported utility functions for testing
// =============================================================================
@@ -44,23 +50,20 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
}
}
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
export { getTableCreationSQL } from "./schema-sql.js";
/** Run drizzle-kit migrations on the database */
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
try {
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
/** Run table creation migrations on a client */
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const tableCreations = getTableCreationSQL();
/** Run ALTER TABLE migrations for backward compatibility with older databases */
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const errors: string[] = [];
for (const sql of tableCreations) {
try {
await client.execute(sql);
} catch (e: any) {
errors.push(e.message);
}
}
// Run ALTER TABLE migrations for backward compatibility with older databases
// These add new columns to existing tables (silently fail if column already exists)
const alterMigrations = [
// Added in v1.x - repeat reminders and nagging settings
@@ -68,6 +71,8 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
// Added in v1.2.3 - dismiss missed doses without deducting stock
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
@@ -147,9 +152,19 @@ export const db = drizzle(client);
// Auto-run migrations (self-healing database)
async function runMigrations() {
const result = await runTableMigrations(client);
if (result.errors.length > 0) {
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
// Run drizzle-kit generated migrations
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
const migrateResult = await runDrizzleMigrations(db);
if (!migrateResult.success) {
console.error(`[DB] Migration error:`, migrateResult.error);
} else {
console.log(`[DB] Drizzle migrations completed`);
}
// 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));
}
console.log(`[DB] Tables verified/created`);
+29 -25
View File
@@ -1,39 +1,45 @@
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { getTableCreationSQL } from "./schema-sql.js";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// =============================================================================
// Exported utility functions for testing
// =============================================================================
/** Get the full migration SQL string (re-exported from schema-sql) */
export { getTableCreationSQL };
/** Split SQL string into individual statements */
/** 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);
}
/** Execute migration statements on a client */
/** Execute drizzle migrations on a database */
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
const statements = getTableCreationSQL();
const errors: string[] = [];
let executed = 0;
const db = drizzle(client);
for (const stmt of statements) {
try {
await client.execute(stmt);
executed++;
} catch (err: any) {
errors.push(err.message);
}
try {
await migrate(db, { migrationsFolder });
// Count tables as a proxy for "executed" statements
const tables = await client.execute(
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
);
const executed = Number(tables.rows[0].count) || 0;
return { success: true, executed, errors };
} catch (err: any) {
errors.push(err.message);
return { success: false, executed: 0, errors };
}
return { success: errors.length === 0, executed, errors };
}
/** Get a preview of statement (first N characters) */
@@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db";
async function main() {
console.log("Starting database setup...");
console.log("Database URL:", url);
console.log("Migrations folder:", migrationsFolder);
const client = createClient({ url });
const db = drizzle(client);
const statements = getTableCreationSQL();
for (const stmt of statements) {
console.log("Executing:", getStatementPreview(stmt));
await client.execute(stmt);
}
console.log("Running drizzle migrations...");
await migrate(db, { migrationsFolder });
console.log("Database setup complete!");
process.exit(0);
+11
View File
@@ -97,6 +97,17 @@ export function getTableCreationSQL(): string[] {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
medication_id integer NOT NULL,
user_id integer NOT NULL,
packs_added integer NOT NULL DEFAULT 0,
loose_pills_added integer NOT NULL DEFAULT 0,
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
+14
View File
@@ -68,6 +68,7 @@ export const userSettings = sqliteTable("user_settings", {
lowStockDays: integer("low_stock_days").notNull().default(30),
normalStockDays: integer("normal_stock_days").notNull().default(90),
highStockDays: integer("high_stock_days").notNull().default(180),
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
// UI preferences
language: text("language", { length: 10 }).notNull().default("en"),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
@@ -115,4 +116,17 @@ export const doseTracking = sqliteTable("dose_tracking", {
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
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
});
// =============================================================================
// Refill History - Tracks when medication stock was refilled
// =============================================================================
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" }),
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'))`),
});
+6
View File
@@ -19,6 +19,8 @@ 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 { refillRoutes } from "./routes/refills.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
@@ -113,6 +115,8 @@ export async function createApp(options?: {
await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
return app;
}
@@ -181,6 +185,8 @@ await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
const start = async () => {
try {
+104 -1
View File
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, shareTokens } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: any, reply: any): Promise<number> {
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
dismissed: d.dismissed ?? false,
})),
};
}
@@ -127,6 +132,103 @@ export async function doseRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
"/doses/dismiss",
{ preHandler: requireAuth },
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = dismissDosesSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
});
}
const { doseIds } = parsed.data;
// Insert dismissed records for each dose that doesn't exist yet
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists (taken or dismissed)
const [existing] = await db.select()
.from(doseTracking)
.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)
.set({ dismissed: true })
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
dismissedCount++;
}
} else {
// Create new dismissed record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null,
dismissed: true,
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
}
);
// ---------------------------------------------------------------------------
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
// ---------------------------------------------------------------------------
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()
.from(doseTracking)
.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));
} else {
// This was only dismissed - delete it
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
// ---------------------------------------------------------------------------
@@ -151,6 +253,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
dismissed: d.dismissed ?? false,
})),
};
}
+499
View File
@@ -0,0 +1,499 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, userSettings, doseTracking, shareTokens } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { resolve, extname } from "path";
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.0";
// =============================================================================
// Zod Schemas for Import Validation
// =============================================================================
const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
remind: z.boolean().optional().default(false),
});
const inventorySchema = z.object({
packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
});
const medicationExportSchema = z.object({
_exportId: z.string(),
name: z.string().min(1),
genericName: z.string().nullable().optional(),
takenBy: z.array(z.string()).default([]),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
schedules: z.array(scheduleSchema).default([]),
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
image: z.string().nullable().optional(), // base64 data URL or null
});
const doseHistorySchema = z.object({
medicationRef: z.string(), // References _exportId
scheduleIndex: z.number().int().min(0),
scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(),
});
const shareLinkSchema = z.object({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
expiresAt: z.string().nullable().optional(), // ISO datetime
regenerateToken: z.boolean().default(true),
});
const settingsExportSchema = z.object({
// Email notifications
emailEnabled: z.boolean().default(false),
notificationEmail: z.string().nullable().optional(),
emailStockReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true),
// Push notifications
shoutrrrEnabled: z.boolean().optional(),
shoutrrrUrl: z.string().nullable().optional(),
shoutrrrStockReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true),
// Reminder settings
reminderDaysBefore: z.number().int().default(7),
repeatDailyReminders: z.boolean().default(false),
skipRemindersForTakenDoses: z.boolean().default(false),
repeatRemindersEnabled: z.boolean().default(false),
reminderRepeatIntervalMinutes: z.number().int().default(30),
maxNaggingReminders: z.number().int().default(5),
// Stock thresholds
lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180),
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
}).optional();
const importDataSchema = z.object({
version: z.string(),
exportedAt: z.string(),
includeSensitiveData: z.boolean().default(false),
medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]),
settings: settingsExportSchema,
shareLinks: z.array(shareLinkSchema).default([]),
});
// =============================================================================
// Helper Functions
// =============================================================================
// Helper to get user ID from request
async function getUserId(request: any, reply: any): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// Parse takenByJson safely
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
// Parse blisters from DB format to export format
function parseBlistersForExport(row: typeof medications.$inferSelect): Array<{ usage: number; every: number; start: string; remind: boolean }> {
try {
const usage = JSON.parse(row.usageJson || "[]") as number[];
const every = JSON.parse(row.everyJson || "[]") as number[];
const start = JSON.parse(row.startJson || "[]") as string[];
const len = Math.min(usage.length, every.length, start.length);
const schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = [];
for (let i = 0; i < len; i++) {
schedules.push({
usage: usage[i],
every: every[i],
start: start[i],
remind: row.intakeRemindersEnabled ?? false,
});
}
return schedules;
} catch {
return [];
}
}
// Read image file and convert to base64 data URL
function imageToBase64(imageUrl: string | null): string | null {
if (!imageUrl) return null;
const imagePath = resolve(IMAGES_DIR, imageUrl);
if (!existsSync(imagePath)) return null;
try {
const imageBuffer = readFileSync(imagePath);
const ext = extname(imageUrl).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
};
const mimeType = mimeTypes[ext] || "image/jpeg";
return `data:${mimeType};base64,${imageBuffer.toString("base64")}`;
} catch {
return null;
}
}
// Save base64 image to file and return filename
function base64ToImage(base64: string, medicationId: number): string | null {
if (!base64 || !base64.startsWith("data:")) return null;
try {
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
const matches = base64.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) return null;
const ext = matches[1] === "jpeg" ? "jpg" : matches[1];
const data = matches[2];
const buffer = Buffer.from(data, "base64");
const filename = `med-${medicationId}-${Date.now()}.${ext}`;
const filepath = resolve(IMAGES_DIR, filename);
// Ensure images directory exists
if (!existsSync(IMAGES_DIR)) {
mkdirSync(IMAGES_DIR, { recursive: true });
}
writeFileSync(filepath, buffer);
return filename;
} catch {
return null;
}
}
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}"
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number } | null {
const parts = doseId.split("-");
if (parts.length < 3) return null;
const medicationId = parseInt(parts[0], 10);
const blisterIndex = parseInt(parts[1], 10);
const timestampMs = parseInt(parts[2], 10);
if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null;
return { medicationId, blisterIndex, timestampMs };
}
// Build dose ID from parts
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number): string {
return `${medicationId}-${blisterIndex}-${timestampMs}`;
}
// =============================================================================
// Export Routes
// =============================================================================
export async function exportRoutes(app: FastifyInstance) {
// All export routes require auth
app.addHook("preHandler", requireAuth);
// ---------------------------------------------------------------------------
// GET /export - Export all user data
// ---------------------------------------------------------------------------
app.get<{ Querystring: { includeSensitive?: string } }>(
"/export",
async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
return {
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
looseTablets: med.looseTablets ?? 0,
},
pillWeightMg: med.pillWeightMg,
schedules: parseBlistersForExport(med),
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
image: imageToBase64(med.imageUrl),
};
});
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const exportDoseHistory = doses.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: new Date(parsed.timestampMs).toISOString(),
takenAt: dose.takenAt?.toISOString() ?? new Date().toISOString(),
markedBy: dose.markedBy,
};
}).filter((d): d is NonNullable<typeof d> => d !== null);
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const exportSettings = settings ? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
} : undefined;
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => ({
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt?.toISOString() ?? null,
regenerateToken: true, // Always regenerate tokens on import for security
}));
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// Set download headers
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return exportData;
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
app.post(
"/import",
async (request, reply) => {
const userId = await getUserId(request, reply);
// 1. Parse and validate import data
const parsed = importDataSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid import data format",
details: parsed.error.format(),
});
}
const importData = parsed.data;
// 2. Delete all existing user data (in correct order to respect foreign keys)
// Note: CASCADE delete should handle this, but let's be explicit
// First, delete images for existing medications
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
for (const med of existingMeds) {
if (med.imageUrl) {
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
if (existsSync(imagePath)) {
try { unlinkSync(imagePath); } catch { /* ignore */ }
}
}
}
// Delete in order: doses, share tokens, medications, settings
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
await db.delete(medications).where(eq(medications.userId, userId));
await db.delete(userSettings).where(eq(userSettings.userId, userId));
// 3. Import medications and build ID mapping
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
// Convert schedules back to JSON arrays
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
const takenByJson = JSON.stringify(med.takenBy);
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
const [inserted] = await db.insert(medications).values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
pillWeightMg: med.pillWeightMg || null,
usageJson,
everyJson,
startJson,
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
imageUrl: null, // Will be set after image is saved
}).returning();
// Save mapping
exportIdToNewId.set(med._exportId, inserted.id);
// Save image if present
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
await db.update(medications)
.set({ imageUrl })
.where(eq(medications.id, inserted.id));
}
}
}
// 4. Import dose history with remapped medication IDs
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue; // Skip orphaned doses
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
});
}
// 5. Import settings
if (importData.settings) {
await db.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
});
}
// 6. Import share links (with new tokens)
for (const share of importData.shareLinks) {
// Always generate new token for security
const token = randomBytes(8).toString("hex");
await db.insert(shareTokens).values({
userId,
token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
return {
success: true,
imported: {
medications: importData.medications.length,
doseHistory: importData.doseHistory.length,
settings: importData.settings ? 1 : 0,
shareLinks: importData.shareLinks.length,
},
};
}
);
}
+124
View File
@@ -0,0 +1,124 @@
import { FastifyInstance } 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 { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
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, {
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> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// POST /medications/:id/refill - Add stock to medication
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
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))
);
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded } = parsed.data;
// Update medication stock
const newPackCount = med.packCount + packsAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded;
await db.update(medications)
.set({
packCount: newPackCount,
looseTablets: newLooseTablets,
updatedAt: new Date(),
})
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
// Create refill history entry
const [refill] = await db.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded,
loosePillsAdded,
})
.returning();
// Calculate pills added for response
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = (packsAdded * pillsPerPack) + loosePillsAdded;
return {
success: true,
refill: {
id: refill.id,
packsAdded,
loosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newPackCount * pillsPerPack + newLooseTablets,
},
};
});
// GET /medications/:id/refills - Get refill history for a medication
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
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))
);
if (!med) return reply.notFound("Medication not found");
// Get refill history, newest first
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 => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: (r.packsAdded * pillsPerPack) + r.loosePillsAdded,
refillDate: r.refillDate,
}));
});
}
+201 -461
View File
@@ -1,45 +1,78 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
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 } from "path";
import { resolve, dirname } from "path";
import { tmpdir } from "os";
import { fileURLToPath } from "url";
// Import the exported utility functions from client.ts
import {
buildDbUrl,
getDbPaths,
ensureDataDirectory,
getTableCreationSQL,
runTableMigrations,
runDrizzleMigrations,
runAlterMigrations,
ensureDefaultUser,
} from "../db/client.js";
// Import the exported utility functions from migrate.ts
import {
getTableCreationSQL as getTableCreationSQLFromMigrate,
splitSQLStatements,
executeMigration,
getStatementPreview,
} from "../db/migrate.js";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
describe("Migration Script Utilities", () => {
describe("getTableCreationSQL", () => {
it("should return a non-empty array of SQL statements", () => {
const statements = getTableCreationSQL();
expect(Array.isArray(statements)).toBe(true);
expect(statements.length).toBeGreaterThan(0);
describe("executeMigration", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
client = createClient({ url: ":memory:" });
});
it("should contain all table definitions", () => {
const statements = getTableCreationSQL();
const allSQL = statements.join(" ");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking");
it("should execute all migrations successfully", async () => {
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBeGreaterThan(0);
expect(result.errors).toHaveLength(0);
});
it("should create all tables", async () => {
await executeMigration(client);
const tables = await client.execute(
"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);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
expect(tableNames).toContain("refill_history");
});
it("should be idempotent", async () => {
await executeMigration(client);
const result = await executeMigration(client);
expect(result.success).toBe(true);
});
it("should allow inserting data after migration", async () => {
await executeMigration(client);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT * FROM users");
expect(result.rows).toHaveLength(1);
});
});
@@ -62,11 +95,6 @@ describe("Migration Script Utilities", () => {
expect(statements).toHaveLength(2);
});
it("should handle getTableCreationSQL output correctly", () => {
const statements = getTableCreationSQL();
expect(statements).toHaveLength(6);
});
it("should preserve whitespace within statements", () => {
const sql = "CREATE TABLE test (\n id INTEGER\n);";
const statements = splitSQLStatements(sql);
@@ -103,52 +131,6 @@ describe("Migration Script Utilities", () => {
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
});
});
describe("executeMigration", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
client = createClient({ url: ":memory:" });
});
it("should execute all migrations successfully", async () => {
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBe(6);
expect(result.errors).toHaveLength(0);
});
it("should create all tables", async () => {
await executeMigration(client);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
});
it("should be idempotent", async () => {
await executeMigration(client);
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBe(6);
});
it("should allow inserting data after migration", async () => {
await executeMigration(client);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT * FROM users");
expect(result.rows).toHaveLength(1);
});
});
});
describe("Database Client Utilities", () => {
@@ -218,63 +200,7 @@ describe("Database Client Utilities", () => {
});
});
describe("getTableCreationSQL", () => {
it("should return array of SQL statements", () => {
const statements = getTableCreationSQL();
expect(Array.isArray(statements)).toBe(true);
expect(statements.length).toBe(6);
});
it("should include users table", () => {
const statements = getTableCreationSQL();
const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users"));
expect(usersSQL).toBeDefined();
expect(usersSQL).toContain("username text NOT NULL UNIQUE");
expect(usersSQL).toContain("password_hash text");
expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'");
});
it("should include medications table", () => {
const statements = getTableCreationSQL();
const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications"));
expect(medsSQL).toBeDefined();
expect(medsSQL).toContain("user_id integer NOT NULL");
expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'");
expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE");
});
it("should include user_settings table", () => {
const statements = getTableCreationSQL();
const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings"));
expect(settingsSQL).toBeDefined();
expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0");
expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'");
});
it("should include refresh_tokens table", () => {
const statements = getTableCreationSQL();
const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens"));
expect(tokensSQL).toBeDefined();
expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE");
});
it("should include share_tokens table", () => {
const statements = getTableCreationSQL();
const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens"));
expect(shareSQL).toBeDefined();
expect(shareSQL).toContain("taken_by text NOT NULL");
});
it("should include dose_tracking table", () => {
const statements = getTableCreationSQL();
const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking"));
expect(doseSQL).toBeDefined();
expect(doseSQL).toContain("dose_id text NOT NULL");
expect(doseSQL).toContain("marked_by text");
});
});
describe("runTableMigrations", () => {
describe("runDrizzleMigrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
@@ -282,23 +208,24 @@ describe("Database Client Utilities", () => {
});
it("should create all tables successfully", async () => {
const result = await runTableMigrations(client);
const db = drizzle(client);
const result = await runDrizzleMigrations(db);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should be idempotent (run twice without errors)", async () => {
await runTableMigrations(client);
const result = await runTableMigrations(client);
const db = drizzle(client);
await runDrizzleMigrations(db);
const result = await runDrizzleMigrations(db);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should create all 6 tables", async () => {
await runTableMigrations(client);
it("should create all 7 tables", async () => {
const db = drizzle(client);
await runDrizzleMigrations(db);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
"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);
@@ -308,6 +235,29 @@ describe("Database Client Utilities", () => {
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
expect(tableNames).toContain("refill_history");
});
});
describe("runAlterMigrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should run without errors on a fresh database", async () => {
const result = await runAlterMigrations(client);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should be idempotent", async () => {
await runAlterMigrations(client);
const result = await runAlterMigrations(client);
expect(result.success).toBe(true);
});
});
@@ -316,7 +266,8 @@ describe("Database Client Utilities", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await runTableMigrations(client);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should create default user when auth is disabled", async () => {
@@ -386,246 +337,83 @@ describe("Database Client", () => {
});
});
describe("Table Schema Creation", () => {
describe("Table Schema via Drizzle Migrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should create users table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)
`);
// Verify table exists
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create medications table with foreign key", async () => {
// First create users table
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='medications'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create user_settings table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create refresh_tokens table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
rotated_at integer,
revoked integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create share_tokens table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='share_tokens'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create dose_tracking table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='dose_tracking'"
);
expect(tables.rows).toHaveLength(1);
});
it("should enforce unique constraint on username", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
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);
await expect(
client.execute("INSERT INTO users (username) VALUES ('testuser')")
).rejects.toThrow();
expect(columnNames).toContain("id");
expect(columnNames).toContain("username");
expect(columnNames).toContain("password_hash");
expect(columnNames).toContain("auth_provider");
});
it("should enforce unique constraint on refresh token_id", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
);
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);
await expect(
client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
)
).rejects.toThrow();
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("name");
expect(columnNames).toContain("taken_by_json");
expect(columnNames).toContain("pack_count");
expect(columnNames).toContain("usage_json");
});
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);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("email_enabled");
expect(columnNames).toContain("language");
expect(columnNames).toContain("stock_calculation_mode");
});
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);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("token_id");
});
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);
expect(columnNames).toContain("id");
expect(columnNames).toContain("token");
expect(columnNames).toContain("taken_by");
});
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);
expect(columnNames).toContain("id");
expect(columnNames).toContain("dose_id");
expect(columnNames).toContain("marked_by");
});
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);
expect(columnNames).toContain("id");
expect(columnNames).toContain("medication_id");
expect(columnNames).toContain("packs_added");
expect(columnNames).toContain("loose_pills_added");
});
});
@@ -634,15 +422,8 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local',
is_active integer NOT NULL DEFAULT 1,
created_at integer NOT NULL DEFAULT (strftime('%s','now'))
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should use default values for auth_provider", async () => {
@@ -656,16 +437,8 @@ describe("Database Client", () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
expect(result.rows[0].is_active).toBe(1);
});
it("should generate created_at timestamp", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'");
expect(typeof result.rows[0].created_at).toBe("number");
// Should be a reasonable Unix timestamp (after year 2020)
expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800);
// SQLite stores booleans as integers
expect(result.rows[0].is_active).toBeTruthy();
});
});
@@ -674,40 +447,18 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
});
it("should use default notification settings", async () => {
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
expect(result.rows[0].email_enabled).toBe(0);
expect(result.rows[0].shoutrrr_enabled).toBe(0);
// SQLite stores booleans as integers (false = 0)
expect(result.rows[0].email_enabled).toBeFalsy();
expect(result.rows[0].shoutrrr_enabled).toBeFalsy();
});
it("should use default stock threshold settings", async () => {
@@ -717,7 +468,6 @@ describe("Database Client", () => {
expect(result.rows[0].low_stock_days).toBe(30);
expect(result.rows[0].normal_stock_days).toBe(90);
expect(result.rows[0].high_stock_days).toBe(180);
expect(result.rows[0].expiry_warning_days).toBe(90);
});
it("should use default language (en)", async () => {
@@ -747,32 +497,9 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
intake_reminders_enabled integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
});
it("should use default inventory values", async () => {
@@ -795,11 +522,11 @@ describe("Database Client", () => {
expect(result.rows[0].start_json).toBe("[]");
});
it("should default intake_reminders_enabled to false (0)", async () => {
it("should default intake_reminders_enabled to false", async () => {
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'");
expect(result.rows[0].intake_reminders_enabled).toBe(0);
expect(result.rows[0].intake_reminders_enabled).toBeFalsy();
});
});
@@ -810,21 +537,8 @@ describe("Database Client", () => {
client = createClient({ url: ":memory:" });
// Enable foreign keys
await client.execute("PRAGMA foreign_keys = ON");
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should cascade delete medications when user is deleted", async () => {
@@ -845,18 +559,44 @@ describe("Database Client", () => {
});
});
describe("Unique Constraints", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
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();
});
it("should enforce unique constraint on refresh token_id", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
);
await expect(
client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
)
).rejects.toThrow();
});
});
describe("Default User Creation (Auth Disabled)", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should be able to create a default user with ID 1", async () => {
+136
View File
@@ -80,6 +80,45 @@ async function registerDoseRoutes(ctx: TestContext) {
return { success: true };
});
// POST /doses/dismiss - Dismiss missed doses without deducting stock
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
const userId = 1;
const { doseIds } = request.body || {};
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
return reply.status(400).send({ error: "doseIds array is required" });
}
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists
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) {
// Update to dismissed if not already
if (!existing.rows[0].dismissed) {
await client.execute({
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
args: [existing.rows[0].id],
});
dismissedCount++;
}
} else {
// Insert new dismissed record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
args: [userId, doseId],
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
});
}
// =============================================================================
@@ -361,4 +400,101 @@ describe("Dose Tracking API", () => {
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
});
// ---------------------------------------------------------------------------
// Dismiss Doses Tests (POST /doses/dismiss)
// ---------------------------------------------------------------------------
describe("POST /doses/dismiss", () => {
it("should dismiss multiple doses", async () => {
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
args: [userId],
});
expect(result.rows.length).toBe(2);
});
it("should not double-count already dismissed doses", async () => {
const doseId = "1-0-1735344000000";
// Dismiss once
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Dismiss again
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
});
it("should reject empty doseIds array", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [] },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should reject missing doseIds", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
const doseId = "1-0-1735344000000";
// First mark as taken
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Then dismiss it
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
// Verify it's now dismissed
const result = await ctx.client.execute({
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
expect(result.rows[0].dismissed).toBe(1);
});
});
});
+354
View File
@@ -54,6 +54,8 @@ const { shareRoutes } = await import("../routes/share.js");
const { medicationRoutes } = await import("../routes/medications.js");
const { settingsRoutes } = await import("../routes/settings.js");
const { healthRoutes } = await import("../routes/health.js");
const { refillRoutes } = await import("../routes/refills.js");
const { exportRoutes } = await import("../routes/export.js");
// =============================================================================
// Test Setup
@@ -139,6 +141,17 @@ async function createSchema(client: Client) {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
medication_id integer NOT NULL,
user_id integer NOT NULL,
packs_added integer NOT NULL DEFAULT 0,
loose_pills_added integer NOT NULL DEFAULT 0,
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
@@ -149,6 +162,7 @@ async function createSchema(client: Client) {
}
async function clearData(client: Client) {
await client.execute("DELETE FROM refill_history");
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
await client.execute("DELETE FROM user_settings");
@@ -229,6 +243,8 @@ describe("E2E Tests with Real Routes", () => {
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(healthRoutes);
await app.register(refillRoutes);
await app.register(exportRoutes);
await app.ready();
});
@@ -1567,4 +1583,342 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(204);
});
});
// ---------------------------------------------------------------------------
// Real Refill Routes Tests
// ---------------------------------------------------------------------------
describe("Real /medications/:id/refill routes", () => {
it("should add refill to medication stock", async () => {
// Create medication first
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Refill Test Med",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
// Add refill
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 10 },
});
expect(refillResponse.statusCode).toBe(200);
const data = refillResponse.json();
expect(data.success).toBe(true);
expect(data.newStock.packCount).toBe(3); // 2 + 1
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
});
it("should return 400 when no packs or pills added", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Refill Test Med 2",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
const response = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 0 },
});
expect(response.statusCode).toBe(400);
});
it("should return 404 for non-existent medication", async () => {
const response = await app.inject({
method: "POST",
url: "/medications/99999/refill",
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(404);
});
it("should return 400 for invalid medication id", async () => {
const response = await app.inject({
method: "POST",
url: "/medications/invalid/refill",
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(400);
});
});
describe("Real /medications/:id/refills routes (history)", () => {
it("should return empty array when no refills", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "No Refill Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
const response = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([]);
});
it("should return refill history after adding refills", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "With Refills Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
// Add two refills
await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 5 },
});
const response = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const refills = response.json();
expect(refills).toHaveLength(2);
// Check both refills exist (order may vary)
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
expect(hasPackRefill).toBe(true);
expect(hasLooseRefill).toBe(true);
});
it("should return 404 for non-existent medication", async () => {
const response = await app.inject({
method: "GET",
url: "/medications/99999/refills",
});
expect(response.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// Real Export/Import Routes Tests
// ---------------------------------------------------------------------------
describe("Real /export routes", () => {
it("should export empty data when no medications", async () => {
const response = await app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.version).toBeDefined();
expect(data.exportedAt).toBeDefined();
expect(data.medications).toEqual([]);
});
it("should export medications with correct structure", async () => {
// Create a medication
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Export Test Med",
genericName: "Test Generic",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
pillWeightMg: 500,
notes: "Test notes",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const response = await app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.medications).toHaveLength(1);
const med = data.medications[0];
expect(med.name).toBe("Export Test Med");
expect(med.genericName).toBe("Test Generic");
expect(med.inventory.packCount).toBe(2);
expect(med.inventory.blistersPerPack).toBe(3);
expect(med.inventory.pillsPerBlister).toBe(10);
expect(med.inventory.looseTablets).toBe(5);
expect(med.pillWeightMg).toBe(500);
expect(med.notes).toBe("Test notes");
expect(med.schedules).toHaveLength(1);
});
it("should include settings when user has settings", async () => {
// Create settings first
await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: true,
notificationEmail: "test@example.com",
},
});
const response = await app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.settings).toBeDefined();
expect(data.settings.emailEnabled).toBe(true);
});
});
describe("Real /import routes", () => {
it("should import medications from export format", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Med",
genericName: "Imported Generic",
takenBy: ["Person A"],
inventory: {
packCount: 3,
blistersPerPack: 2,
pillsPerBlister: 14,
looseTablets: 7,
},
pillWeightMg: 250,
schedules: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z", remind: true }
],
notes: "Imported notes",
intakeRemindersEnabled: true,
}
],
};
const response = await app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
const result = response.json();
expect(result.success).toBe(true);
expect(result.imported.medications).toBe(1);
// Verify medication was created
const medsResponse = await app.inject({
method: "GET",
url: "/medications",
});
const meds = medsResponse.json();
expect(meds).toHaveLength(1);
expect(meds[0].name).toBe("Imported Med");
});
it("should return 400 for invalid import data", async () => {
const response = await app.inject({
method: "POST",
url: "/import",
payload: { invalid: "data" },
});
expect(response.statusCode).toBe(400);
});
it("should replace existing medications on import", async () => {
// First create a medication
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Existing Med",
packCount: 5,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Verify it exists
let medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].name).toBe("Existing Med");
expect(medsResponse.json()[0].packCount).toBe(5);
// Import will REPLACE all data
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Med",
inventory: { packCount: 10, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
}
],
};
const response = await app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
const result = response.json();
expect(result.success).toBe(true);
expect(result.imported.medications).toBe(1);
// Verify: old med is gone, new med exists
medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].name).toBe("Imported Med");
expect(medsResponse.json()[0].packCount).toBe(10);
});
});
});
+851
View File
@@ -0,0 +1,851 @@
/**
* Tests for /export and /import API endpoints.
* Tests export/import functionality with schema-independent format.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
import { randomBytes } from "crypto";
// =============================================================================
// Route Registration (simplified test routes)
// =============================================================================
async function registerExportRoutes(ctx: TestContext) {
const { app, client } = ctx;
const userId = 1; // Test user ID
// Helper to parse blisters from DB
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
const usage = JSON.parse(row.usage_json || "[]") as number[];
const every = JSON.parse(row.every_json || "[]") as number[];
const start = JSON.parse(row.start_json || "[]") as string[];
const len = Math.min(usage.length, every.length, start.length);
return Array.from({ length: len }, (_, i) => ({
usage: usage[i],
every: every[i],
start: start[i],
remind: Boolean(row.intake_reminders_enabled),
}));
}
// GET /export
app.get<{ Querystring: { includeSensitive?: string } }>("/export", async (request, reply) => {
const includeSensitive = request.query.includeSensitive === "true";
// Load medications
const medsResult = await client.execute({
sql: `SELECT * FROM medications WHERE user_id = ? ORDER BY id`,
args: [userId],
});
const medIdToExportId = new Map<number, string>();
const medications = medsResult.rows.map((m, i) => {
const exportId = `med-${i + 1}`;
medIdToExportId.set(m.id as number, exportId);
return {
_exportId: exportId,
name: m.name,
genericName: m.generic_name,
takenBy: JSON.parse((m.taken_by_json as string) || "[]"),
inventory: {
packCount: m.pack_count ?? 1,
blistersPerPack: m.blisters_per_pack ?? 1,
pillsPerBlister: m.pills_per_blister ?? 1,
looseTablets: m.loose_tablets ?? 0,
},
pillWeightMg: m.pill_weight_mg,
schedules: parseBlisters(m),
expiryDate: m.expiry_date,
notes: m.notes,
intakeRemindersEnabled: Boolean(m.intake_reminders_enabled),
image: null, // Skip images in test
};
});
// Load dose tracking
const dosesResult = await client.execute({
sql: `SELECT * FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
const doseHistory = dosesResult.rows
.map((d) => {
const parts = (d.dose_id as string).split("-");
if (parts.length < 3) return null;
const medId = parseInt(parts[0], 10);
const exportId = medIdToExportId.get(medId);
if (!exportId) return null;
return {
medicationRef: exportId,
scheduleIndex: parseInt(parts[1], 10),
scheduledTime: new Date(parseInt(parts[2], 10)).toISOString(),
takenAt: d.taken_at ? new Date(d.taken_at as number * 1000).toISOString() : new Date().toISOString(),
markedBy: d.marked_by,
};
})
.filter(Boolean);
// Load settings
const settingsResult = await client.execute({
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
args: [userId],
});
let settings = undefined;
if (settingsResult.rows.length > 0) {
const s = settingsResult.rows[0];
settings = {
emailEnabled: Boolean(s.email_enabled),
notificationEmail: s.notification_email,
emailStockReminders: Boolean(s.email_stock_reminders ?? 1),
emailIntakeReminders: Boolean(s.email_intake_reminders ?? 1),
shoutrrrEnabled: includeSensitive ? Boolean(s.shoutrrr_enabled) : undefined,
shoutrrrUrl: includeSensitive ? s.shoutrrr_url : undefined,
shoutrrrStockReminders: Boolean(s.shoutrrr_stock_reminders ?? 1),
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders ?? 1),
reminderDaysBefore: s.reminder_days_before ?? 7,
repeatDailyReminders: Boolean(s.repeat_daily_reminders),
skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses),
repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled),
reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30,
maxNaggingReminders: s.max_nagging_reminders ?? 5,
lowStockDays: s.low_stock_days ?? 30,
normalStockDays: s.normal_stock_days ?? 90,
highStockDays: s.high_stock_days ?? 180,
language: s.language ?? "en",
stockCalculationMode: s.stock_calculation_mode ?? "automatic",
};
}
// Load share links
const sharesResult = await client.execute({
sql: `SELECT * FROM share_tokens WHERE user_id = ?`,
args: [userId],
});
const shareLinks = sharesResult.rows.map((s) => ({
takenBy: s.taken_by,
scheduleDays: s.schedule_days ?? 30,
expiresAt: s.expires_at ? new Date(s.expires_at as number * 1000).toISOString() : null,
regenerateToken: true,
}));
return {
version: "1.0",
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications,
doseHistory,
settings,
shareLinks,
};
});
// POST /import
app.post<{ Body: any }>("/import", async (request, reply) => {
const importData = request.body as any;
// Basic validation
if (!importData.version) {
return reply.status(400).send({ error: "Invalid import data format" });
}
// Delete existing data
await client.execute({ sql: `DELETE FROM dose_tracking WHERE user_id = ?`, args: [userId] });
await client.execute({ sql: `DELETE FROM share_tokens WHERE user_id = ?`, args: [userId] });
await client.execute({ sql: `DELETE FROM medications WHERE user_id = ?`, args: [userId] });
await client.execute({ sql: `DELETE FROM user_settings WHERE user_id = ?`, args: [userId] });
// Import medications
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications || []) {
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
const takenByJson = JSON.stringify(med.takenBy || []);
const result = await client.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
pill_weight_mg, expiry_date, notes, intake_reminders_enabled,
usage_json, every_json, start_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
userId,
med.name,
med.genericName || null,
takenByJson,
med.inventory?.packCount ?? 1,
med.inventory?.blistersPerPack ?? 1,
med.inventory?.pillsPerBlister ?? 1,
med.inventory?.looseTablets ?? 0,
med.pillWeightMg ?? null,
med.expiryDate || null,
med.notes || null,
med.intakeRemindersEnabled ? 1 : 0,
usageJson,
everyJson,
startJson,
],
});
exportIdToNewId.set(med._exportId, result.rows[0].id as number);
}
// Import dose history
for (const dose of importData.doseHistory || []) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue;
const timestampMs = new Date(dose.scheduledTime).getTime();
const doseId = `${newMedId}-${dose.scheduleIndex}-${timestampMs}`;
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`,
args: [
userId,
doseId,
Math.floor(new Date(dose.takenAt).getTime() / 1000),
dose.markedBy || null,
],
});
}
// Import settings
if (importData.settings) {
const s = importData.settings;
await client.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email,
email_stock_reminders, email_intake_reminders,
shoutrrr_enabled, shoutrrr_url,
shoutrrr_stock_reminders, shoutrrr_intake_reminders,
reminder_days_before, repeat_daily_reminders,
skip_reminders_for_taken_doses, repeat_reminders_enabled,
reminder_repeat_interval_minutes, max_nagging_reminders,
low_stock_days, normal_stock_days, high_stock_days,
language, stock_calculation_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
s.emailEnabled ? 1 : 0,
s.notificationEmail || null,
s.emailStockReminders ?? 1,
s.emailIntakeReminders ?? 1,
s.shoutrrrEnabled ? 1 : 0,
s.shoutrrrUrl || null,
s.shoutrrrStockReminders ?? 1,
s.shoutrrrIntakeReminders ?? 1,
s.reminderDaysBefore ?? 7,
s.repeatDailyReminders ? 1 : 0,
s.skipRemindersForTakenDoses ? 1 : 0,
s.repeatRemindersEnabled ? 1 : 0,
s.reminderRepeatIntervalMinutes ?? 30,
s.maxNaggingReminders ?? 5,
s.lowStockDays ?? 30,
s.normalStockDays ?? 90,
s.highStockDays ?? 180,
s.language ?? "en",
s.stockCalculationMode ?? "automatic",
],
});
}
// Import share links
for (const share of importData.shareLinks || []) {
const token = randomBytes(8).toString("hex");
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
args: [
userId,
token,
share.takenBy,
share.scheduleDays ?? 30,
share.expiresAt ? Math.floor(new Date(share.expiresAt).getTime() / 1000) : null,
],
});
}
return {
success: true,
imported: {
medications: (importData.medications || []).length,
doseHistory: (importData.doseHistory || []).length,
settings: importData.settings ? 1 : 0,
shareLinks: (importData.shareLinks || []).length,
},
};
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Export/Import API", () => {
let ctx: TestContext;
let userId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerExportRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='medications'");
userId = await createTestUser(ctx.client, { username: "testuser" });
});
// ---------------------------------------------------------------------------
// GET /export
// ---------------------------------------------------------------------------
describe("GET /export", () => {
it("should export empty data for new user", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.version).toBe("1.0");
expect(data.exportedAt).toBeDefined();
expect(data.medications).toEqual([]);
expect(data.doseHistory).toEqual([]);
expect(data.shareLinks).toEqual([]);
});
it("should export medications with correct format", async () => {
const startDate = "2025-01-15T08:00:00.000Z";
await createTestMedication(ctx.client, {
userId,
name: "Aspirin",
genericName: "Acetylsalicylic acid",
takenBy: ["Daniel", "Maria"],
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
pillWeightMg: 500,
expiryDate: "2027-06-30",
notes: "Take with food",
intakeRemindersEnabled: true,
blisters: [
{ usage: 1, every: 1, start: startDate },
{ usage: 0.5, every: 7, start: startDate },
],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.medications).toHaveLength(1);
const med = data.medications[0];
expect(med._exportId).toBe("med-1");
expect(med.name).toBe("Aspirin");
expect(med.genericName).toBe("Acetylsalicylic acid");
expect(med.takenBy).toEqual(["Daniel", "Maria"]);
expect(med.inventory).toEqual({
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
});
expect(med.pillWeightMg).toBe(500);
expect(med.expiryDate).toBe("2027-06-30");
expect(med.notes).toBe("Take with food");
expect(med.intakeRemindersEnabled).toBe(true);
expect(med.schedules).toHaveLength(2);
expect(med.schedules[0]).toEqual({
usage: 1,
every: 1,
start: startDate,
remind: true,
});
});
it("should export settings", async () => {
// Create settings
await ctx.client.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, language, low_stock_days
) VALUES (?, 1, 'test@example.com', 'de', 14)`,
args: [userId],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.settings).toBeDefined();
expect(data.settings.emailEnabled).toBe(true);
expect(data.settings.notificationEmail).toBe("test@example.com");
expect(data.settings.language).toBe("de");
expect(data.settings.lowStockDays).toBe(14);
});
it("should exclude sensitive data by default", async () => {
await ctx.client.execute({
sql: `INSERT INTO user_settings (
user_id, shoutrrr_enabled, shoutrrr_url
) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`,
args: [userId],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.includeSensitiveData).toBe(false);
expect(data.settings.shoutrrrEnabled).toBeUndefined();
expect(data.settings.shoutrrrUrl).toBeUndefined();
});
it("should include sensitive data when requested", async () => {
await ctx.client.execute({
sql: `INSERT INTO user_settings (
user_id, shoutrrr_enabled, shoutrrr_url
) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`,
args: [userId],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export?includeSensitive=true",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.includeSensitiveData).toBe(true);
expect(data.settings.shoutrrrEnabled).toBe(true);
expect(data.settings.shoutrrrUrl).toBe("ntfy://user:pass@ntfy.sh/topic");
});
it("should export dose history with medication references", async () => {
const medId = await createTestMedication(ctx.client, {
userId,
name: "Test Med",
});
// Create dose tracking entry
const timestampMs = Date.now();
const doseId = `${medId}-0-${timestampMs}`;
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at) VALUES (?, ?, ?)`,
args: [userId, doseId, Math.floor(Date.now() / 1000)],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doseHistory).toHaveLength(1);
expect(data.doseHistory[0].medicationRef).toBe("med-1");
expect(data.doseHistory[0].scheduleIndex).toBe(0);
expect(data.doseHistory[0].scheduledTime).toBeDefined();
expect(data.doseHistory[0].takenAt).toBeDefined();
});
it("should export share links", async () => {
await ctx.client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`,
args: [userId, "abc123", "Daniel", 30],
});
const response = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.shareLinks).toHaveLength(1);
expect(data.shareLinks[0].takenBy).toBe("Daniel");
expect(data.shareLinks[0].scheduleDays).toBe(30);
expect(data.shareLinks[0].regenerateToken).toBe(true);
});
});
// ---------------------------------------------------------------------------
// POST /import
// ---------------------------------------------------------------------------
describe("POST /import", () => {
it("should import medications", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Med",
genericName: "Generic",
takenBy: ["Alice"],
inventory: {
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
},
pillWeightMg: 250,
schedules: [
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z", remind: true },
],
expiryDate: "2027-12-31",
notes: "Test notes",
intakeRemindersEnabled: true,
},
],
doseHistory: [],
shareLinks: [],
};
const response = await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
expect(response.json().success).toBe(true);
expect(response.json().imported.medications).toBe(1);
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT * FROM medications WHERE user_id = ?`,
args: [userId],
});
expect(result.rows).toHaveLength(1);
expect(result.rows[0].name).toBe("Imported Med");
expect(result.rows[0].generic_name).toBe("Generic");
expect(result.rows[0].pack_count).toBe(2);
expect(result.rows[0].blisters_per_pack).toBe(3);
expect(result.rows[0].pills_per_blister).toBe(10);
expect(result.rows[0].loose_tablets).toBe(5);
});
it("should replace existing data on import", async () => {
// Create existing medication
await createTestMedication(ctx.client, {
userId,
name: "Existing Med",
});
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "New Med",
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }],
},
],
doseHistory: [],
shareLinks: [],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify old med deleted, new one exists
const result = await ctx.client.execute({
sql: `SELECT * FROM medications WHERE user_id = ?`,
args: [userId],
});
expect(result.rows).toHaveLength(1);
expect(result.rows[0].name).toBe("New Med");
});
it("should import dose history with remapped IDs", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Med 1",
schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }],
},
],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2025-01-15T08:00:00.000Z",
takenAt: "2025-01-15T08:15:00.000Z",
markedBy: null,
},
],
shareLinks: [],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify dose tracking
const doses = await ctx.client.execute({
sql: `SELECT * FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
expect(doses.rows).toHaveLength(1);
// Dose ID should contain the NEW medication ID
const doseId = doses.rows[0].dose_id as string;
expect(doseId).toMatch(/^\d+-0-\d+$/);
});
it("should import settings", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [],
doseHistory: [],
settings: {
emailEnabled: true,
notificationEmail: "imported@example.com",
language: "de",
lowStockDays: 14,
normalStockDays: 60,
highStockDays: 120,
},
shareLinks: [],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify settings
const settings = await ctx.client.execute({
sql: `SELECT * FROM user_settings WHERE user_id = ?`,
args: [userId],
});
expect(settings.rows).toHaveLength(1);
expect(settings.rows[0].email_enabled).toBe(1);
expect(settings.rows[0].notification_email).toBe("imported@example.com");
expect(settings.rows[0].language).toBe("de");
expect(settings.rows[0].low_stock_days).toBe(14);
});
it("should import share links with new tokens", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [],
doseHistory: [],
shareLinks: [
{
takenBy: "Daniel",
scheduleDays: 60,
regenerateToken: true,
},
],
};
await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
// Verify share token
const shares = await ctx.client.execute({
sql: `SELECT * FROM share_tokens WHERE user_id = ?`,
args: [userId],
});
expect(shares.rows).toHaveLength(1);
expect(shares.rows[0].taken_by).toBe("Daniel");
expect(shares.rows[0].schedule_days).toBe(60);
expect(shares.rows[0].token).toBeDefined();
expect((shares.rows[0].token as string).length).toBe(16); // 8 bytes = 16 hex chars
});
it("should reject invalid import data", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/import",
payload: { invalid: "data" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid import data format");
});
});
// ---------------------------------------------------------------------------
// Export/Import Roundtrip Tests
// ---------------------------------------------------------------------------
describe("Export/Import Roundtrip", () => {
it("should preserve all data through export/import cycle", async () => {
// Setup: Create medications, doses, settings, shares
const startDate = "2025-01-15T08:00:00.000Z";
const medId = await createTestMedication(ctx.client, {
userId,
name: "Roundtrip Med",
genericName: "Generic Name",
takenBy: ["Daniel", "Maria"],
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
pillWeightMg: 500,
expiryDate: "2027-06-30",
notes: "Test notes",
intakeRemindersEnabled: true,
blisters: [
{ usage: 1, every: 1, start: startDate },
{ usage: 0.5, every: 7, start: startDate },
],
});
// Create dose
const timestampMs = new Date(startDate).getTime();
const doseId = `${medId}-0-${timestampMs}`;
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`,
args: [userId, doseId, Math.floor(Date.now() / 1000), "Daniel"],
});
// Create settings
await ctx.client.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, notification_email, language, low_stock_days) VALUES (?, 1, 'test@example.com', 'de', 14)`,
args: [userId],
});
// Create share
await ctx.client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`,
args: [userId, "original123", "Daniel", 60],
});
// Export
const exportResponse = await ctx.app.inject({
method: "GET",
url: "/export",
});
expect(exportResponse.statusCode).toBe(200);
const exportData = exportResponse.json();
// Import (this replaces all data)
const importResponse = await ctx.app.inject({
method: "POST",
url: "/import",
payload: exportData,
});
expect(importResponse.statusCode).toBe(200);
// Export again and compare
const reExportResponse = await ctx.app.inject({
method: "GET",
url: "/export",
});
const reExportData = reExportResponse.json();
// Compare (excluding timestamps and IDs that change)
expect(reExportData.medications).toHaveLength(1);
expect(reExportData.medications[0].name).toBe("Roundtrip Med");
expect(reExportData.medications[0].genericName).toBe("Generic Name");
expect(reExportData.medications[0].takenBy).toEqual(["Daniel", "Maria"]);
expect(reExportData.medications[0].inventory).toEqual({
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
});
expect(reExportData.medications[0].schedules).toHaveLength(2);
expect(reExportData.doseHistory).toHaveLength(1);
expect(reExportData.doseHistory[0].markedBy).toBe("Daniel");
expect(reExportData.settings.emailEnabled).toBe(true);
expect(reExportData.settings.notificationEmail).toBe("test@example.com");
expect(reExportData.settings.language).toBe("de");
expect(reExportData.shareLinks).toHaveLength(1);
expect(reExportData.shareLinks[0].takenBy).toBe("Daniel");
});
it("should handle import with different schema (backward compatibility)", async () => {
// Simulate import from older version without some fields
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Legacy Med",
// Missing: genericName, takenBy, pillWeightMg, etc.
inventory: {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
},
schedules: [
{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" },
],
},
],
doseHistory: [],
// Missing: settings, shareLinks
};
const response = await ctx.app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
expect(response.json().success).toBe(true);
// Verify defaults were applied
const result = await ctx.client.execute({
sql: `SELECT * FROM medications WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].name).toBe("Legacy Med");
expect(result.rows[0].generic_name).toBeNull();
expect(result.rows[0].taken_by_json).toBe("[]");
});
});
});
+1
View File
@@ -136,6 +136,7 @@ async function createSchema(client: Client) {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
+394
View File
@@ -0,0 +1,394 @@
/**
* 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 {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
// Store userId at module level so routes can access it
let currentUserId = 1;
// =============================================================================
// Route Registration
// =============================================================================
async function registerRefillRoutes(ctx: TestContext) {
const { app, client } = ctx;
// POST /medications/:id/refill - Add stock and record history
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
"/medications/:id/refill",
async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
// Validate input
if (packsAdded < 0 || loosePillsAdded < 0) {
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" });
}
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
const med = medResult.rows[0];
const newPackCount = (med.pack_count as number) + packsAdded;
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
// Update medication stock
await client.execute({
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
args: [newPackCount, newLooseTablets, medId],
});
// Record refill history
await client.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
VALUES (?, ?, ?, ?)`,
args: [medId, userId, packsAdded, loosePillsAdded],
});
return {
success: true,
pillsAdded: totalPillsAdded,
newPackCount,
newLooseTablets,
};
}
);
// GET /medications/:id/refills - Get refill history
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
// Get refill history, newest first
const refillResult = await client.execute({
sql: `SELECT id, packs_added, loose_pills_added, refill_date
FROM refill_history
WHERE medication_id = ? AND user_id = ?
ORDER BY refill_date DESC`,
args: [medId, userId],
});
return {
refills: refillResult.rows.map((r) => ({
id: r.id,
packsAdded: r.packs_added,
loosePillsAdded: r.loose_pills_added,
refillDate: r.refill_date,
})),
};
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Refill API", () => {
let ctx: TestContext;
let userId: number;
let medId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerRefillRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user
userId = await createTestUser(ctx.client, { username: "testuser" });
// Update the module-level userId so routes use the correct one
currentUserId = userId;
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
medId = await createTestMedication(ctx.client, {
userId,
name: "Test Med",
packCount: 1,
blistersPerPack: 10,
pillsPerBlister: 10,
looseTablets: 5,
});
});
// ---------------------------------------------------------------------------
// POST /medications/:id/refill
// ---------------------------------------------------------------------------
describe("POST /medications/:id/refill", () => {
it("should add packs to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
expect(data.newPackCount).toBe(3); // 1 + 2
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT pack_count FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].pack_count).toBe(3);
});
it("should add loose pills to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { loosePillsAdded: 15 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(15);
expect(data.newLooseTablets).toBe(20); // 5 + 15
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].loose_tablets).toBe(20);
});
it("should add both packs and loose pills", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 10 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
expect(data.newPackCount).toBe(2);
expect(data.newLooseTablets).toBe(15);
});
it("should record refill in history", async () => {
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2, loosePillsAdded: 5 },
});
// Check history
const result = await ctx.client.execute({
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].packs_added).toBe(2);
expect(result.rows[0].loose_pills_added).toBe(5);
});
it("should reject refill with zero amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 0 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("At least one");
});
it("should reject refill with negative amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: -1 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("non-negative");
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/99999/refill`,
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// GET /medications/:id/refills
// ---------------------------------------------------------------------------
describe("GET /medications/:id/refills", () => {
it("should return empty array when no refills", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ refills: [] });
});
it("should return refill history newest first", async () => {
// Add two refills with different values so we can identify them
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
await new Promise((r) => setTimeout(r, 1100));
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 20 },
});
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.refills).toHaveLength(2);
// Newest first (loose pills - added second)
expect(data.refills[0].packsAdded).toBe(0);
expect(data.refills[0].loosePillsAdded).toBe(20);
// Older (packs - added first)
expect(data.refills[1].packsAdded).toBe(1);
expect(data.refills[1].loosePillsAdded).toBe(0);
// Each entry should have an id and refillDate
for (const refill of data.refills) {
expect(refill.id).toBeTypeOf("number");
expect(refill.refillDate).toBeTruthy();
}
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/99999/refills`,
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// Cascade Delete Tests
// ---------------------------------------------------------------------------
describe("Cascade Delete", () => {
it("should delete refill history when medication is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(1);
// Delete medication
await ctx.client.execute({
sql: `DELETE FROM medications WHERE id = ?`,
args: [medId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(0);
});
it("should delete refill history when user is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(1);
// Delete user
await ctx.client.execute({
sql: `DELETE FROM users WHERE id = ?`,
args: [userId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(0);
});
});
});
+23 -9
View File
@@ -9,8 +9,15 @@ import sensible from "@fastify/sensible";
import fastifyMultipart from "@fastify/multipart";
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { beforeAll, afterAll, beforeEach } from "vitest";
import { getTableCreationSQL } from "../db/schema-sql.js";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// Type for our test database
export type TestDb = ReturnType<typeof drizzle>;
@@ -61,14 +68,11 @@ export async function buildTestApp(): Promise<TestContext> {
}
/**
* Create test database schema
* Create test database schema using drizzle-kit migrations
*/
async function runTestMigrations(client: Client): Promise<void> {
const tableCreations = getTableCreationSQL();
for (const sql of tableCreations) {
await client.execute(sql);
}
const db = drizzle(client);
await migrate(db, { migrationsFolder });
}
// =============================================================================
@@ -107,6 +111,9 @@ export interface CreateMedicationOptions {
pillsPerBlister?: number;
looseTablets?: number;
pillWeightMg?: number;
expiryDate?: string | null;
notes?: string | null;
intakeRemindersEnabled?: boolean;
/** Array of { usage, every, start } for each blister schedule */
blisters?: Array<{ usage: number; every: number; start: string }>;
}
@@ -128,6 +135,9 @@ export async function createTestMedication(
pillsPerBlister = 10,
looseTablets = 0,
pillWeightMg = null,
expiryDate = null,
notes = null,
intakeRemindersEnabled = false,
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
} = options;
@@ -141,8 +151,8 @@ export async function createTestMedication(
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
pill_weight_mg, usage_json, every_json, start_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
userId,
name,
@@ -156,6 +166,9 @@ export async function createTestMedication(
usageJson,
everyJson,
startJson,
expiryDate,
notes,
intakeRemindersEnabled ? 1 : 0,
],
});
@@ -273,6 +286,7 @@ export async function closeTestApp(ctx: TestContext): Promise<void> {
*/
export async function clearTestData(client: Client): Promise<void> {
// Order matters due to foreign keys
await client.execute("DELETE FROM refill_history");
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
await client.execute("DELETE FROM refresh_tokens");
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

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-frontend",
"version": "1.0.2",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.0.2",
"version": "1.1.0",
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.0.2",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+841 -79
View File
File diff suppressed because it is too large Load Diff
+56 -5
View File
@@ -38,7 +38,14 @@
"pastDaysCount": "{{count}} Tag",
"pastDaysCount_other": "{{count}} Tage",
"missedDoses": "{{count}} verpasste Dosis",
"missedDoses_other": "{{count}} verpasste Dosen"
"missedDoses_other": "{{count}} verpasste Dosen",
"clearMissed": "Verpasste löschen",
"clearMissedConfirmTitle": "Verpasste Dosen löschen?",
"clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
"clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
"clearMissedConfirm": "Ja, löschen",
"clearMissedCancel": "Abbrechen",
"clearMissedSuccess": "{{count}} verpasste Dosen gelöscht"
},
"reminders": {
"active": "Automatische Erinnerungen aktiv",
@@ -91,8 +98,8 @@
}
},
"form": {
"editEntry": "Eintrag bearbeiten",
"newEntry": "Neuer Eintrag",
"editEntry": "Medikament bearbeiten",
"newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten",
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
@@ -173,7 +180,9 @@
},
"push": {
"url": "URL",
"supports": "Unterstützt ntfy, Discord, Telegram, Slack"
"urlPlaceholder": "ntfy://topic oder pushover://:token@userkey/",
"supports": "Unterstützt ntfy, Pushover, Gotify, Discord, Telegram, Slack & mehr",
"docsLink": "Siehe shoutrrr.dev für alle Services"
},
"schedule": {
"title": "Erinnerungsplan",
@@ -217,7 +226,7 @@
"pillWeight": "Tablettengewicht",
"expiryDate": "Ablaufdatum",
"intakeSchedule": "Einnahmeplan",
"coverageStatus": "Reichweite",
"coverageStatus": "Bestand",
"daysLeft": "Tage übrig",
"runsOut": "Aufgebraucht",
"notes": "Notizen",
@@ -348,5 +357,47 @@
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
"expiredOn": "Abgelaufen am: {{date}}"
}
},
"exportImport": {
"title": "Daten Export / Import",
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
"exportTitle": "Export",
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
"importTitle": "Import",
"importDesc": "Stelle Daten aus einer Sicherung wieder her. Dies ersetzt alle bestehenden Daten.",
"export": "Daten exportieren",
"exporting": "Exportiere...",
"import": "Datei auswählen",
"importing": "Importiere...",
"selectFile": "Datei auswählen",
"includeSensitive": "Sensible Daten einschließen (Benachrichtigungs-URLs)",
"sensitiveWarning": "Benachrichtigungs-URLs können Passwörter enthalten und werden im Klartext gespeichert.",
"confirmImport": "Alle Daten ersetzen?",
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
"confirmButton": "Ja, alles ersetzen",
"cancelButton": "Abbrechen",
"exportSuccess": "Daten erfolgreich exportiert",
"importSuccess": "Daten erfolgreich importiert",
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
"importError": "Daten konnten nicht importiert werden",
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.",
"downloadFilename": "medassist-export"
},
"refill": {
"title": "Nachfüllen",
"packs": "Packungen hinzufügen",
"loosePills": "Lose Tabletten hinzufügen",
"pillsPerPack": "1 Packung = {{count}} Tabletten",
"addToStock": "Zum Bestand hinzufügen",
"adding": "Wird hinzugefügt...",
"success": "{{pills}} Tabletten zum Bestand hinzugefügt",
"history": "Nachfüll-Verlauf",
"noHistory": "Noch keine Nachfüllungen erfasst",
"packsAdded": "{{count}} Packung",
"packsAdded_other": "{{count}} Packungen",
"pillsAdded": "{{count}} Tablette",
"pillsAdded_other": "{{count}} Tabletten",
"button": "Nachfüllen"
}
}
+55 -4
View File
@@ -40,7 +40,14 @@
"pastDaysCount": "{{count}} day",
"pastDaysCount_other": "{{count}} days",
"missedDoses": "{{count}} missed dose",
"missedDoses_other": "{{count}} missed doses"
"missedDoses_other": "{{count}} missed doses",
"clearMissed": "Clear missed",
"clearMissedConfirmTitle": "Clear Missed Doses?",
"clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.",
"clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.",
"clearMissedConfirm": "Yes, Clear",
"clearMissedCancel": "Cancel",
"clearMissedSuccess": "Cleared {{count}} missed doses"
},
"reminders": {
"active": "Automatic reminders active",
@@ -93,8 +100,8 @@
}
},
"form": {
"editEntry": "Edit entry",
"newEntry": "New entry",
"editEntry": "Edit medication",
"newEntry": "New medication",
"badge": "Packs + loose pills",
"commercialName": "Commercial Name",
"genericName": "Generic Name",
@@ -175,7 +182,9 @@
},
"push": {
"url": "URL",
"supports": "Supports ntfy, Discord, Telegram, Slack"
"urlPlaceholder": "ntfy://topic or pushover://:token@userkey/",
"supports": "Supports ntfy, Pushover, Gotify, Discord, Telegram, Slack & more",
"docsLink": "See shoutrrr.dev for all services"
},
"schedule": {
"title": "Reminder Schedule",
@@ -350,5 +359,47 @@
"contact": "Please contact {{username}} to request a new link.",
"expiredOn": "Expired on: {{date}}"
}
},
"exportImport": {
"title": "Data Export / Import",
"description": "Backup your data or transfer it to another device.",
"exportTitle": "Export",
"exportDesc": "Download all your data as a JSON file.",
"importTitle": "Import",
"importDesc": "Restore data from a backup file. This will replace all existing data.",
"export": "Export Data",
"exporting": "Exporting...",
"import": "Select File",
"importing": "Importing...",
"selectFile": "Select File",
"includeSensitive": "Include sensitive data (notification URLs)",
"sensitiveWarning": "Notification URLs may contain passwords and will be stored in plain text.",
"confirmImport": "Replace All Data?",
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
"confirmImportWarning": "This action cannot be undone!",
"confirmButton": "Yes, Replace All",
"cancelButton": "Cancel",
"exportSuccess": "Data exported successfully",
"importSuccess": "Data imported successfully",
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
"importError": "Failed to import data",
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.",
"downloadFilename": "medassist-export"
},
"refill": {
"title": "Refill",
"packs": "Packs to add",
"loosePills": "Loose pills to add",
"pillsPerPack": "1 pack = {{count}} pills",
"addToStock": "Add to Stock",
"adding": "Adding...",
"success": "Added {{pills}} pills to stock",
"history": "Refill History",
"noHistory": "No refills recorded yet",
"packsAdded": "{{count}} pack",
"packsAdded_other": "{{count}} packs",
"pillsAdded": "{{count}} pill",
"pillsAdded_other": "{{count}} pills",
"button": "Refill"
}
}
+445 -10
View File
@@ -431,6 +431,46 @@ button.secondary:hover {
background: var(--bg-secondary);
}
/* Success button (Refill, etc.) */
button.success {
background: var(--success);
color: white;
border: none;
}
button.success:hover {
filter: brightness(1.1);
}
button.success:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Primary/Accent button (New entry, Add intake, etc.) */
button.primary {
background: var(--accent);
color: white;
border: none;
}
button.primary:hover {
background: var(--accent-light);
}
button.primary:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Info button (Edit, secondary actions) */
button.info {
background: #3b82f6;
color: white;
border: none;
}
button.info:hover {
background: #60a5fa;
}
/* Ghost button (Cancel, etc.) */
button.ghost {
background: transparent;
@@ -637,6 +677,7 @@ textarea.auto-resize {
.past-days-toggle {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
@@ -662,6 +703,7 @@ textarea.auto-resize {
.past-days-icon {
font-size: 0.7rem;
opacity: 0.6;
flex-shrink: 0;
}
.past-days-label {
font-weight: 500;
@@ -669,6 +711,8 @@ textarea.auto-resize {
.past-days-count {
opacity: 0.6;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
.past-days-warning {
margin-left: auto;
@@ -690,6 +734,35 @@ textarea.auto-resize {
background: rgba(234, 179, 8, 0.08);
}
/* Past days header container - toggle + clear button */
.past-days-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.past-days-header .past-days-toggle {
flex: 1;
}
.clear-missed-btn {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
font-weight: 500;
background: rgba(234, 179, 8, 0.15);
color: var(--warning);
border: 1px solid var(--warning);
border-radius: var(--btn-radius);
cursor: pointer;
white-space: nowrap;
transition: background 150ms ease, transform 100ms ease;
}
.clear-missed-btn:hover {
background: rgba(234, 179, 8, 0.25);
transform: translateY(-1px);
}
.clear-missed-btn:active {
transform: translateY(0);
}
/* Past day blocks styling */
.day-block.past {
opacity: 0.7;
@@ -1359,13 +1432,21 @@ textarea.auto-resize {
}
.setting-row.language-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 1.5rem;
gap: 1rem;
}
.setting-row.language-row .setting-label {
flex: 0 0 auto;
min-width: 100px;
}
.language-select {
width: auto;
min-width: 160px;
flex: 1 1 auto;
min-width: 140px;
max-width: 200px;
padding: 0.6rem 2rem 0.6rem 0.75rem;
font-size: 1rem;
@@ -1634,11 +1715,42 @@ textarea.auto-resize {
}
.info-tooltip:hover::after,
.info-tooltip:hover::before {
.info-tooltip:hover::before,
.info-tooltip:focus::after,
.info-tooltip:focus::before,
.info-tooltip.tooltip-active::after,
.info-tooltip.tooltip-active::before {
opacity: 1;
visibility: visible;
}
/* Mobile tooltip - disable hover, use click only */
@media (max-width: 640px) {
.info-tooltip:hover::after,
.info-tooltip:hover::before {
opacity: 0;
visibility: hidden;
}
.info-tooltip.tooltip-active::after {
opacity: 1;
visibility: visible;
position: fixed;
top: 50%;
left: 50%;
bottom: auto;
right: auto;
transform: translate(-50%, -50%);
max-width: calc(100vw - 32px);
width: max-content;
z-index: 9999;
}
.info-tooltip::before {
display: none;
}
}
/* Channels Overview */
.channels-overview {
display: flex;
@@ -1947,7 +2059,8 @@ textarea.auto-resize {
.schedule-row {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.85rem;
}
@@ -1960,11 +2073,27 @@ textarea.auto-resize {
.schedule-label {
color: var(--text-secondary);
flex-shrink: 0;
min-width: 0;
}
.schedule-value {
color: var(--text-primary);
font-weight: 500;
text-align: right;
word-break: break-word;
}
/* Mobile: stack schedule rows vertically when text is long */
@media (max-width: 400px) {
.schedule-row {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.schedule-value {
text-align: left;
}
}
/* Legacy support for old channel-btn (can remove later) */
@@ -2061,10 +2190,13 @@ textarea.auto-resize {
font-size: 0.9rem;
}
/* Compact Setting Row */
/* Compact Setting Row - for inline toggles without card styling */
.setting-row.compact {
padding: 0.75rem 1rem;
padding: 0.75rem 0;
margin-top: 0.5rem;
background: transparent;
border: none;
border-radius: 0;
}
.setting-row.compact .setting-label {
@@ -2255,6 +2387,9 @@ textarea.auto-resize {
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
.med-avatar.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
.med-avatar.clickable:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
/* Table/Timeline cells with avatar */
.cell-with-avatar {
display: flex;
@@ -2856,14 +2991,22 @@ textarea.auto-resize {
}
.lightbox-image {
max-width: 90vw;
max-height: 85vh;
max-width: 50vw;
max-height: 50vh;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: zoomIn 0.3s ease;
}
/* Mobile: larger lightbox image */
@media (max-width: 768px) {
.lightbox-image {
max-width: 90vw;
max-height: 70vh;
}
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
@@ -2903,6 +3046,22 @@ textarea.auto-resize {
.med-detail-footer {
padding: 1rem 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.med-detail-footer > button {
flex: 1 1 auto;
}
.med-detail-footer .footer-actions {
flex: 1 1 auto;
justify-content: flex-end;
}
.med-detail-footer button {
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
}
.med-detail-grid {
@@ -3254,7 +3413,17 @@ h3 .reminder-icon.info-tooltip {
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
}
.user-menu:hover .user-dropdown {
/* Only use hover on devices that support it (not touch) */
@media (hover: hover) and (pointer: fine) {
.user-menu:hover .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
}
/* Click-based open for all devices */
.user-menu.open .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
@@ -3688,6 +3857,215 @@ h3 .reminder-icon.info-tooltip {
align-items: center;
}
/* =============================================================================
Refill Modal & History
============================================================================= */
.refill-modal {
max-width: 500px;
padding: 1.5rem;
}
.refill-modal h2 {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.refill-med-name {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.refill-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.refill-form label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-weight: 500;
}
.refill-form input {
padding: 0.75rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 1rem;
}
.refill-footer-right {
display: flex;
align-items: center;
gap: 1rem;
}
.refill-footer-right .refill-preview {
height: 42px;
}
/* Refill modal footer mobile */
@media (max-width: 640px) {
.refill-modal .modal-footer {
flex-direction: column;
gap: 0.75rem;
}
.refill-modal .modal-footer > button,
.refill-modal .modal-footer .refill-footer-right {
width: 100%;
}
.refill-modal .modal-footer .refill-footer-right {
justify-content: space-between;
}
.refill-modal .modal-footer .refill-footer-right button {
flex: 1;
}
}
/* Inline refill form in edit modal */
.refill-form-inline {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.refill-form-inline label {
flex: 1;
min-width: 100px;
}
.refill-form-inline label input {
width: 100%;
}
.refill-form-inline button {
flex-shrink: 0;
margin-bottom: 0;
align-self: flex-end;
height: 42px;
padding: 0 1rem;
min-width: 110px;
}
.refill-preview {
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0 0.75rem;
background: transparent;
border: 1px dashed var(--success);
border-radius: 6px;
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
align-self: flex-end;
box-sizing: border-box;
}
.refill-section {
border-left: 3px solid var(--success);
background: var(--bg-secondary);
margin-top: 1.5rem;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 8px 8px 0;
}
.refill-section .refill-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem 0;
}
.refill-section .refill-form-inline button {
background: var(--success);
color: white;
border: none;
}
.refill-section .refill-form-inline button:hover:not(:disabled) {
background: var(--success-hover, #3aa865);
filter: brightness(1.1);
}
.refill-section .refill-form-inline button:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Clickable section header (for expand/collapse) */
.section-header-clickable {
cursor: pointer;
user-select: none;
}
.section-header-clickable:hover {
color: var(--accent);
}
/* Refill history in detail modal */
.refill-history-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.refill-history-header .collapse-icon {
font-size: 0.75rem;
color: var(--text-secondary);
}
.refill-history-header .refill-count {
font-weight: normal;
font-size: 0.875rem;
color: var(--text-secondary);
}
.refill-history-list {
margin-top: 0.75rem;
}
.refill-history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-primary);
}
.refill-history-item:last-child {
border-bottom: none;
}
.refill-date {
font-size: 0.875rem;
color: var(--text-secondary);
}
.refill-details {
font-size: 0.875rem;
font-weight: 500;
color: var(--success);
}
/* Nested modal overlay */
.modal-overlay.nested {
background: rgba(0, 0, 0, 0.6);
}
/* =============================================================================
Shared Schedule Page (Public)
============================================================================= */
@@ -3977,3 +4355,60 @@ h3 .reminder-icon.info-tooltip {
margin-top: 0.5rem;
width: 100%;
}
/* Action Cards (for Export/Import etc.) - similar to radio-card */
.action-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: 12px;
transition: all 0.2s ease;
}
.action-card:hover {
border-color: var(--border-primary);
}
.action-card-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.action-card-title {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
}
.action-card-desc {
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.4;
}
.action-card button,
.action-card .btn {
flex-shrink: 0;
}
@media (max-width: 480px) {
.action-card {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.action-card button,
.action-card .btn {
width: 100%;
text-align: center;
justify-content: center;
}
}