Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbd2661498 | |||
| 89edd74de3 | |||
| bafde8f689 | |||
| 30d72f625d | |||
| cea1a8b119 | |||
| 3aa2b608b0 | |||
| e24a540f17 | |||
| fae96c9fdd | |||
| 11b55fc638 | |||
| b68c0b0737 | |||
| 8fc9fac8f5 | |||
| 1920b47924 | |||
| 857b1462e3 | |||
| 813aa0faf9 | |||
| 75bb7abebc | |||
| bb46b26ec6 | |||
| 8d22669bef | |||
| fb0b3df794 | |||
| 48ae48a165 | |||
| a190667320 | |||
| cfdca04df9 | |||
| a28e3724ae | |||
| 42d00dd1c0 | |||
| 8928915947 | |||
| cfd37ca526 | |||
| 288e075786 | |||
| 13c6430dee | |||
| ec3793dd05 | |||
| d5f6ceba19 | |||
| 6f0553d7dd | |||
| 82b2be48cd | |||
| 269a549563 | |||
| 055c0dfe10 | |||
| 318f63657b | |||
| 718157e472 | |||
| f00f11aa55 | |||
| 4081e03970 | |||
| 9cfbf89d46 | |||
| ffab9ef4da | |||
| ed707444a2 | |||
| d0a40bde88 | |||
| e754729e08 |
@@ -78,4 +78,44 @@ REMINDER_DAYS_BEFORE=7
|
|||||||
# Admin settings (not editable in UI)
|
# Admin settings (not editable in UI)
|
||||||
REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM
|
REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM
|
||||||
REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder
|
REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder
|
||||||
EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Default User Settings (applied when new user is created)
|
||||||
|
# =============================================================================
|
||||||
|
# These ENV values are only used as DEFAULTS when a new user is created.
|
||||||
|
# Once a user saves their settings in the app, these ENV values are ignored
|
||||||
|
# for that user - their saved preferences take precedence.
|
||||||
|
#
|
||||||
|
# Useful for server admins to pre-configure settings for all new users.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Email notifications (requires SMTP config above)
|
||||||
|
# DEFAULT_EMAIL_ENABLED=false
|
||||||
|
# DEFAULT_NOTIFICATION_EMAIL=
|
||||||
|
# DEFAULT_EMAIL_STOCK_REMINDERS=true
|
||||||
|
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
|
||||||
|
|
||||||
|
# Push notifications (ntfy/gotify via Shoutrrr)
|
||||||
|
# DEFAULT_SHOUTRRR_ENABLED=false
|
||||||
|
# DEFAULT_SHOUTRRR_URL=
|
||||||
|
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
|
||||||
|
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
|
||||||
|
|
||||||
|
# Repeat/nagging reminders for missed doses
|
||||||
|
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
|
||||||
|
# DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES=30
|
||||||
|
# DEFAULT_MAX_NAGGING_REMINDERS=5
|
||||||
|
# DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES=false
|
||||||
|
|
||||||
|
# Stock reminder settings
|
||||||
|
# DEFAULT_REPEAT_DAILY_REMINDERS=false
|
||||||
|
|
||||||
|
# Stock thresholds (days of supply)
|
||||||
|
# DEFAULT_LOW_STOCK_DAYS=30
|
||||||
|
# DEFAULT_NORMAL_STOCK_DAYS=90
|
||||||
|
# DEFAULT_HIGH_STOCK_DAYS=180
|
||||||
|
|
||||||
|
# UI defaults
|
||||||
|
# DEFAULT_LANGUAGE=en # en or de
|
||||||
|
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# MedAssist-ng - AI Coding Instructions
|
# MedAssist-ng - AI Coding Instructions
|
||||||
|
|
||||||
|
## General Rules
|
||||||
|
|
||||||
|
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
|
||||||
|
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
|
||||||
|
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository.
|
||||||
|
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
|
||||||
|
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
|
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
|
||||||
@@ -41,25 +49,25 @@ cd backend && npm run test:coverage # Run with coverage report
|
|||||||
|
|
||||||
## Testing (MANDATORY)
|
## Testing (MANDATORY)
|
||||||
|
|
||||||
> ⚠️ **WICHTIG**: Jede neue Funktionalität MUSS mit Tests abgedeckt werden!
|
> ⚠️ **IMPORTANT**: Every new feature MUST be covered by tests!
|
||||||
> Pull Requests ohne Tests für neue Features werden nicht akzeptiert.
|
> Pull Requests without tests for new features will not be accepted.
|
||||||
|
|
||||||
### Test-Framework
|
### Test Framework
|
||||||
- **Vitest 2.1** mit v8 Coverage
|
- **Vitest 2.1** with v8 Coverage
|
||||||
- Tests in `backend/src/test/*.test.ts`
|
- Tests in `backend/src/test/*.test.ts`
|
||||||
- Coverage-Ziel: Mindestens gleiche oder bessere Coverage nach Änderungen
|
- Coverage goal: At least equal or better coverage after changes
|
||||||
|
|
||||||
### Test-Struktur
|
### Test Structure
|
||||||
| Datei | Testet |
|
| File | Tests |
|
||||||
|-------|--------|
|
|------|-------|
|
||||||
| `routes.test.ts` | API-Endpunkte (Auth, Medications, Doses, Settings, Share, Planner) |
|
| `routes.test.ts` | API endpoints (Auth, Medications, Doses, Settings, Share, Planner) |
|
||||||
| `services.test.ts` | Scheduler-Utilities (Timezone, Blisters, Usage-Berechnung) |
|
| `services.test.ts` | Scheduler utilities (Timezone, Blisters, Usage calculation) |
|
||||||
| `db.test.ts` | Datenbank-Schema und Operationen |
|
| `db.test.ts` | Database schema and operations |
|
||||||
|
|
||||||
### Tests schreiben
|
### Writing Tests
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Backend Test Beispiel (backend/src/test/example.test.ts)
|
// Backend Test Example (backend/src/test/example.test.ts)
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
|
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
|
||||||
|
|
||||||
@@ -90,21 +98,24 @@ describe('Feature Name', () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test-Commands
|
### Test Commands
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm test # Alle Tests ausführen
|
CI=true npm test # Run tests once (ALWAYS run this way!)
|
||||||
npm run test:coverage # Mit Coverage-Report
|
CI=true npm run test:coverage # With coverage report
|
||||||
npm test -- --watch # Watch-Mode für Entwicklung
|
npm test -- --watch # Watch mode for manual development
|
||||||
npm test -- -t "test name" # Einzelnen Test ausführen
|
npm test -- -t "test name" # Run single test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> ⚠️ **IMPORTANT for AI agents**: ALWAYS run tests with `CI=true`!
|
||||||
|
> Without `CI=true`, Vitest runs in watch mode and waits for input.
|
||||||
|
|
||||||
## CI/CD Pipeline (GitHub Actions)
|
## CI/CD Pipeline (GitHub Actions)
|
||||||
|
|
||||||
### Workflow-Übersicht
|
### Workflow Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
Pull Request erstellt
|
Pull Request created
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ test.yml │
|
│ test.yml │
|
||||||
@@ -116,65 +127,187 @@ Pull Request erstellt
|
|||||||
│ ├─ npm ci │
|
│ ├─ npm ci │
|
||||||
│ └─ npm run build │
|
│ └─ npm run build │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
↓ Tests müssen bestehen
|
↓ Tests must pass
|
||||||
PR kann gemerged werden
|
PR can be merged
|
||||||
↓
|
↓
|
||||||
Push to main / Tag erstellt
|
Push to main / Tag created
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ docker-build.yml │
|
│ docker-build.yml │
|
||||||
│ ├─ backend-test (parallel) │
|
│ ├─ backend-test (parallel) │
|
||||||
│ ├─ frontend-build (parallel) │
|
│ ├─ frontend-build (parallel) │
|
||||||
│ └─ build-and-push (nach Tests) │
|
│ └─ build-and-push (after tests) │
|
||||||
│ ├─ Docker Images bauen │
|
│ ├─ Build Docker images │
|
||||||
│ └─ Push zu GHCR │
|
│ └─ Push to GHCR │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Branch Protection
|
### Branch Protection
|
||||||
|
|
||||||
> ⚠️ **WICHTIG**: Der `main` Branch ist geschützt!
|
> ⚠️ **IMPORTANT**: The `main` branch is protected!
|
||||||
> Direktes Pushen nach `main` ist **nicht möglich** - GitHub lehnt den Push ab.
|
> Direct pushing to `main` is **not possible** - GitHub will reject the push.
|
||||||
> Alle Änderungen müssen über Pull Requests erfolgen.
|
> All changes must go through Pull Requests.
|
||||||
|
|
||||||
- **main** Branch ist geschützt (Repository Rules)
|
- **main** branch is protected (Repository Rules)
|
||||||
- Direktes Pushen wird von GitHub abgelehnt mit: `GH013: Repository rule violations`
|
- Direct pushing is rejected by GitHub with: `GH013: Repository rule violations`
|
||||||
- PRs benötigen:
|
- PRs require:
|
||||||
- ✅ `backend-test` Status Check bestanden
|
- ✅ `backend-test` Status Check passed
|
||||||
- ✅ `frontend-build` Status Check bestanden
|
- ✅ `frontend-build` Status Check passed
|
||||||
- Nach erfolgreichem Merge wird der Feature-Branch automatisch gelöscht
|
- After successful merge, the feature branch is automatically deleted
|
||||||
|
|
||||||
**Workflow für Änderungen:**
|
**Workflow for changes:**
|
||||||
```bash
|
```bash
|
||||||
# 1. Feature Branch erstellen
|
# 1. Create feature branch
|
||||||
git checkout -b feat/mein-feature
|
git checkout -b feat/my-feature
|
||||||
|
|
||||||
# 2. Änderungen committen und pushen
|
# 2. Commit and push changes
|
||||||
git add . && git commit -m "feat: Beschreibung"
|
git add . && git commit -m "feat: Description"
|
||||||
git push -u origin feat/mein-feature
|
git push -u origin feat/my-feature
|
||||||
|
|
||||||
# 3. PR erstellen (via GitHub CLI oder Web)
|
# 3. Create PR (via GitHub CLI or Web)
|
||||||
gh pr create --title "Mein Feature" --body "Beschreibung"
|
gh pr create --title "My Feature" --body "Description"
|
||||||
|
|
||||||
# 4. Warten bis CI grün ist, dann mergen
|
# 4. Wait until CI is green, then merge
|
||||||
gh pr merge --squash --delete-branch
|
gh pr merge --squash --delete-branch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Workflow-Dateien
|
### Workflow Files
|
||||||
| Datei | Trigger | Zweck |
|
| File | Trigger | Purpose |
|
||||||
|-------|---------|-------|
|
|------|---------|--------|
|
||||||
| `.github/workflows/test.yml` | Pull Requests | Tests ausführen, PR blockieren bei Fehlern |
|
| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
|
||||||
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Docker Images bauen und pushen |
|
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images |
|
||||||
|
|
||||||
### Neuen Code hinzufügen - Checkliste
|
### Adding New Code - Checklist
|
||||||
1. ✅ Feature implementieren
|
1. ✅ Implement feature
|
||||||
2. ✅ Tests für das Feature schreiben
|
2. ✅ Write tests for the feature
|
||||||
3. ✅ Lokal `npm run test:coverage` ausführen
|
3. ✅ Run `npm run test:coverage` locally
|
||||||
4. ✅ Coverage darf nicht sinken
|
4. ✅ Coverage must not decrease
|
||||||
5. ✅ Feature Branch erstellen und pushen
|
5. ✅ Create and push feature branch
|
||||||
6. ✅ Pull Request erstellen
|
6. ✅ Create Pull Request
|
||||||
7. ✅ Warten bis CI grün ist
|
7. ✅ Wait until CI is green
|
||||||
8. ✅ PR mergen (Branch wird automatisch gelöscht)
|
8. ✅ Merge PR (branch is automatically deleted)
|
||||||
|
|
||||||
|
## GitHub Releases
|
||||||
|
|
||||||
|
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
|
||||||
|
|
||||||
|
### Release Workflow (MANDATORY for minor/major releases)
|
||||||
|
|
||||||
|
The `main` branch is protected - releases must go through the automated release script.
|
||||||
|
|
||||||
|
**Release Process:**
|
||||||
|
```bash
|
||||||
|
# 1. Run release script (creates PR, waits for CI, merges, creates tag)
|
||||||
|
./scripts/release.sh [patch|minor|major]
|
||||||
|
|
||||||
|
# 2. GitHub Actions creates a DRAFT release automatically
|
||||||
|
# 3. User asks AI to write release notes:
|
||||||
|
# "Write the release notes for vX.Y.Z"
|
||||||
|
# 4. AI writes descriptive release notes following the style guide below
|
||||||
|
# 5. User publishes the draft release with the written notes
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
|
||||||
|
> Do NOT just publish the auto-generated commit list. Follow the process above.
|
||||||
|
|
||||||
|
**AI Assistant Release Notes Workflow:**
|
||||||
|
1. When user asks to write release notes for a version:
|
||||||
|
- Check commits since previous tag: `git log vPREV..vNEW --oneline`
|
||||||
|
- Read through the changes to understand what was added/fixed
|
||||||
|
- Write release notes following the style guide below
|
||||||
|
- Present the notes to the user for copying to GitHub
|
||||||
|
|
||||||
|
### Creating Release Notes
|
||||||
|
|
||||||
|
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||||
|
> Not just auto-generated commit lists, but a brief descriptive text.
|
||||||
|
|
||||||
|
**Keep it 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
|
## Key Patterns
|
||||||
|
|
||||||
@@ -283,9 +416,25 @@ Each blister defines a recurring intake:
|
|||||||
| Stock Thresholds | Warning days, critical days, expiry warning days |
|
| Stock Thresholds | Warning days, critical days, expiry warning days |
|
||||||
| Email Notifications | Enable, email address, stock/intake toggles |
|
| Email Notifications | Enable, email address, stock/intake toggles |
|
||||||
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles |
|
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles |
|
||||||
| Reminder Settings | Days before, repeat daily |
|
| Reminder Settings | Days before, repeat daily, skip for taken, repeat/nagging |
|
||||||
| SMTP | Email config (read-only from .env) |
|
| SMTP | Email config (read-only from .env) |
|
||||||
|
|
||||||
|
### Settings ENV Defaults
|
||||||
|
All user settings can be pre-configured via ENV variables (see `.env.example`).
|
||||||
|
These are only used as **defaults when a new user is created**.
|
||||||
|
Once a user saves settings in the app, their saved values take precedence over ENV.
|
||||||
|
|
||||||
|
| ENV Variable | Setting | Default |
|
||||||
|
|--------------|---------|---------|
|
||||||
|
| `DEFAULT_EMAIL_ENABLED` | Email notifications | false |
|
||||||
|
| `DEFAULT_SHOUTRRR_ENABLED` | Push notifications | false |
|
||||||
|
| `DEFAULT_SHOUTRRR_URL` | ntfy/gotify URL | (empty) |
|
||||||
|
| `DEFAULT_REPEAT_REMINDERS_ENABLED` | Nagging reminders | false |
|
||||||
|
| `DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES` | Nag interval | 30 |
|
||||||
|
| `DEFAULT_MAX_NAGGING_REMINDERS` | Max nags | 5 |
|
||||||
|
| `DEFAULT_LOW_STOCK_DAYS` | Low stock threshold | 30 |
|
||||||
|
| `DEFAULT_LANGUAGE` | UI language | en |
|
||||||
|
|
||||||
## Database Schema (`backend/src/db/schema.ts`)
|
## Database Schema (`backend/src/db/schema.ts`)
|
||||||
|
|
||||||
| Table | Description |
|
| Table | Description |
|
||||||
@@ -347,14 +496,75 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
|||||||
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
||||||
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
||||||
|
|
||||||
## Database Schema Changes
|
## Database Schema Changes (IMPORTANT: Backward Compatibility!)
|
||||||
|
|
||||||
When adding new database columns:
|
> ⚠️ **CRITICAL**: The app MUST remain backward compatible with older databases!
|
||||||
|
> Users upgrade their Docker containers but keep their existing DB.
|
||||||
|
> The app must NOT crash if old columns are missing.
|
||||||
|
|
||||||
1. **Update schema**: `backend/src/db/schema.ts` - Add the Drizzle column definition
|
### ⚠️ MANDATORY for EVERY New Feature
|
||||||
2. **Update client.ts**: `backend/src/db/client.ts` - Add column to `CREATE TABLE IF NOT EXISTS`
|
|
||||||
3. **Update migrate.ts**: `backend/src/db/migrate.ts` - Same as client.ts
|
**Before implementing ANY feature that touches user data or settings:**
|
||||||
4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart
|
|
||||||
|
1. **Check if new DB columns are needed** - Does the feature require storing new data?
|
||||||
|
2. **If YES → Follow ALL steps below** - Schema.ts + Drizzle migration + ALTER migration + NULL-safe code
|
||||||
|
3. **NEVER skip the ALTER migration** - This is the #1 cause of production 500 errors!
|
||||||
|
|
||||||
|
**Common mistake:** Adding a column to `schema.ts` and forgetting the ALTER migration in `client.ts`.
|
||||||
|
The Drizzle migration only works for NEW databases. Existing production databases need the ALTER migration!
|
||||||
|
|
||||||
|
### Schema Management with Drizzle Kit
|
||||||
|
|
||||||
|
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
|
||||||
|
|
||||||
|
- **`backend/src/db/schema.ts`** - Drizzle ORM schema definitions (TypeScript)
|
||||||
|
- **`backend/drizzle/`** - Generated SQL migrations (auto-generated from schema.ts)
|
||||||
|
|
||||||
|
**DO NOT manually edit migration files!** They are generated from schema.ts.
|
||||||
|
|
||||||
|
### Adding New Columns
|
||||||
|
|
||||||
|
1. **Add to schema.ts** with DEFAULT value:
|
||||||
|
```typescript
|
||||||
|
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate migration**:
|
||||||
|
```bash
|
||||||
|
cd backend && npx drizzle-kit generate --name add_column_name
|
||||||
|
```
|
||||||
|
|
||||||
|
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`,
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **NULL-safe reading** in routes:
|
||||||
|
```typescript
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules for New Columns
|
||||||
|
|
||||||
|
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
|
## File Locations
|
||||||
|
|
||||||
@@ -362,6 +572,8 @@ When adding new database columns:
|
|||||||
|---------|----------|
|
|---------|----------|
|
||||||
| Backend entry | `backend/src/index.ts` |
|
| Backend entry | `backend/src/index.ts` |
|
||||||
| Database schema | `backend/src/db/schema.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 routes | `backend/src/routes/*.ts` |
|
||||||
| Backend services | `backend/src/services/*.ts` |
|
| Backend services | `backend/src/services/*.ts` |
|
||||||
| Frontend app | `frontend/src/App.tsx` |
|
| Frontend app | `frontend/src/App.tsx` |
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ name: Build and Push Docker Images
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'docker-compose*.yml'
|
||||||
|
- '.github/workflows/docker-build.yml'
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -113,6 +118,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Create GitHub Release (only on tag push)
|
# Create GitHub Release (only on tag push)
|
||||||
|
|||||||
@@ -16,27 +16,63 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate changelog
|
- name: Get version info
|
||||||
id: changelog
|
id: version
|
||||||
run: |
|
run: |
|
||||||
|
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
VERSION=${CURRENT_TAG#v}
|
||||||
|
echo "tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Get previous tag
|
# Get previous tag
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
||||||
|
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||||
if [ -z "$PREV_TAG" ]; then
|
PREV_TAG=""
|
||||||
# First release - get all commits
|
|
||||||
CHANGES=$(git log --pretty=format:"- %s" HEAD)
|
|
||||||
else
|
|
||||||
# Get commits since last tag
|
|
||||||
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
|
|
||||||
fi
|
fi
|
||||||
|
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||||
# Write to file for multiline support
|
|
||||||
echo "$CHANGES" > changelog.txt
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Generate release template
|
||||||
|
run: |
|
||||||
|
cat > release_notes.md << 'EOF'
|
||||||
|
## What's New
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Write 1-2 sentences describing the main changes in this release.
|
||||||
|
Example: This release introduces a medication refill tracking feature and improves the mobile user experience.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
<!-- List new features with **bold** names and descriptions -->
|
||||||
|
- **Feature Name**: Description of the feature
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
<!-- List improvements and fixes -->
|
||||||
|
- **Improvement**: Description
|
||||||
|
|
||||||
|
### Where to Find It
|
||||||
|
|
||||||
|
<!-- Tell users where they can access new features -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/danielvolz/medassist-ng-backend:${{ steps.version.outputs.version }}
|
||||||
|
docker pull ghcr.io/danielvolz/medassist-ng-frontend:${{ steps.version.outputs.version }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/${{ steps.version.outputs.previous_tag }}...${{ steps.version.outputs.tag }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Create Draft Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
body_path: changelog.txt
|
body_path: release_notes.md
|
||||||
generate_release_notes: true
|
draft: true
|
||||||
|
generate_release_notes: false
|
||||||
|
name: "Release ${{ steps.version.outputs.tag }}"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,33 +1,73 @@
|
|||||||
# Node
|
# ===================
|
||||||
|
# Dependencies
|
||||||
|
# ===================
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Build outputs
|
||||||
|
# ===================
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.tmp/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Test & Coverage
|
||||||
|
# ===================
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Environment
|
||||||
|
# ===================
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Database & Data
|
||||||
|
# ===================
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
data/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Logs
|
||||||
|
# ===================
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Build outputs
|
# ===================
|
||||||
dist/
|
# OS files
|
||||||
build/
|
# ===================
|
||||||
coverage/
|
|
||||||
.tmp/
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# SQLite
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
*.db-journal
|
|
||||||
backend/data/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Editor
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# IDE / Editor
|
||||||
|
# ===================
|
||||||
|
.idea/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Keep shared VS Code settings
|
||||||
|
# .vscode/ is NOT ignored - settings.json is useful for the team
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Misc
|
||||||
|
# ===================
|
||||||
|
*.local
|
||||||
|
.cache/
|
||||||
|
.turbo/
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"vitest.root": "backend",
|
||||||
|
"vitest.enable": true,
|
||||||
|
"vitest.commandLine": "npm test --"
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
> **Think of this app as a helpful tool, but make all health decisions independently!**
|
> **Think of this app as a helpful tool, but make all health decisions independently!**
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
|
- [Screenshots](#screenshots)
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
@@ -38,11 +39,91 @@
|
|||||||
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
|
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
|
||||||
</p>
|
</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
|
### Smart Inventory
|
||||||
- Track exact stock: packs, blisters, and loose pills
|
- Track exact stock: packs, blisters, and loose pills
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
- Automatic calculation based on intake schedule
|
- 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
|
### Flexible Schedules
|
||||||
- Daily, weekly, or custom intervals per medication
|
- Daily, weekly, or custom intervals per medication
|
||||||
- Independent schedules for each medication
|
- Independent schedules for each medication
|
||||||
@@ -60,9 +141,14 @@
|
|||||||
- Manage medications for multiple people
|
- Manage medications for multiple people
|
||||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
- 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
|
### Notifications
|
||||||
- Email via SMTP
|
- 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
|
- Supports both stock warnings and intake reminders
|
||||||
|
|
||||||
### Privacy & Security
|
### Privacy & Security
|
||||||
@@ -148,6 +234,54 @@ Generate secrets with: `openssl rand -hex 32`
|
|||||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
| `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
|
# Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
|
# Copy drizzle migrations folder (required for database setup)
|
||||||
|
COPY drizzle ./drizzle
|
||||||
|
|
||||||
# Create data directory and set ownership to node user (UID 1000)
|
# Create data directory and set ownership to node user (UID 1000)
|
||||||
RUN mkdir -p /app/data && chown -R node:node /app
|
RUN mkdir -p /app/data && chown -R node:node /app
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `medications` ADD `stock_adjustment` integer DEFAULT 0 NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `medications` ADD `last_stock_correction_at` integer;
|
||||||
@@ -0,0 +1,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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "bcb60728-38c0-4965-adac-829c02240d89",
|
||||||
|
"prevId": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stock_adjustment": {
|
||||||
|
"name": "stock_adjustment",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "098ee506-e43d-4ccb-bee5-c387905695ab",
|
||||||
|
"prevId": "bcb60728-38c0-4965-adac-829c02240d89",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stock_adjustment": {
|
||||||
|
"name": "stock_adjustment",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"last_stock_correction_at": {
|
||||||
|
"name": "last_stock_correction_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768600500759,
|
||||||
|
"tag": "0000_init",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768734577830,
|
||||||
|
"tag": "0001_add_stock_adjustment",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768736677092,
|
||||||
|
"tag": "0002_add_last_stock_correction_at",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.0.2",
|
"version": "1.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@libsql/client": "^0.10.0",
|
"@libsql/client": "^0.10.0",
|
||||||
"argon2": "^0.40.0",
|
"argon2": "^0.40.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.32.2",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"openid-client": "^6.8.1",
|
"openid-client": "^6.8.1",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/nodemailer": "^6.4.21",
|
"@types/nodemailer": "^6.4.21",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { createClient, Client } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
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 dotenv from "dotenv";
|
||||||
import { getTableCreationSQL } from "./schema-sql.js";
|
|
||||||
|
|
||||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
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
|
// Exported utility functions for testing
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -44,19 +50,69 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
|
/** Run drizzle-kit migrations on the database */
|
||||||
export { getTableCreationSQL } from "./schema-sql.js";
|
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 */
|
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||||
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||||
const tableCreations = getTableCreationSQL();
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const sql of tableCreations) {
|
// These add new columns to existing tables (silently fail if column already exists)
|
||||||
|
const alterMigrations = [
|
||||||
|
// Added in v1.x - repeat reminders and nagging settings
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||||
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||||
|
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||||
|
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of alterMigrations) {
|
||||||
try {
|
try {
|
||||||
await client.execute(sql);
|
await client.execute(sql);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
errors.push(e.message);
|
// Silently ignore "duplicate column" errors - column already exists
|
||||||
|
if (!e.message?.includes("duplicate column")) {
|
||||||
|
errors.push(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tables that might be missing (silently fail if already exists)
|
||||||
|
const createTableMigrations = [
|
||||||
|
// Added in v1.3.x - refill history tracking
|
||||||
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of createTableMigrations) {
|
||||||
|
try {
|
||||||
|
await client.execute(sql);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Silently ignore "table already exists" errors
|
||||||
|
if (!e.message?.includes("already exists")) {
|
||||||
|
errors.push(e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +182,19 @@ export const db = drizzle(client);
|
|||||||
|
|
||||||
// Auto-run migrations (self-healing database)
|
// Auto-run migrations (self-healing database)
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
const result = await runTableMigrations(client);
|
// Run drizzle-kit generated migrations
|
||||||
if (result.errors.length > 0) {
|
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||||
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
|
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`);
|
console.log(`[DB] Tables verified/created`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,45 @@
|
|||||||
import { createClient, Client } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import fs from "fs";
|
import { resolve, dirname } from "path";
|
||||||
import path from "path";
|
import { fileURLToPath } from "url";
|
||||||
import { getTableCreationSQL } from "./schema-sql.js";
|
|
||||||
|
|
||||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
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
|
// Exported utility functions for testing
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/** Get the full migration SQL string (re-exported from schema-sql) */
|
/** Split SQL string into individual statements (for backwards compatibility with tests) */
|
||||||
export { getTableCreationSQL };
|
|
||||||
|
|
||||||
/** Split SQL string into individual statements */
|
|
||||||
export function splitSQLStatements(sql: string): string[] {
|
export function splitSQLStatements(sql: string): string[] {
|
||||||
return sql.split(';').filter(s => s.trim().length > 0);
|
return sql.split(';').filter(s => s.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Execute migration statements on a client */
|
/** Execute drizzle migrations on a database */
|
||||||
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let executed = 0;
|
const db = drizzle(client);
|
||||||
|
|
||||||
for (const stmt of statements) {
|
try {
|
||||||
try {
|
await migrate(db, { migrationsFolder });
|
||||||
await client.execute(stmt);
|
|
||||||
executed++;
|
// Count tables as a proxy for "executed" statements
|
||||||
} catch (err: any) {
|
const tables = await client.execute(
|
||||||
errors.push(err.message);
|
"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) */
|
/** Get a preview of statement (first N characters) */
|
||||||
@@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db";
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log("Starting database setup...");
|
console.log("Starting database setup...");
|
||||||
console.log("Database URL:", url);
|
console.log("Database URL:", url);
|
||||||
|
console.log("Migrations folder:", migrationsFolder);
|
||||||
|
|
||||||
const client = createClient({ url });
|
const client = createClient({ url });
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
const statements = getTableCreationSQL();
|
console.log("Running drizzle migrations...");
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
for (const stmt of statements) {
|
|
||||||
console.log("Executing:", getStatementPreview(stmt));
|
|
||||||
await client.execute(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Database setup complete!");
|
console.log("Database setup complete!");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export function getTableCreationSQL(): string[] {
|
|||||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||||
|
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||||
|
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
|
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||||
|
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||||
low_stock_days integer NOT NULL DEFAULT 30,
|
low_stock_days integer NOT NULL DEFAULT 30,
|
||||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
@@ -93,6 +97,17 @@ export function getTableCreationSQL(): string[] {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
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
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const medications = sqliteTable("medications", {
|
|||||||
packCount: integer("pack_count").notNull().default(1),
|
packCount: integer("pack_count").notNull().default(1),
|
||||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||||
looseTablets: integer("loose_tablets").notNull().default(0),
|
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
|
||||||
|
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
|
||||||
|
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
|
||||||
pillWeightMg: integer("pill_weight_mg"),
|
pillWeightMg: integer("pill_weight_mg"),
|
||||||
usageJson: text("usage_json").notNull().default("[]"),
|
usageJson: text("usage_json").notNull().default("[]"),
|
||||||
everyJson: text("every_json").notNull().default("[]"),
|
everyJson: text("every_json").notNull().default("[]"),
|
||||||
@@ -60,10 +62,15 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
// Reminder settings
|
// Reminder settings
|
||||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||||
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
||||||
|
skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false),
|
||||||
|
repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
|
reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30),
|
||||||
|
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
||||||
// Stock thresholds (days)
|
// Stock thresholds (days)
|
||||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||||
|
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
@@ -111,4 +118,17 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
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'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
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'))`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { settingsRoutes } from "./routes/settings.js";
|
|||||||
import { plannerRoutes } from "./routes/planner.js";
|
import { plannerRoutes } from "./routes/planner.js";
|
||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { doseRoutes } from "./routes/doses.js";
|
import { doseRoutes } from "./routes/doses.js";
|
||||||
|
import { exportRoutes } from "./routes/export.js";
|
||||||
|
import { refillRoutes } from "./routes/refills.js";
|
||||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||||
|
|
||||||
@@ -113,6 +115,8 @@ export async function createApp(options?: {
|
|||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -181,6 +185,8 @@ await app.register(settingsRoutes);
|
|||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { doseTracking, shareTokens } from "../db/schema.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 { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
|
|||||||
doseId: z.string().min(1, "doseId is required"),
|
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
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: any, reply: any): Promise<number> {
|
||||||
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
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
|
// 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,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,579 @@
|
|||||||
|
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),
|
||||||
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
||||||
|
});
|
||||||
|
|
||||||
|
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(),
|
||||||
|
dismissed: z.boolean().default(false),
|
||||||
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
|
});
|
||||||
|
|
||||||
|
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}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
||||||
|
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number; person: string | null } | null {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Check if there's a person suffix (4th part onwards, could be multi-part name)
|
||||||
|
const person = parts.length > 3 ? parts.slice(3).join("-") : null;
|
||||||
|
|
||||||
|
return { medicationId, blisterIndex, timestampMs, person };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dose ID from parts (with optional person suffix)
|
||||||
|
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number, person?: string | null): string {
|
||||||
|
const base = `${medicationId}-${blisterIndex}-${timestampMs}`;
|
||||||
|
return person ? `${base}-${person}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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; includeImages?: string } }>(
|
||||||
|
"/export",
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const includeSensitive = request.query.includeSensitive === "true";
|
||||||
|
const includeImages = request.query.includeImages !== "false"; // Default to true
|
||||||
|
|
||||||
|
// 1. Load all medications
|
||||||
|
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Safely convert lastStockCorrectionAt to ISO string
|
||||||
|
let lastStockCorrectionAtIso: string | null = null;
|
||||||
|
if (med.lastStockCorrectionAt) {
|
||||||
|
try {
|
||||||
|
if (med.lastStockCorrectionAt instanceof Date && !isNaN(med.lastStockCorrectionAt.getTime())) {
|
||||||
|
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
|
||||||
|
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
|
||||||
|
const d = new Date(med.lastStockCorrectionAt);
|
||||||
|
lastStockCorrectionAtIso = !isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
lastStockCorrectionAtIso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
|
},
|
||||||
|
pillWeightMg: med.pillWeightMg,
|
||||||
|
schedules: parseBlistersForExport(med),
|
||||||
|
expiryDate: med.expiryDate,
|
||||||
|
notes: med.notes,
|
||||||
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
|
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||||
|
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Load all dose tracking entries
|
||||||
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
|
|
||||||
|
const exportDoseHistory = doses.map((dose) => {
|
||||||
|
const parsed = parseDoseId(dose.doseId);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const exportId = medIdToExportId.get(parsed.medicationId);
|
||||||
|
if (!exportId) return null; // Orphaned dose, skip
|
||||||
|
|
||||||
|
// Safely convert takenAt to ISO string
|
||||||
|
let takenAtIso: string;
|
||||||
|
try {
|
||||||
|
if (dose.takenAt instanceof Date && !isNaN(dose.takenAt.getTime())) {
|
||||||
|
takenAtIso = dose.takenAt.toISOString();
|
||||||
|
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
|
||||||
|
const d = new Date(dose.takenAt);
|
||||||
|
takenAtIso = !isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
takenAtIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
takenAtIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely convert scheduled time
|
||||||
|
let scheduledTimeIso: string;
|
||||||
|
try {
|
||||||
|
const d = new Date(parsed.timestampMs);
|
||||||
|
scheduledTimeIso = !isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} catch {
|
||||||
|
scheduledTimeIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicationRef: exportId,
|
||||||
|
scheduleIndex: parsed.blisterIndex,
|
||||||
|
scheduledTime: scheduledTimeIso,
|
||||||
|
takenAt: takenAtIso,
|
||||||
|
markedBy: dose.markedBy,
|
||||||
|
dismissed: dose.dismissed ?? false,
|
||||||
|
takenByPerson: parsed.person,
|
||||||
|
};
|
||||||
|
}).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) => {
|
||||||
|
// Safely convert expiresAt to ISO string
|
||||||
|
let expiresAtIso: string | null = null;
|
||||||
|
if (share.expiresAt) {
|
||||||
|
try {
|
||||||
|
if (share.expiresAt instanceof Date && !isNaN(share.expiresAt.getTime())) {
|
||||||
|
expiresAtIso = share.expiresAt.toISOString();
|
||||||
|
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
|
||||||
|
const d = new Date(share.expiresAt);
|
||||||
|
expiresAtIso = !isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
expiresAtIso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
takenBy: share.takenBy,
|
||||||
|
scheduleDays: share.scheduleDays,
|
||||||
|
expiresAt: expiresAtIso,
|
||||||
|
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",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
// Increase body limit to 50MB to handle exports with base64 images
|
||||||
|
rawBody: true,
|
||||||
|
},
|
||||||
|
bodyLimit: 50 * 1024 * 1024, // 50 MB
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||||
|
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();
|
||||||
|
// Rebuild dose ID with optional person suffix
|
||||||
|
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||||
|
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId,
|
||||||
|
doseId,
|
||||||
|
takenAt: new Date(dose.takenAt),
|
||||||
|
markedBy: dose.markedBy || null,
|
||||||
|
dismissed: dose.dismissed ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Read version from package.json at startup
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageJsonPath = resolve(__dirname, "../../package.json");
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||||
|
const backendVersion = packageJson.version || "unknown";
|
||||||
|
|
||||||
export async function healthRoutes(app: FastifyInstance) {
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
app.get("/health", async () => ({
|
||||||
|
status: "ok",
|
||||||
|
version: backendVersion,
|
||||||
|
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||||
|
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: row.blistersPerPack ?? 1,
|
blistersPerPack: row.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||||
looseTablets: row.looseTablets ?? 0,
|
looseTablets: row.looseTablets ?? 0,
|
||||||
|
stockAdjustment: row.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: row.pillWeightMg,
|
pillWeightMg: row.pillWeightMg,
|
||||||
blisters: parseBlisters(row),
|
blisters: parseBlisters(row),
|
||||||
imageUrl: row.imageUrl,
|
imageUrl: row.imageUrl,
|
||||||
@@ -147,6 +149,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: inserted.blistersPerPack,
|
blistersPerPack: inserted.blistersPerPack,
|
||||||
pillsPerBlister: inserted.pillsPerBlister,
|
pillsPerBlister: inserted.pillsPerBlister,
|
||||||
looseTablets: inserted.looseTablets,
|
looseTablets: inserted.looseTablets,
|
||||||
|
stockAdjustment: inserted.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: inserted.pillWeightMg,
|
pillWeightMg: inserted.pillWeightMg,
|
||||||
blisters,
|
blisters,
|
||||||
imageUrl: inserted.imageUrl,
|
imageUrl: inserted.imageUrl,
|
||||||
@@ -235,6 +239,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: result[0].blistersPerPack,
|
blistersPerPack: result[0].blistersPerPack,
|
||||||
pillsPerBlister: result[0].pillsPerBlister,
|
pillsPerBlister: result[0].pillsPerBlister,
|
||||||
looseTablets: result[0].looseTablets,
|
looseTablets: result[0].looseTablets,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: result[0].pillWeightMg,
|
pillWeightMg: result[0].pillWeightMg,
|
||||||
blisters,
|
blisters,
|
||||||
imageUrl: result[0].imageUrl,
|
imageUrl: result[0].imageUrl,
|
||||||
@@ -245,6 +251,41 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||||
|
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||||
|
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>("/medications/:id/stock-adjustment", async (req, reply) => {
|
||||||
|
const idNum = Number(req.params.id);
|
||||||
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
|
const { stockAdjustment } = req.body as { stockAdjustment: number };
|
||||||
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(medications)
|
||||||
|
.set({
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!result.length) return reply.notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result[0].id,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
|
updatedAt: result[0].updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
@@ -339,7 +380,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const packCount = row.packCount ?? 1;
|
const packCount = row.packCount ?? 1;
|
||||||
const blistersPerPack = row.blistersPerPack ?? 1;
|
const blistersPerPack = row.blistersPerPack ?? 1;
|
||||||
const looseTablets = row.looseTablets ?? 0;
|
const looseTablets = row.looseTablets ?? 0;
|
||||||
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
const stockAdjustment = row.stockAdjustment ?? 0;
|
||||||
|
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
|
|
||||||
// Calculate consumption up to now (same logic as frontend)
|
// Calculate consumption up to now (same logic as frontend)
|
||||||
let consumedUntilNow = 0;
|
let consumedUntilNow = 0;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ export type UserSettings = {
|
|||||||
shoutrrrIntakeReminders: boolean;
|
shoutrrrIntakeReminders: boolean;
|
||||||
reminderDaysBefore: number;
|
reminderDaysBefore: number;
|
||||||
repeatDailyReminders: boolean;
|
repeatDailyReminders: boolean;
|
||||||
|
skipRemindersForTakenDoses: boolean;
|
||||||
|
repeatRemindersEnabled: boolean;
|
||||||
|
reminderRepeatIntervalMinutes: number;
|
||||||
|
maxNaggingReminders: number;
|
||||||
lowStockDays: number;
|
lowStockDays: number;
|
||||||
normalStockDays: number;
|
normalStockDays: number;
|
||||||
highStockDays: number;
|
highStockDays: number;
|
||||||
@@ -45,6 +49,10 @@ type SettingsBody = {
|
|||||||
emailIntakeReminders: boolean;
|
emailIntakeReminders: boolean;
|
||||||
shoutrrrStockReminders: boolean;
|
shoutrrrStockReminders: boolean;
|
||||||
shoutrrrIntakeReminders: boolean;
|
shoutrrrIntakeReminders: boolean;
|
||||||
|
skipRemindersForTakenDoses: boolean;
|
||||||
|
repeatRemindersEnabled: boolean;
|
||||||
|
reminderRepeatIntervalMinutes: number;
|
||||||
|
maxNaggingReminders: number;
|
||||||
language: string;
|
language: string;
|
||||||
stockCalculationMode: "automatic" | "manual";
|
stockCalculationMode: "automatic" | "manual";
|
||||||
};
|
};
|
||||||
@@ -57,37 +65,58 @@ type TestShoutrrrBody = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default settings for new users
|
// Helper to parse boolean env vars
|
||||||
const defaultSettings = {
|
function envBool(key: string, defaultVal: boolean): boolean {
|
||||||
emailEnabled: false,
|
const val = process.env[key];
|
||||||
notificationEmail: null,
|
if (val === undefined) return defaultVal;
|
||||||
emailStockReminders: true,
|
return val === "true" || val === "1";
|
||||||
emailIntakeReminders: true,
|
}
|
||||||
shoutrrrEnabled: false,
|
|
||||||
shoutrrrUrl: null,
|
// Helper to parse integer env vars
|
||||||
shoutrrrStockReminders: true,
|
function envInt(key: string, defaultVal: number): number {
|
||||||
shoutrrrIntakeReminders: true,
|
const val = process.env[key];
|
||||||
reminderDaysBefore: 7,
|
if (val === undefined) return defaultVal;
|
||||||
repeatDailyReminders: false,
|
const parsed = parseInt(val, 10);
|
||||||
lowStockDays: 30,
|
return isNaN(parsed) ? defaultVal : parsed;
|
||||||
normalStockDays: 90,
|
}
|
||||||
highStockDays: 180,
|
|
||||||
language: "en",
|
// Default settings for new users - read from ENV with fallbacks
|
||||||
stockCalculationMode: "automatic" as const,
|
function getDefaultSettings() {
|
||||||
lastAutoEmailSent: null,
|
return {
|
||||||
lastNotificationType: null,
|
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||||
lastNotificationChannel: null,
|
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||||
};
|
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||||||
|
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
||||||
|
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
||||||
|
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
||||||
|
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
||||||
|
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
||||||
|
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
||||||
|
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
||||||
|
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
||||||
|
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
|
||||||
|
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
|
||||||
|
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
|
||||||
|
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
|
||||||
|
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
|
||||||
|
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
|
||||||
|
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||||
|
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||||
|
lastAutoEmailSent: null,
|
||||||
|
lastNotificationType: null,
|
||||||
|
lastNotificationChannel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get or create user settings
|
// Helper to get or create user settings
|
||||||
async function getOrCreateUserSettings(userId: number) {
|
async function getOrCreateUserSettings(userId: number) {
|
||||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
// Create default settings for user
|
// Create default settings for user (using ENV defaults)
|
||||||
[settings] = await db.insert(userSettings).values({
|
[settings] = await db.insert(userSettings).values({
|
||||||
userId,
|
userId,
|
||||||
...defaultSettings,
|
...getDefaultSettings(),
|
||||||
}).returning();
|
}).returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +138,10 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
|||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
reminderDaysBefore: settings.reminderDaysBefore,
|
reminderDaysBefore: settings.reminderDaysBefore,
|
||||||
repeatDailyReminders: settings.repeatDailyReminders,
|
repeatDailyReminders: settings.repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
lowStockDays: settings.lowStockDays,
|
lowStockDays: settings.lowStockDays,
|
||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
@@ -135,6 +168,10 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
|||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
reminderDaysBefore: settings.reminderDaysBefore,
|
reminderDaysBefore: settings.reminderDaysBefore,
|
||||||
repeatDailyReminders: settings.repeatDailyReminders,
|
repeatDailyReminders: settings.repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
lowStockDays: settings.lowStockDays,
|
lowStockDays: settings.lowStockDays,
|
||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
@@ -187,6 +224,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
emailIntakeReminders: settings.emailIntakeReminders,
|
emailIntakeReminders: settings.emailIntakeReminders,
|
||||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
|
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||||
|
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||||
// SMTP settings (from .env - shared/server-configured)
|
// SMTP settings (from .env - shared/server-configured)
|
||||||
@@ -233,6 +274,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||||||
reminderDaysBefore: body.reminderDaysBefore,
|
reminderDaysBefore: body.reminderDaysBefore,
|
||||||
repeatDailyReminders,
|
repeatDailyReminders,
|
||||||
|
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: body.maxNaggingReminders ?? 5,
|
||||||
lowStockDays: body.lowStockDays ?? 30,
|
lowStockDays: body.lowStockDays ?? 30,
|
||||||
normalStockDays: body.normalStockDays ?? 90,
|
normalStockDays: body.normalStockDays ?? 90,
|
||||||
highStockDays: body.highStockDays ?? 180,
|
highStockDays: body.highStockDays ?? 180,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Parse takenBy JSON array
|
// Parse takenBy JSON array
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
|
||||||
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
return {
|
return {
|
||||||
id: med.id,
|
id: med.id,
|
||||||
name: med.name,
|
name: med.name,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and, gte, lte } from "drizzle-orm";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { medications } from "../db/schema.js";
|
import { medications, doseTracking } from "../db/schema.js";
|
||||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
parseBlisters,
|
parseBlisters,
|
||||||
parseTakenByJson,
|
parseTakenByJson,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
|
getTodaysIntakes,
|
||||||
parseIntakeReminderState,
|
parseIntakeReminderState,
|
||||||
createDefaultIntakeReminderState,
|
createDefaultIntakeReminderState,
|
||||||
cleanOldIntakeReminders,
|
cleanOldIntakeReminders,
|
||||||
@@ -46,7 +47,13 @@ function parseBlistersFromRow(row: { usageJson: string; everyJson: string; start
|
|||||||
return parseBlisters(row);
|
return parseBlisters(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> {
|
async function sendIntakeReminderEmail(
|
||||||
|
email: string,
|
||||||
|
intakes: UpcomingIntake[],
|
||||||
|
language: Language,
|
||||||
|
isRepeat: boolean = false,
|
||||||
|
repeatIntervalMinutes?: number
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
const smtpUser = process.env.SMTP_USER;
|
const smtpUser = process.env.SMTP_USER;
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||||
@@ -96,11 +103,16 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
|
|||||||
? tr.intakeReminder.alertSingle
|
? tr.intakeReminder.alertSingle
|
||||||
: t(tr.intakeReminder.alertMultiple, { count: intakes.length });
|
: t(tr.intakeReminder.alertMultiple, { count: intakes.length });
|
||||||
|
|
||||||
|
// Different description for repeat reminders
|
||||||
|
const description = isRepeat && repeatIntervalMinutes
|
||||||
|
? `⚠️ Don't forget your medication! This reminder will be sent every ${repeatIntervalMinutes} minutes until you mark it as taken.`
|
||||||
|
: t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE });
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.intakeReminder.title}</h2>
|
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.intakeReminder.title}</h2>
|
||||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}</p>
|
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${description}</p>
|
||||||
|
|
||||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;">
|
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;">
|
||||||
<p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;">
|
<p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;">
|
||||||
@@ -142,7 +154,7 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
|
|||||||
|
|
||||||
const plainText = `${tr.intakeReminder.title}
|
const plainText = `${tr.intakeReminder.title}
|
||||||
|
|
||||||
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
|
${description}
|
||||||
|
|
||||||
${intakes.map((i) => {
|
${intakes.map((i) => {
|
||||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||||
@@ -152,7 +164,9 @@ ${intakes.map((i) => {
|
|||||||
---
|
---
|
||||||
${tr.intakeReminder.footer}`;
|
${tr.intakeReminder.footer}`;
|
||||||
|
|
||||||
const subject = t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
|
const subject = isRepeat
|
||||||
|
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") })}`
|
||||||
|
: t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
@@ -181,13 +195,18 @@ ${tr.intakeReminder.footer}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
|
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
|
||||||
|
logger.info(`[IntakeReminder] Checking for intake reminders...`);
|
||||||
|
|
||||||
// Get all user settings to iterate over each user
|
// Get all user settings to iterate over each user
|
||||||
const allUserSettings = await getAllUserSettings();
|
const allUserSettings = await getAllUserSettings();
|
||||||
|
|
||||||
if (allUserSettings.length === 0) {
|
if (allUserSettings.length === 0) {
|
||||||
|
logger.info(`[IntakeReminder] No users with settings found`);
|
||||||
return; // No users with settings
|
return; // No users with settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
|
||||||
|
|
||||||
for (const userSettings of allUserSettings) {
|
for (const userSettings of allUserSettings) {
|
||||||
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
||||||
}
|
}
|
||||||
@@ -200,56 +219,190 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
const language = settings.language;
|
const language = settings.language;
|
||||||
const tr = getTranslations(language);
|
const tr = getTranslations(language);
|
||||||
|
|
||||||
|
logger.info(`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`);
|
||||||
|
|
||||||
// Check if any intake reminder notifications are enabled (granular check)
|
// Check if any intake reminder notifications are enabled (granular check)
|
||||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||||
|
|
||||||
if (!emailEnabled && !shoutrrrEnabled) {
|
if (!emailEnabled && !shoutrrrEnabled) {
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
|
||||||
return; // No intake reminder notifications enabled for this user
|
return; // No intake reminder notifications enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// Get all medications with intake reminders enabled for this user
|
||||||
const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
|
const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
|
||||||
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
|
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (medsWithReminders.length === 0) {
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = loadIntakeReminderState();
|
logger.info(`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`);
|
||||||
const allUpcoming: UpcomingIntake[] = [];
|
|
||||||
const locale = getDateLocale(language);
|
|
||||||
|
|
||||||
// Find all upcoming intakes across all medications for this user
|
const state = loadIntakeReminderState();
|
||||||
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
|
const locale = getDateLocale(language);
|
||||||
|
const tz = getTimezone();
|
||||||
|
|
||||||
|
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`);
|
||||||
|
|
||||||
|
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||||
for (const med of medsWithReminders) {
|
for (const med of medsWithReminders) {
|
||||||
const blisters = parseBlistersFromRow(med);
|
const blisters = parseBlistersFromRow(med);
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale);
|
|
||||||
allUpcoming.push(...upcoming);
|
logger.info(`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters`);
|
||||||
|
|
||||||
|
// Process each blister separately to track blisterIndex
|
||||||
|
blisters.forEach((blister, blisterIndex) => {
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - start: ${blister.start}, every: ${blister.every} days, usage: ${blister.usage}`);
|
||||||
|
|
||||||
|
// Always get upcoming intakes (15 min before) for first reminders
|
||||||
|
const upcomingIntakes = getUpcomingIntakes(med.name, [blister], REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale, tz);
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`);
|
||||||
|
|
||||||
|
// Add upcoming intakes for first reminders
|
||||||
|
allUpcoming.push(...upcomingIntakes.map(intake => ({
|
||||||
|
...intake,
|
||||||
|
medicationId: med.id,
|
||||||
|
blisterIndex,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||||
|
if (settings.repeatRemindersEnabled) {
|
||||||
|
const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz);
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map(i => i.intakeTime.toISOString()).join(', ')}`);
|
||||||
|
const missedIntakes = allTodaysIntakes.filter(intake => intake.intakeTime.getTime() < now.getTime());
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${missedIntakes.length} missed intakes (past intake time)`);
|
||||||
|
|
||||||
|
// Add missed intakes for repeat reminders (only if not already in upcoming list)
|
||||||
|
const upcomingTimes = new Set(upcomingIntakes.map(i => i.intakeTime.getTime()));
|
||||||
|
allUpcoming.push(...missedIntakes
|
||||||
|
.filter(intake => !upcomingTimes.has(intake.intakeTime.getTime()))
|
||||||
|
.map(intake => ({
|
||||||
|
...intake,
|
||||||
|
medicationId: med.id,
|
||||||
|
blisterIndex,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
|
||||||
|
|
||||||
if (allUpcoming.length === 0) {
|
if (allUpcoming.length === 0) {
|
||||||
return; // No upcoming intakes in the window
|
logger.info(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
|
||||||
|
return; // No upcoming intakes for today
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out already-sent reminders (keyed by user)
|
// Determine which doses need reminders (new or repeated)
|
||||||
const newReminders = allUpcoming.filter(intake => {
|
const nowMs = Date.now();
|
||||||
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
let remindersToSend: typeof allUpcoming = [];
|
||||||
return !state.sentReminders.includes(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newReminders.length === 0) {
|
for (const intake of allUpcoming) {
|
||||||
return; // All reminders already sent
|
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
||||||
|
const existingEntry = state.reminders[key];
|
||||||
|
const intakeTimeMs = intake.intakeTime.getTime();
|
||||||
|
const isIntakePast = intakeTimeMs < nowMs;
|
||||||
|
|
||||||
|
if (!existingEntry) {
|
||||||
|
// New dose - always send first reminder (upcoming or already missed)
|
||||||
|
remindersToSend.push(intake);
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: First reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${isIntakePast ? 'missed' : 'upcoming'})`);
|
||||||
|
} else if (settings.repeatRemindersEnabled && isIntakePast) {
|
||||||
|
// Repeat reminder - only for intakes that are already past (missed)
|
||||||
|
const intervalMs = settings.reminderRepeatIntervalMinutes * 60 * 1000;
|
||||||
|
const timeSinceLastReminder = nowMs - existingEntry.lastSentAt;
|
||||||
|
const maxReminders = settings.maxNaggingReminders ?? 5;
|
||||||
|
|
||||||
|
if (existingEntry.sendCount >= maxReminders) {
|
||||||
|
// Max reminders reached - stop nagging
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Max reminders (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`);
|
||||||
|
} else if (timeSinceLastReminder >= intervalMs) {
|
||||||
|
remindersToSend.push(intake);
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Repeat reminder for missed "${intake.medName}" at ${intake.intakeTimeStr} (${existingEntry.sendCount + 1}/${maxReminders})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Else: Already sent and either repeats disabled or intake not yet past - skip
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remindersToSend.length === 0) {
|
||||||
|
return; // All reminders already sent and no repeats needed
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${newReminders.length} upcoming intakes...`);
|
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
|
||||||
|
if (settings.skipRemindersForTakenDoses) {
|
||||||
|
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
|
||||||
|
const takenToday = await db.select().from(doseTracking).where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, settings.userId),
|
||||||
|
gte(doseTracking.takenAt, todayStart),
|
||||||
|
lte(doseTracking.takenAt, todayEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const takenDoseIds = new Set(takenToday.map(d => d.doseId));
|
||||||
|
|
||||||
|
// Filter out reminders for doses that were already taken
|
||||||
|
remindersToSend = remindersToSend.filter(intake => {
|
||||||
|
const timestamp = intake.intakeTime.getTime();
|
||||||
|
|
||||||
|
// Check both with and without person suffix
|
||||||
|
if (intake.takenBy.length > 0) {
|
||||||
|
// For multi-person medications, check if any person has taken it
|
||||||
|
const anyTaken = intake.takenBy.some(person => {
|
||||||
|
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`;
|
||||||
|
return takenDoseIds.has(doseId);
|
||||||
|
});
|
||||||
|
return !anyTaken; // Skip if any person has taken it
|
||||||
|
} else {
|
||||||
|
// For non-person-specific medications
|
||||||
|
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`;
|
||||||
|
return !takenDoseIds.has(doseId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remindersToSend.length === 0) {
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
|
||||||
|
|
||||||
|
// Determine if this is a repeat reminder:
|
||||||
|
// - Any intake already has a state entry AND is past (repeat after first reminder)
|
||||||
|
// - OR intake is past even without state entry (missed the 15-min window)
|
||||||
|
const isRepeatReminder = remindersToSend.some(intake => {
|
||||||
|
const intakeTimeMs = intake.intakeTime.getTime();
|
||||||
|
const isIntakePast = intakeTimeMs < nowMs;
|
||||||
|
return isIntakePast; // Use repeat message for ANY missed intake
|
||||||
|
});
|
||||||
|
|
||||||
let emailSuccess = false;
|
let emailSuccess = false;
|
||||||
let shoutrrrSuccess = false;
|
let shoutrrrSuccess = false;
|
||||||
|
|
||||||
// Send email if enabled for intake reminders
|
// Send email if enabled for intake reminders
|
||||||
if (emailEnabled) {
|
if (emailEnabled) {
|
||||||
const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language);
|
const result = await sendIntakeReminderEmail(
|
||||||
|
settings.notificationEmail!,
|
||||||
|
remindersToSend,
|
||||||
|
language,
|
||||||
|
isRepeatReminder,
|
||||||
|
settings.reminderRepeatIntervalMinutes
|
||||||
|
);
|
||||||
emailSuccess = result.success;
|
emailSuccess = result.success;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
||||||
@@ -260,8 +413,15 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// Send Shoutrrr notification if enabled for intake reminders
|
// Send Shoutrrr notification if enabled for intake reminders
|
||||||
if (shoutrrrEnabled) {
|
if (shoutrrrEnabled) {
|
||||||
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
|
const title = isRepeatReminder
|
||||||
const message = newReminders
|
? (language === 'de' ? '⚠️ Medikamenten-Erinnerung' : '⚠️ Medication Reminder')
|
||||||
|
: t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
|
||||||
|
|
||||||
|
const repeatNote = isRepeatReminder && settings.reminderRepeatIntervalMinutes
|
||||||
|
? `\n\n⚠️ This reminder will be sent every ${settings.reminderRepeatIntervalMinutes} minutes until marked as taken.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const message = remindersToSend
|
||||||
.map((i) => {
|
.map((i) => {
|
||||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||||
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
|
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
|
||||||
@@ -271,7 +431,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
}
|
}
|
||||||
return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
|
return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n") + repeatNote;
|
||||||
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
@@ -284,14 +444,32 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// Update state if any notification was sent successfully
|
// Update state if any notification was sent successfully
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`);
|
// Update or create entries for sent reminders
|
||||||
|
for (const intake of remindersToSend) {
|
||||||
|
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
||||||
|
const existing = state.reminders[key];
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing entry (repeat)
|
||||||
|
state.reminders[key] = {
|
||||||
|
firstSentAt: existing.firstSentAt,
|
||||||
|
lastSentAt: nowMs,
|
||||||
|
sendCount: existing.sendCount + 1,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Create new entry (first send)
|
||||||
|
state.reminders[key] = {
|
||||||
|
firstSentAt: nowMs,
|
||||||
|
lastSentAt: nowMs,
|
||||||
|
sendCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up old entries (older than 24 hours)
|
// Clean up old entries (remove doses from past days)
|
||||||
const cleanedReminders = cleanOldIntakeReminders(state.sentReminders);
|
state.reminders = cleanOldIntakeReminders(state.reminders, tz);
|
||||||
|
|
||||||
saveIntakeReminderState({
|
saveIntakeReminderState(state);
|
||||||
sentReminders: [...cleanedReminders, ...newKeys],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update global reminder state for UI display
|
// Update global reminder state for UI display
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const blisters = parseBlistersFromRow(row);
|
const blisters = parseBlistersFromRow(row);
|
||||||
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
|
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||||
|
|
||||||
// Check if medication runs out within reminderDaysBefore days
|
// Check if medication runs out within reminderDaysBefore days
|
||||||
|
|||||||
@@ -1,45 +1,78 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { createClient } from "@libsql/client";
|
import { createClient } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { mkdirSync, rmSync, existsSync } from "fs";
|
import { mkdirSync, rmSync, existsSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve, dirname } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// Import the exported utility functions from client.ts
|
// Import the exported utility functions from client.ts
|
||||||
import {
|
import {
|
||||||
buildDbUrl,
|
buildDbUrl,
|
||||||
getDbPaths,
|
getDbPaths,
|
||||||
ensureDataDirectory,
|
ensureDataDirectory,
|
||||||
getTableCreationSQL,
|
runDrizzleMigrations,
|
||||||
runTableMigrations,
|
runAlterMigrations,
|
||||||
ensureDefaultUser,
|
ensureDefaultUser,
|
||||||
} from "../db/client.js";
|
} from "../db/client.js";
|
||||||
|
|
||||||
// Import the exported utility functions from migrate.ts
|
// Import the exported utility functions from migrate.ts
|
||||||
import {
|
import {
|
||||||
getTableCreationSQL as getTableCreationSQLFromMigrate,
|
|
||||||
splitSQLStatements,
|
splitSQLStatements,
|
||||||
executeMigration,
|
executeMigration,
|
||||||
getStatementPreview,
|
getStatementPreview,
|
||||||
} from "../db/migrate.js";
|
} 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("Migration Script Utilities", () => {
|
||||||
describe("getTableCreationSQL", () => {
|
describe("executeMigration", () => {
|
||||||
it("should return a non-empty array of SQL statements", () => {
|
let client: ReturnType<typeof createClient>;
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
expect(Array.isArray(statements)).toBe(true);
|
beforeEach(() => {
|
||||||
expect(statements.length).toBeGreaterThan(0);
|
client = createClient({ url: ":memory:" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should contain all table definitions", () => {
|
it("should execute all migrations successfully", async () => {
|
||||||
const statements = getTableCreationSQL();
|
const result = await executeMigration(client);
|
||||||
const allSQL = statements.join(" ");
|
expect(result.success).toBe(true);
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users");
|
expect(result.executed).toBeGreaterThan(0);
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications");
|
expect(result.errors).toHaveLength(0);
|
||||||
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");
|
it("should create all tables", async () => {
|
||||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking");
|
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);
|
expect(statements).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle getTableCreationSQL output correctly", () => {
|
|
||||||
const statements = getTableCreationSQL();
|
|
||||||
expect(statements).toHaveLength(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve whitespace within statements", () => {
|
it("should preserve whitespace within statements", () => {
|
||||||
const sql = "CREATE TABLE test (\n id INTEGER\n);";
|
const sql = "CREATE TABLE test (\n id INTEGER\n);";
|
||||||
const statements = splitSQLStatements(sql);
|
const statements = splitSQLStatements(sql);
|
||||||
@@ -103,52 +131,6 @@ describe("Migration Script Utilities", () => {
|
|||||||
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
|
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", () => {
|
describe("Database Client Utilities", () => {
|
||||||
@@ -218,63 +200,7 @@ describe("Database Client Utilities", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTableCreationSQL", () => {
|
describe("runDrizzleMigrations", () => {
|
||||||
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", () => {
|
|
||||||
let client: ReturnType<typeof createClient>;
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -282,23 +208,24 @@ describe("Database Client Utilities", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should create all tables successfully", async () => {
|
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.success).toBe(true);
|
||||||
expect(result.errors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be idempotent (run twice without errors)", async () => {
|
it("should be idempotent (run twice without errors)", async () => {
|
||||||
await runTableMigrations(client);
|
const db = drizzle(client);
|
||||||
const result = await runTableMigrations(client);
|
await runDrizzleMigrations(db);
|
||||||
|
const result = await runDrizzleMigrations(db);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.errors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create all 6 tables", async () => {
|
it("should create all 7 tables", async () => {
|
||||||
await runTableMigrations(client);
|
const db = drizzle(client);
|
||||||
|
await runDrizzleMigrations(db);
|
||||||
|
|
||||||
const tables = await client.execute(
|
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);
|
const tableNames = tables.rows.map(r => r.name);
|
||||||
@@ -308,6 +235,29 @@ describe("Database Client Utilities", () => {
|
|||||||
expect(tableNames).toContain("refresh_tokens");
|
expect(tableNames).toContain("refresh_tokens");
|
||||||
expect(tableNames).toContain("share_tokens");
|
expect(tableNames).toContain("share_tokens");
|
||||||
expect(tableNames).toContain("dose_tracking");
|
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 () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
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 () => {
|
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>;
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create users table", async () => {
|
it("should have users table with correct columns", async () => {
|
||||||
await client.execute(`
|
const columns = await client.execute("PRAGMA table_info(users)");
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
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')");
|
|
||||||
|
|
||||||
await expect(
|
expect(columnNames).toContain("id");
|
||||||
client.execute("INSERT INTO users (username) VALUES ('testuser')")
|
expect(columnNames).toContain("username");
|
||||||
).rejects.toThrow();
|
expect(columnNames).toContain("password_hash");
|
||||||
|
expect(columnNames).toContain("auth_provider");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enforce unique constraint on refresh token_id", async () => {
|
it("should have medications table with correct columns", async () => {
|
||||||
await client.execute(`
|
const columns = await client.execute("PRAGMA table_info(medications)");
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
const columnNames = columns.rows.map(r => r.name);
|
||||||
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)"
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
expect(columnNames).toContain("id");
|
||||||
client.execute(
|
expect(columnNames).toContain("user_id");
|
||||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
expect(columnNames).toContain("name");
|
||||||
)
|
expect(columnNames).toContain("taken_by_json");
|
||||||
).rejects.toThrow();
|
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 () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
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'))
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default values for auth_provider", async () => {
|
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')");
|
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||||
|
|
||||||
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
|
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
|
||||||
expect(result.rows[0].is_active).toBe(1);
|
// SQLite stores booleans as integers
|
||||||
});
|
expect(result.rows[0].is_active).toBeTruthy();
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -674,40 +447,18 @@ describe("Database Client", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
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("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 () => {
|
it("should use default notification settings", async () => {
|
||||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||||
|
|
||||||
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
|
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
|
||||||
expect(result.rows[0].email_enabled).toBe(0);
|
// SQLite stores booleans as integers (false = 0)
|
||||||
expect(result.rows[0].shoutrrr_enabled).toBe(0);
|
expect(result.rows[0].email_enabled).toBeFalsy();
|
||||||
|
expect(result.rows[0].shoutrrr_enabled).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default stock threshold settings", async () => {
|
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].low_stock_days).toBe(30);
|
||||||
expect(result.rows[0].normal_stock_days).toBe(90);
|
expect(result.rows[0].normal_stock_days).toBe(90);
|
||||||
expect(result.rows[0].high_stock_days).toBe(180);
|
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 () => {
|
it("should use default language (en)", async () => {
|
||||||
@@ -747,32 +497,9 @@ describe("Database Client", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
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("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 () => {
|
it("should use default inventory values", async () => {
|
||||||
@@ -795,11 +522,11 @@ describe("Database Client", () => {
|
|||||||
expect(result.rows[0].start_json).toBe("[]");
|
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')");
|
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'");
|
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:" });
|
client = createClient({ url: ":memory:" });
|
||||||
// Enable foreign keys
|
// Enable foreign keys
|
||||||
await client.execute("PRAGMA foreign_keys = ON");
|
await client.execute("PRAGMA foreign_keys = ON");
|
||||||
|
const db = drizzle(client);
|
||||||
await client.execute(`
|
await migrate(db, { migrationsFolder });
|
||||||
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
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should cascade delete medications when user is deleted", async () => {
|
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)", () => {
|
describe("Default User Creation (Auth Disabled)", () => {
|
||||||
let client: ReturnType<typeof createClient>;
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
client = createClient({ url: ":memory:" });
|
client = createClient({ url: ":memory:" });
|
||||||
await client.execute(`
|
const db = drizzle(client);
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
await migrate(db, { migrationsFolder });
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username text NOT NULL UNIQUE,
|
|
||||||
auth_provider text NOT NULL DEFAULT 'local'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be able to create a default user with ID 1", async () => {
|
it("should be able to create a default user with ID 1", async () => {
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ async function registerDoseRoutes(ctx: TestContext) {
|
|||||||
|
|
||||||
return { success: true };
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const { shareRoutes } = await import("../routes/share.js");
|
|||||||
const { medicationRoutes } = await import("../routes/medications.js");
|
const { medicationRoutes } = await import("../routes/medications.js");
|
||||||
const { settingsRoutes } = await import("../routes/settings.js");
|
const { settingsRoutes } = await import("../routes/settings.js");
|
||||||
const { healthRoutes } = await import("../routes/health.js");
|
const { healthRoutes } = await import("../routes/health.js");
|
||||||
|
const { refillRoutes } = await import("../routes/refills.js");
|
||||||
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Test Setup
|
// Test Setup
|
||||||
@@ -83,6 +85,8 @@ async function createSchema(client: Client) {
|
|||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
loose_tablets integer NOT NULL DEFAULT 0,
|
||||||
|
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||||
|
last_stock_correction_at integer,
|
||||||
pill_weight_mg integer,
|
pill_weight_mg integer,
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
usage_json text NOT NULL DEFAULT '[]',
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
every_json text NOT NULL DEFAULT '[]',
|
||||||
@@ -107,6 +111,10 @@ async function createSchema(client: Client) {
|
|||||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||||
|
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||||
|
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
|
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||||
|
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||||
low_stock_days integer NOT NULL DEFAULT 30,
|
low_stock_days integer NOT NULL DEFAULT 30,
|
||||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
@@ -135,6 +143,17 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
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
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
@@ -145,6 +164,7 @@ async function createSchema(client: Client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearData(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 dose_tracking");
|
||||||
await client.execute("DELETE FROM share_tokens");
|
await client.execute("DELETE FROM share_tokens");
|
||||||
await client.execute("DELETE FROM user_settings");
|
await client.execute("DELETE FROM user_settings");
|
||||||
@@ -225,6 +245,8 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
await app.register(medicationRoutes);
|
await app.register(medicationRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
|
await app.register(refillRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
|
||||||
await app.ready();
|
await app.ready();
|
||||||
});
|
});
|
||||||
@@ -556,6 +578,9 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
url: "/settings",
|
url: "/settings",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
console.error("GET /settings error:", response.body);
|
||||||
|
}
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
// Check default values
|
// Check default values
|
||||||
@@ -720,7 +745,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ status: "ok" });
|
const json = response.json();
|
||||||
|
expect(json.status).toBe("ok");
|
||||||
|
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||||
|
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1138,7 +1166,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ status: "ok" });
|
const json = response.json();
|
||||||
|
expect(json.status).toBe("ok");
|
||||||
|
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||||
|
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1554,4 +1585,342 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.statusCode).toBe(204);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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("[]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,6 +80,8 @@ async function createSchema(client: Client) {
|
|||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
loose_tablets integer NOT NULL DEFAULT 0,
|
||||||
|
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||||
|
last_stock_correction_at integer,
|
||||||
pill_weight_mg integer,
|
pill_weight_mg integer,
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
usage_json text NOT NULL DEFAULT '[]',
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
every_json text NOT NULL DEFAULT '[]',
|
||||||
@@ -104,6 +106,10 @@ async function createSchema(client: Client) {
|
|||||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||||
|
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||||
|
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
|
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||||
|
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||||
low_stock_days integer NOT NULL DEFAULT 30,
|
low_stock_days integer NOT NULL DEFAULT 30,
|
||||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
@@ -132,6 +138,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ async function createSchema(client: Client) {
|
|||||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||||
|
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
|
||||||
|
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
|
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
|
||||||
|
max_nagging_reminders integer NOT NULL DEFAULT 5,
|
||||||
low_stock_days integer NOT NULL DEFAULT 30,
|
low_stock_days integer NOT NULL DEFAULT 30,
|
||||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
calculateDailyUsage,
|
calculateDailyUsage,
|
||||||
calculateDepletionInfo,
|
calculateDepletionInfo,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
|
getTodaysIntakes,
|
||||||
createDefaultReminderState,
|
createDefaultReminderState,
|
||||||
createDefaultIntakeReminderState,
|
createDefaultIntakeReminderState,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
@@ -381,6 +382,94 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
|||||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getTodaysIntakes", () => {
|
||||||
|
it("should return all intakes for today", () => {
|
||||||
|
// Daily medication at 08:00 starting yesterday
|
||||||
|
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
|
||||||
|
|
||||||
|
// Get intakes for 2025-01-02 (today's intake should be at 08:00)
|
||||||
|
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC");
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const intake = result.find(i => i.intakeTime.getUTCHours() === 8);
|
||||||
|
expect(intake).toBeDefined();
|
||||||
|
expect(intake?.medName).toBe("TestMed");
|
||||||
|
expect(intake?.usage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include past intakes from today", () => {
|
||||||
|
// Medication at 00:01 today (definitely in the past)
|
||||||
|
const todayMidnight = new Date();
|
||||||
|
todayMidnight.setUTCHours(0, 1, 0, 0);
|
||||||
|
|
||||||
|
const blisters: Blister[] = [{
|
||||||
|
usage: 2,
|
||||||
|
every: 1,
|
||||||
|
start: todayMidnight.toISOString()
|
||||||
|
}];
|
||||||
|
|
||||||
|
const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC");
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].medName).toBe("PastMed");
|
||||||
|
expect(result[0].usage).toBe(2);
|
||||||
|
expect(result[0].takenBy).toEqual(["Bob"]);
|
||||||
|
expect(result[0].pillWeightMg).toBe(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple intakes per day", () => {
|
||||||
|
// Two intakes today: morning and evening
|
||||||
|
const today = new Date();
|
||||||
|
const morning = new Date(today);
|
||||||
|
morning.setUTCHours(8, 0, 0, 0);
|
||||||
|
const evening = new Date(today);
|
||||||
|
evening.setUTCHours(20, 0, 0, 0);
|
||||||
|
|
||||||
|
const blisters: Blister[] = [
|
||||||
|
{ usage: 1, every: 1, start: morning.toISOString() },
|
||||||
|
{ usage: 1, every: 1, start: evening.toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = getTodaysIntakes("MultiMed", blisters, [], null, "en-US", "UTC");
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not include intakes from other days", () => {
|
||||||
|
// Weekly medication on a different day of week
|
||||||
|
const lastWeek = new Date();
|
||||||
|
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||||
|
|
||||||
|
const blisters: Blister[] = [{
|
||||||
|
usage: 1,
|
||||||
|
every: 7,
|
||||||
|
start: lastWeek.toISOString()
|
||||||
|
}];
|
||||||
|
|
||||||
|
// If today is not the same day of week, should return empty
|
||||||
|
const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC");
|
||||||
|
|
||||||
|
// This test might return 0 or 1 depending on the day
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle timezone correctly", () => {
|
||||||
|
// 23:00 in Europe/Berlin on a specific date
|
||||||
|
const blisters: Blister[] = [{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2025-01-01T22:00:00.000Z" // 23:00 Berlin time
|
||||||
|
}];
|
||||||
|
|
||||||
|
const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin");
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
if (result.length > 0) {
|
||||||
|
expect(result[0].intakeTimeStr).toContain("23:");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Scheduler Utils - State Management", () => {
|
describe("Scheduler Utils - State Management", () => {
|
||||||
@@ -399,7 +488,7 @@ describe("Scheduler Utils - State Management", () => {
|
|||||||
describe("createDefaultIntakeReminderState", () => {
|
describe("createDefaultIntakeReminderState", () => {
|
||||||
it("should create default intake reminder state", () => {
|
it("should create default intake reminder state", () => {
|
||||||
const state = createDefaultIntakeReminderState();
|
const state = createDefaultIntakeReminderState();
|
||||||
expect(state.sentReminders).toEqual([]);
|
expect(state.reminders).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,62 +528,91 @@ describe("Scheduler Utils - State Management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("parseIntakeReminderState", () => {
|
describe("parseIntakeReminderState", () => {
|
||||||
it("should parse valid JSON", () => {
|
it("should parse valid new format JSON", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
reminders: {
|
||||||
|
"med1:123": { firstSentAt: 1000, lastSentAt: 2000, sendCount: 2 },
|
||||||
|
"med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = parseIntakeReminderState(json);
|
||||||
|
expect(Object.keys(state.reminders)).toHaveLength(2);
|
||||||
|
expect(state.reminders["med1:123"].sendCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert old array format to new format", () => {
|
||||||
const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] });
|
const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] });
|
||||||
|
|
||||||
const state = parseIntakeReminderState(json);
|
const state = parseIntakeReminderState(json);
|
||||||
expect(state.sentReminders).toEqual(["med1:123", "med2:456"]);
|
expect(Object.keys(state.reminders)).toHaveLength(2);
|
||||||
|
expect(state.reminders["med1:123"]).toBeDefined();
|
||||||
|
expect(state.reminders["med1:123"].sendCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return defaults for invalid JSON", () => {
|
it("should return defaults for invalid JSON", () => {
|
||||||
const state = parseIntakeReminderState("invalid");
|
const state = parseIntakeReminderState("invalid");
|
||||||
expect(state.sentReminders).toEqual([]);
|
expect(state.reminders).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle missing sentReminders", () => {
|
it("should handle missing reminders field", () => {
|
||||||
const state = parseIntakeReminderState("{}");
|
const state = parseIntakeReminderState("{}");
|
||||||
expect(state.sentReminders).toEqual([]);
|
expect(state.reminders).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cleanOldIntakeReminders", () => {
|
describe("cleanOldIntakeReminders", () => {
|
||||||
it("should remove entries older than maxAgeMs", () => {
|
it("should remove entries from past days (timezone-aware)", () => {
|
||||||
const now = Date.now();
|
const tz = "Europe/Berlin";
|
||||||
const oldTimestamp = now - 25 * 60 * 60 * 1000; // 25 hours ago
|
const now = new Date();
|
||||||
const recentTimestamp = now - 1 * 60 * 60 * 1000; // 1 hour ago
|
const today = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
today.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
const reminders = [
|
const yesterday = new Date(today);
|
||||||
`med1:${oldTimestamp}`,
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
`med2:${recentTimestamp}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const cleaned = cleanOldIntakeReminders(reminders, 24 * 60 * 60 * 1000);
|
const reminders = {
|
||||||
|
[`med1:${yesterday.getTime()}`]: { firstSentAt: yesterday.getTime(), lastSentAt: yesterday.getTime(), sendCount: 1 },
|
||||||
|
[`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
expect(cleaned).toHaveLength(1);
|
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
||||||
expect(cleaned[0]).toContain("med2");
|
|
||||||
|
expect(Object.keys(cleaned)).toHaveLength(1);
|
||||||
|
expect(cleaned[`med2:${today.getTime()}`]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep all entries if none are old", () => {
|
it("should keep all entries from today", () => {
|
||||||
const now = Date.now();
|
const tz = "Europe/Berlin";
|
||||||
const reminders = [
|
const now = new Date();
|
||||||
`med1:${now - 1000}`,
|
const morning = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
`med2:${now - 2000}`,
|
morning.setHours(8, 0, 0, 0);
|
||||||
];
|
|
||||||
|
|
||||||
const cleaned = cleanOldIntakeReminders(reminders);
|
const evening = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
expect(cleaned).toHaveLength(2);
|
evening.setHours(20, 0, 0, 0);
|
||||||
|
|
||||||
|
const reminders = {
|
||||||
|
[`med1:${morning.getTime()}`]: { firstSentAt: morning.getTime(), lastSentAt: morning.getTime(), sendCount: 1 },
|
||||||
|
[`med2:${evening.getTime()}`]: { firstSentAt: evening.getTime(), lastSentAt: evening.getTime(), sendCount: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
||||||
|
expect(Object.keys(cleaned)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle empty array", () => {
|
it("should handle empty reminders", () => {
|
||||||
const cleaned = cleanOldIntakeReminders([]);
|
const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin");
|
||||||
expect(cleaned).toEqual([]);
|
expect(cleaned).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle malformed entries (invalid timestamp)", () => {
|
it("should handle malformed entries (invalid timestamp in key)", () => {
|
||||||
const reminders = ["med1:invalid", "med2:notanumber"];
|
const reminders = {
|
||||||
const cleaned = cleanOldIntakeReminders(reminders);
|
"med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 },
|
||||||
// NaN from parseInt will cause these to be filtered out (0 < cutoff)
|
"med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 }
|
||||||
expect(cleaned).toEqual([]);
|
};
|
||||||
|
const cleaned = cleanOldIntakeReminders(reminders, "Europe/Berlin");
|
||||||
|
// NaN from parseInt will cause these to be filtered out (invalid < todayStart)
|
||||||
|
expect(Object.keys(cleaned)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
shoutrrrIntakeReminders: true,
|
shoutrrrIntakeReminders: true,
|
||||||
reminderDaysBefore: 7,
|
reminderDaysBefore: 7,
|
||||||
repeatDailyReminders: false,
|
repeatDailyReminders: false,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
lowStockDays: 30,
|
lowStockDays: 30,
|
||||||
normalStockDays: 90,
|
normalStockDays: 90,
|
||||||
highStockDays: 180,
|
highStockDays: 180,
|
||||||
@@ -62,6 +66,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders),
|
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders),
|
||||||
reminderDaysBefore: s.reminder_days_before,
|
reminderDaysBefore: s.reminder_days_before,
|
||||||
repeatDailyReminders: Boolean(s.repeat_daily_reminders),
|
repeatDailyReminders: Boolean(s.repeat_daily_reminders),
|
||||||
|
skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses ?? false),
|
||||||
|
repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled ?? false),
|
||||||
|
reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30,
|
||||||
|
maxNaggingReminders: s.max_nagging_reminders ?? 5,
|
||||||
lowStockDays: s.low_stock_days,
|
lowStockDays: s.low_stock_days,
|
||||||
normalStockDays: s.normal_stock_days,
|
normalStockDays: s.normal_stock_days,
|
||||||
highStockDays: s.high_stock_days,
|
highStockDays: s.high_stock_days,
|
||||||
@@ -84,6 +92,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
shoutrrrIntakeReminders?: boolean;
|
shoutrrrIntakeReminders?: boolean;
|
||||||
reminderDaysBefore?: number;
|
reminderDaysBefore?: number;
|
||||||
repeatDailyReminders?: boolean;
|
repeatDailyReminders?: boolean;
|
||||||
|
skipRemindersForTakenDoses?: boolean;
|
||||||
|
repeatRemindersEnabled?: boolean;
|
||||||
|
reminderRepeatIntervalMinutes?: number;
|
||||||
|
maxNaggingReminders?: number;
|
||||||
lowStockDays?: number;
|
lowStockDays?: number;
|
||||||
normalStockDays?: number;
|
normalStockDays?: number;
|
||||||
highStockDays?: number;
|
highStockDays?: number;
|
||||||
@@ -111,6 +123,12 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) {
|
if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) {
|
||||||
return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" });
|
return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" });
|
||||||
}
|
}
|
||||||
|
if (body.reminderRepeatIntervalMinutes !== undefined && (body.reminderRepeatIntervalMinutes < 5 || body.reminderRepeatIntervalMinutes > 480)) {
|
||||||
|
return reply.status(400).send({ error: "reminderRepeatIntervalMinutes must be between 5 and 480" });
|
||||||
|
}
|
||||||
|
if (body.maxNaggingReminders !== undefined && (body.maxNaggingReminders < 1 || body.maxNaggingReminders > 20)) {
|
||||||
|
return reply.status(400).send({ error: "maxNaggingReminders must be between 1 and 20" });
|
||||||
|
}
|
||||||
|
|
||||||
// Check if settings exist
|
// Check if settings exist
|
||||||
const existing = await client.execute({
|
const existing = await client.execute({
|
||||||
@@ -126,10 +144,11 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
email_stock_reminders, email_intake_reminders,
|
email_stock_reminders, email_intake_reminders,
|
||||||
shoutrrr_enabled, shoutrrr_url,
|
shoutrrr_enabled, shoutrrr_url,
|
||||||
shoutrrr_stock_reminders, shoutrrr_intake_reminders,
|
shoutrrr_stock_reminders, shoutrrr_intake_reminders,
|
||||||
reminder_days_before, repeat_daily_reminders,
|
reminder_days_before, repeat_daily_reminders, skip_reminders_for_taken_doses,
|
||||||
|
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
|
||||||
low_stock_days, normal_stock_days, high_stock_days,
|
low_stock_days, normal_stock_days, high_stock_days,
|
||||||
expiry_warning_days, language, stock_calculation_mode
|
expiry_warning_days, language, stock_calculation_mode
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
args: [
|
args: [
|
||||||
userId,
|
userId,
|
||||||
body.emailEnabled ? 1 : 0,
|
body.emailEnabled ? 1 : 0,
|
||||||
@@ -142,6 +161,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||||
body.reminderDaysBefore ?? 7,
|
body.reminderDaysBefore ?? 7,
|
||||||
body.repeatDailyReminders ? 1 : 0,
|
body.repeatDailyReminders ? 1 : 0,
|
||||||
|
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||||
|
body.repeatRemindersEnabled ? 1 : 0,
|
||||||
|
body.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
body.maxNaggingReminders ?? 5,
|
||||||
body.lowStockDays ?? 30,
|
body.lowStockDays ?? 30,
|
||||||
body.normalStockDays ?? 90,
|
body.normalStockDays ?? 90,
|
||||||
body.highStockDays ?? 180,
|
body.highStockDays ?? 180,
|
||||||
@@ -164,6 +187,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
shoutrrr_intake_reminders = ?,
|
shoutrrr_intake_reminders = ?,
|
||||||
reminder_days_before = ?,
|
reminder_days_before = ?,
|
||||||
repeat_daily_reminders = ?,
|
repeat_daily_reminders = ?,
|
||||||
|
skip_reminders_for_taken_doses = ?,
|
||||||
|
repeat_reminders_enabled = ?,
|
||||||
|
reminder_repeat_interval_minutes = ?,
|
||||||
|
max_nagging_reminders = ?,
|
||||||
low_stock_days = ?,
|
low_stock_days = ?,
|
||||||
normal_stock_days = ?,
|
normal_stock_days = ?,
|
||||||
high_stock_days = ?,
|
high_stock_days = ?,
|
||||||
@@ -183,6 +210,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
|||||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||||
body.reminderDaysBefore ?? 7,
|
body.reminderDaysBefore ?? 7,
|
||||||
body.repeatDailyReminders ? 1 : 0,
|
body.repeatDailyReminders ? 1 : 0,
|
||||||
|
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||||
|
body.repeatRemindersEnabled ? 1 : 0,
|
||||||
|
body.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
body.maxNaggingReminders ?? 5,
|
||||||
body.lowStockDays ?? 30,
|
body.lowStockDays ?? 30,
|
||||||
body.normalStockDays ?? 90,
|
body.normalStockDays ?? 90,
|
||||||
body.highStockDays ?? 180,
|
body.highStockDays ?? 180,
|
||||||
@@ -507,4 +538,137 @@ describe("Settings API", () => {
|
|||||||
expect(getResponse.json().stockCalculationMode).toBe("automatic");
|
expect(getResponse.json().stockCalculationMode).toBe("automatic");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Repeat Reminders & Skip Reminders Settings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Repeat Reminders Settings", () => {
|
||||||
|
it("should enable repeat reminders with interval", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
repeatRemindersEnabled: true,
|
||||||
|
reminderRepeatIntervalMinutes: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const getResponse = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = getResponse.json();
|
||||||
|
expect(settings.repeatRemindersEnabled).toBe(true);
|
||||||
|
expect(settings.reminderRepeatIntervalMinutes).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate repeat interval range", async () => {
|
||||||
|
let response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
repeatRemindersEnabled: true,
|
||||||
|
reminderRepeatIntervalMinutes: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
|
||||||
|
response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
repeatRemindersEnabled: true,
|
||||||
|
reminderRepeatIntervalMinutes: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate max nagging reminders range", async () => {
|
||||||
|
let response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
maxNaggingReminders: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
|
||||||
|
response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
maxNaggingReminders: 25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
|
||||||
|
// Valid values should work
|
||||||
|
response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
maxNaggingReminders: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const getResponse = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = getResponse.json();
|
||||||
|
expect(settings.maxNaggingReminders).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Skip Reminders for Taken Doses", () => {
|
||||||
|
it("should enable and disable skip reminders setting", async () => {
|
||||||
|
let response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
skipRemindersForTakenDoses: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work with repeat reminders enabled", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
repeatRemindersEnabled: true,
|
||||||
|
reminderRepeatIntervalMinutes: 5,
|
||||||
|
skipRemindersForTakenDoses: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const getResponse = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = getResponse.json();
|
||||||
|
expect(settings.repeatRemindersEnabled).toBe(true);
|
||||||
|
expect(settings.reminderRepeatIntervalMinutes).toBe(5);
|
||||||
|
expect(settings.skipRemindersForTakenDoses).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ import sensible from "@fastify/sensible";
|
|||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import { createClient, Client } from "@libsql/client";
|
import { createClient, Client } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { beforeAll, afterAll, beforeEach } from "vitest";
|
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
|
// Type for our test database
|
||||||
export type TestDb = ReturnType<typeof drizzle>;
|
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> {
|
async function runTestMigrations(client: Client): Promise<void> {
|
||||||
const tableCreations = getTableCreationSQL();
|
const db = drizzle(client);
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
for (const sql of tableCreations) {
|
|
||||||
await client.execute(sql);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -107,6 +111,9 @@ export interface CreateMedicationOptions {
|
|||||||
pillsPerBlister?: number;
|
pillsPerBlister?: number;
|
||||||
looseTablets?: number;
|
looseTablets?: number;
|
||||||
pillWeightMg?: number;
|
pillWeightMg?: number;
|
||||||
|
expiryDate?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
intakeRemindersEnabled?: boolean;
|
||||||
/** Array of { usage, every, start } for each blister schedule */
|
/** Array of { usage, every, start } for each blister schedule */
|
||||||
blisters?: Array<{ usage: number; every: number; start: string }>;
|
blisters?: Array<{ usage: number; every: number; start: string }>;
|
||||||
}
|
}
|
||||||
@@ -128,6 +135,9 @@ export async function createTestMedication(
|
|||||||
pillsPerBlister = 10,
|
pillsPerBlister = 10,
|
||||||
looseTablets = 0,
|
looseTablets = 0,
|
||||||
pillWeightMg = null,
|
pillWeightMg = null,
|
||||||
|
expiryDate = null,
|
||||||
|
notes = null,
|
||||||
|
intakeRemindersEnabled = false,
|
||||||
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -141,8 +151,8 @@ export async function createTestMedication(
|
|||||||
sql: `INSERT INTO medications (
|
sql: `INSERT INTO medications (
|
||||||
user_id, name, generic_name, taken_by_json,
|
user_id, name, generic_name, taken_by_json,
|
||||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
pill_weight_mg, usage_json, every_json, start_json
|
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
args: [
|
args: [
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
@@ -156,6 +166,9 @@ export async function createTestMedication(
|
|||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
startJson,
|
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> {
|
export async function clearTestData(client: Client): Promise<void> {
|
||||||
// Order matters due to foreign keys
|
// 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 dose_tracking");
|
||||||
await client.execute("DELETE FROM share_tokens");
|
await client.execute("DELETE FROM share_tokens");
|
||||||
await client.execute("DELETE FROM refresh_tokens");
|
await client.execute("DELETE FROM refresh_tokens");
|
||||||
|
|||||||
@@ -188,6 +188,70 @@ export type UpcomingIntake = {
|
|||||||
pillWeightMg: number | null;
|
pillWeightMg: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all intakes for today (past and future) - used for repeat reminders.
|
||||||
|
* Returns all intakes scheduled for today in user's timezone.
|
||||||
|
*/
|
||||||
|
export function getTodaysIntakes(
|
||||||
|
medName: string,
|
||||||
|
blisters: Blister[],
|
||||||
|
takenBy: string[],
|
||||||
|
pillWeightMg: number | null,
|
||||||
|
locale: string,
|
||||||
|
tz?: string
|
||||||
|
): UpcomingIntake[] {
|
||||||
|
const timezone = tz ?? getTimezone();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Get start and end of today in user's timezone
|
||||||
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||||
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const intakes: UpcomingIntake[] = [];
|
||||||
|
|
||||||
|
for (const blister of blisters) {
|
||||||
|
const startTime = new Date(blister.start).getTime();
|
||||||
|
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (intervalMs <= 0) continue;
|
||||||
|
|
||||||
|
// Find all occurrences that fall within today
|
||||||
|
let currentTime = startTime;
|
||||||
|
|
||||||
|
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||||
|
if (currentTime < todayStart.getTime()) {
|
||||||
|
const elapsed = todayStart.getTime() - startTime;
|
||||||
|
const intervals = Math.floor(elapsed / intervalMs);
|
||||||
|
currentTime = startTime + intervals * intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all intakes for today
|
||||||
|
while (currentTime <= todayEnd.getTime()) {
|
||||||
|
if (currentTime >= todayStart.getTime()) {
|
||||||
|
const intakeDate = new Date(currentTime);
|
||||||
|
intakes.push({
|
||||||
|
medName,
|
||||||
|
usage: blister.usage,
|
||||||
|
intakeTime: intakeDate,
|
||||||
|
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: timezone
|
||||||
|
}),
|
||||||
|
takenBy,
|
||||||
|
pillWeightMg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentTime += intervalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intakes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get upcoming intakes that fall within the reminder window.
|
* Get upcoming intakes that fall within the reminder window.
|
||||||
* Returns intakes that should be notified about right now.
|
* Returns intakes that should be notified about right now.
|
||||||
@@ -277,8 +341,14 @@ export type ReminderState = {
|
|||||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IntakeReminderEntry = {
|
||||||
|
firstSentAt: number; // Timestamp when first reminder was sent
|
||||||
|
lastSentAt: number; // Timestamp when last reminder was sent
|
||||||
|
sendCount: number; // How many times reminder was sent
|
||||||
|
};
|
||||||
|
|
||||||
export type IntakeReminderState = {
|
export type IntakeReminderState = {
|
||||||
sentReminders: string[];
|
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Create default reminder state */
|
/** Create default reminder state */
|
||||||
@@ -295,7 +365,7 @@ export function createDefaultReminderState(): ReminderState {
|
|||||||
|
|
||||||
/** Create default intake reminder state */
|
/** Create default intake reminder state */
|
||||||
export function createDefaultIntakeReminderState(): IntakeReminderState {
|
export function createDefaultIntakeReminderState(): IntakeReminderState {
|
||||||
return { sentReminders: [] };
|
return { reminders: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse reminder state from JSON string */
|
/** Parse reminder state from JSON string */
|
||||||
@@ -315,12 +385,28 @@ export function parseReminderState(json: string): ReminderState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse intake reminder state from JSON string */
|
/** Parse intake reminder state from JSON string (backward compatible) */
|
||||||
export function parseIntakeReminderState(json: string): IntakeReminderState {
|
export function parseIntakeReminderState(json: string): IntakeReminderState {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(json);
|
const saved = JSON.parse(json);
|
||||||
|
|
||||||
|
// Backward compatibility: convert old array format to new map format
|
||||||
|
if (Array.isArray(saved.sentReminders)) {
|
||||||
|
const reminders: Record<string, IntakeReminderEntry> = {};
|
||||||
|
const now = Date.now();
|
||||||
|
for (const key of saved.sentReminders) {
|
||||||
|
reminders[key] = {
|
||||||
|
firstSentAt: now,
|
||||||
|
lastSentAt: now,
|
||||||
|
sendCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { reminders };
|
||||||
|
}
|
||||||
|
|
||||||
|
// New format
|
||||||
return {
|
return {
|
||||||
sentReminders: saved.sentReminders ?? [],
|
reminders: saved.reminders ?? {},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return createDefaultIntakeReminderState();
|
return createDefaultIntakeReminderState();
|
||||||
@@ -328,10 +414,21 @@ export function parseIntakeReminderState(json: string): IntakeReminderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Clean up old intake reminder entries (older than given milliseconds) */
|
/** Clean up old intake reminder entries (older than given milliseconds) */
|
||||||
export function cleanOldIntakeReminders(sentReminders: string[], maxAgeMs: number = 24 * 60 * 60 * 1000): string[] {
|
/** Clean up old intake reminder entries (using timezone-aware day check) */
|
||||||
const cutoff = Date.now() - maxAgeMs;
|
export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
|
||||||
return sentReminders.filter(key => {
|
// Get start of today in user's timezone
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
const todayStartMs = todayStart.getTime();
|
||||||
|
|
||||||
|
// Keep only reminders from today onwards (based on dose timestamp in key)
|
||||||
|
const cleaned: Record<string, IntakeReminderEntry> = {};
|
||||||
|
for (const [key, entry] of Object.entries(reminders)) {
|
||||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||||
return timestamp > cutoff;
|
if (timestamp >= todayStartMs) {
|
||||||
});
|
cleaned[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- backend_node_modules:/app/node_modules
|
- backend_node_modules:/app/node_modules
|
||||||
- ./backend/data:/app/data
|
- ./data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
@@ -12,8 +12,8 @@ server {
|
|||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# Allow larger file uploads (for medication images)
|
# Allow larger file uploads (for medication images and data import/export)
|
||||||
client_max_body_size 10M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1713,9 +1713,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -1735,12 +1735,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.11.0",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.11.0"
|
"react-router": "7.12.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.2",
|
"version": "1.4.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
{t("auth.register", "Create Account")}
|
{t("auth.register", "Create Account")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
{/* SSO Login Button - also show on registration */}
|
||||||
|
{authState?.oidcEnabled && (
|
||||||
|
<div className="auth-sso">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary auth-submit sso-btn"
|
||||||
|
onClick={() => window.location.href = "/api/auth/oidc/login"}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sso-icon">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||||
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
||||||
|
</button>
|
||||||
|
{authState?.localAuthEnabled && (
|
||||||
|
<div className="auth-divider">
|
||||||
|
<span>{t("auth.or", "or")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Local Registration Form - only show if local auth is enabled */}
|
||||||
|
{authState?.localAuthEnabled && (
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
|
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{onSwitchToLogin && (
|
{onSwitchToLogin && (
|
||||||
<div className="auth-links">
|
<div className="auth-links">
|
||||||
|
|||||||
@@ -38,7 +38,14 @@
|
|||||||
"pastDaysCount": "{{count}} Tag",
|
"pastDaysCount": "{{count}} Tag",
|
||||||
"pastDaysCount_other": "{{count}} Tage",
|
"pastDaysCount_other": "{{count}} Tage",
|
||||||
"missedDoses": "{{count}} verpasste Dosis",
|
"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": {
|
"reminders": {
|
||||||
"active": "Automatische Erinnerungen aktiv",
|
"active": "Automatische Erinnerungen aktiv",
|
||||||
@@ -91,8 +98,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Eintrag bearbeiten",
|
"editEntry": "Medikament bearbeiten",
|
||||||
"newEntry": "Neuer Eintrag",
|
"newEntry": "Neues Medikament",
|
||||||
"badge": "Packungen + lose Tabletten",
|
"badge": "Packungen + lose Tabletten",
|
||||||
"commercialName": "Handelsname",
|
"commercialName": "Handelsname",
|
||||||
"genericName": "Wirkstoff",
|
"genericName": "Wirkstoff",
|
||||||
@@ -157,7 +164,15 @@
|
|||||||
"push": "Push",
|
"push": "Push",
|
||||||
"stockReminders": "Bestands-Erinnerungen",
|
"stockReminders": "Bestands-Erinnerungen",
|
||||||
"intakeReminders": "Einnahme-Erinnerungen",
|
"intakeReminders": "Einnahme-Erinnerungen",
|
||||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten."
|
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||||
|
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||||
|
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||||
|
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
||||||
|
"repeatRemindersTooltip": "Sende automatisch wiederholte Erinnerungen für Dosen, die noch nicht als genommen markiert wurden",
|
||||||
|
"reminderInterval": "Erinnerungsintervall (Minuten)",
|
||||||
|
"reminderIntervalTooltip": "Wie oft wiederholte Erinnerungen für verpasste Dosen gesendet werden sollen",
|
||||||
|
"maxNaggingReminders": "Max. Erinnerungen pro Dosis",
|
||||||
|
"maxNaggingRemindersTooltip": "Wiederholungserinnerungen nach dieser Anzahl Versuchen stoppen (1-20)"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"recipient": "Empfänger",
|
"recipient": "Empfänger",
|
||||||
@@ -165,7 +180,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"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": {
|
"schedule": {
|
||||||
"title": "Erinnerungsplan",
|
"title": "Erinnerungsplan",
|
||||||
@@ -209,7 +226,7 @@
|
|||||||
"pillWeight": "Tablettengewicht",
|
"pillWeight": "Tablettengewicht",
|
||||||
"expiryDate": "Ablaufdatum",
|
"expiryDate": "Ablaufdatum",
|
||||||
"intakeSchedule": "Einnahmeplan",
|
"intakeSchedule": "Einnahmeplan",
|
||||||
"coverageStatus": "Reichweite",
|
"coverageStatus": "Bestand",
|
||||||
"daysLeft": "Tage übrig",
|
"daysLeft": "Tage übrig",
|
||||||
"runsOut": "Aufgebraucht",
|
"runsOut": "Aufgebraucht",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
@@ -313,7 +330,8 @@
|
|||||||
"fullBlister": "voller Blister",
|
"fullBlister": "voller Blister",
|
||||||
"fullBlisters": "volle Blister",
|
"fullBlisters": "volle Blister",
|
||||||
"inBlister": "in 1 Blister",
|
"inBlister": "in 1 Blister",
|
||||||
"total": "gesamt"
|
"total": "gesamt",
|
||||||
|
"max": "max"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Teilen",
|
"button": "Teilen",
|
||||||
@@ -340,5 +358,88 @@
|
|||||||
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
||||||
"expiredOn": "Abgelaufen am: {{date}}"
|
"expiredOn": "Abgelaufen am: {{date}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exportImport": {
|
||||||
|
"title": "Daten Export / Import",
|
||||||
|
"description": "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.",
|
||||||
|
"includeImages": "Medikamentenbilder einschließen",
|
||||||
|
"includeImagesHint": "Bilder vergrößern die Datei erheblich. Deaktivieren für kleinere Exports (~50 KB statt mehrere MB).",
|
||||||
|
"exportOptions": "Export-Optionen",
|
||||||
|
"exportWithImages": "Mit Bildern",
|
||||||
|
"exportWithImagesDesc": "Vollständiges Backup mit allen Medikamentenbildern. Größere Datei.",
|
||||||
|
"exportDataOnly": "Nur Daten",
|
||||||
|
"exportDataOnlyDesc": "Kompaktes Backup ohne Bilder. Viel kleinere Datei (~50 KB).",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"editStock": {
|
||||||
|
"title": "Bestand korrigieren",
|
||||||
|
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
|
||||||
|
"fullBlisters": "Volle Blister",
|
||||||
|
"partialBlisterPills": "Angebrochener Blister",
|
||||||
|
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||||
|
"currentTotal": "Aktueller Bestand",
|
||||||
|
"newTotal": "Neuer Bestand",
|
||||||
|
"difference": "Differenz",
|
||||||
|
"save": "Korrektur speichern",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"success": "Bestand erfolgreich korrigiert"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Über",
|
||||||
|
"appName": "MedAssist-ng",
|
||||||
|
"description": "Open-Source Medikamentenverwaltung und Planungsanwendung für selbst gehostete Umgebungen.",
|
||||||
|
"version": "Version",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"checkForUpdates": "Nach Updates suchen",
|
||||||
|
"checking": "Prüfe...",
|
||||||
|
"upToDate": "Du bist auf dem neuesten Stand!",
|
||||||
|
"updateAvailable": "Update verfügbar",
|
||||||
|
"viewOnGitHub": "Auf GitHub ansehen",
|
||||||
|
"downloadUpdate": "Update herunterladen",
|
||||||
|
"checkFailed": "Update-Prüfung fehlgeschlagen",
|
||||||
|
"lastChecked": "Zuletzt geprüft",
|
||||||
|
"github": "GitHub",
|
||||||
|
"license": "MIT-Lizenz",
|
||||||
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
|
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
|
||||||
|
"techStack": "Entwickelt mit React, Fastify & SQLite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,14 @@
|
|||||||
"pastDaysCount": "{{count}} day",
|
"pastDaysCount": "{{count}} day",
|
||||||
"pastDaysCount_other": "{{count}} days",
|
"pastDaysCount_other": "{{count}} days",
|
||||||
"missedDoses": "{{count}} missed dose",
|
"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": {
|
"reminders": {
|
||||||
"active": "Automatic reminders active",
|
"active": "Automatic reminders active",
|
||||||
@@ -93,8 +100,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Edit entry",
|
"editEntry": "Edit medication",
|
||||||
"newEntry": "New entry",
|
"newEntry": "New medication",
|
||||||
"badge": "Packs + loose pills",
|
"badge": "Packs + loose pills",
|
||||||
"commercialName": "Commercial Name",
|
"commercialName": "Commercial Name",
|
||||||
"genericName": "Generic Name",
|
"genericName": "Generic Name",
|
||||||
@@ -159,7 +166,15 @@
|
|||||||
"push": "Push",
|
"push": "Push",
|
||||||
"stockReminders": "Stock Reminders",
|
"stockReminders": "Stock Reminders",
|
||||||
"intakeReminders": "Intake Reminders",
|
"intakeReminders": "Intake Reminders",
|
||||||
"enableHint": "Enable at least one channel below to receive notifications."
|
"enableHint": "Enable at least one channel below to receive notifications.",
|
||||||
|
"skipTakenDoses": "Skip reminders for taken doses",
|
||||||
|
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
|
||||||
|
"repeatReminders": "Repeat reminders for missed doses",
|
||||||
|
"repeatRemindersTooltip": "Automatically send repeated reminders for doses that haven't been marked as taken",
|
||||||
|
"reminderInterval": "Reminder interval (minutes)",
|
||||||
|
"reminderIntervalTooltip": "How often to send repeated reminders for missed doses",
|
||||||
|
"maxNaggingReminders": "Max reminders per dose",
|
||||||
|
"maxNaggingRemindersTooltip": "Stop sending repeat reminders after this many attempts (1-20)"
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"recipient": "Recipient",
|
"recipient": "Recipient",
|
||||||
@@ -167,7 +182,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"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": {
|
"schedule": {
|
||||||
"title": "Reminder Schedule",
|
"title": "Reminder Schedule",
|
||||||
@@ -315,7 +332,8 @@
|
|||||||
"fullBlister": "full blister",
|
"fullBlister": "full blister",
|
||||||
"fullBlisters": "full blisters",
|
"fullBlisters": "full blisters",
|
||||||
"inBlister": "in 1 blister",
|
"inBlister": "in 1 blister",
|
||||||
"total": "total"
|
"total": "total",
|
||||||
|
"max": "max"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Share",
|
"button": "Share",
|
||||||
@@ -342,5 +360,88 @@
|
|||||||
"contact": "Please contact {{username}} to request a new link.",
|
"contact": "Please contact {{username}} to request a new link.",
|
||||||
"expiredOn": "Expired on: {{date}}"
|
"expiredOn": "Expired on: {{date}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exportImport": {
|
||||||
|
"title": "Data Export / Import",
|
||||||
|
"description": "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.",
|
||||||
|
"includeImages": "Include medication images",
|
||||||
|
"includeImagesHint": "Images significantly increase file size. Uncheck for smaller exports (~50 KB instead of several MB).",
|
||||||
|
"exportOptions": "Export Options",
|
||||||
|
"exportWithImages": "With Images",
|
||||||
|
"exportWithImagesDesc": "Full backup including all medication images. Larger file size.",
|
||||||
|
"exportDataOnly": "Data Only",
|
||||||
|
"exportDataOnlyDesc": "Compact backup without images. Much smaller file size (~50 KB).",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"editStock": {
|
||||||
|
"title": "Correct Stock",
|
||||||
|
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
|
||||||
|
"fullBlisters": "Full blisters",
|
||||||
|
"partialBlisterPills": "Partial blister",
|
||||||
|
"pillsPerBlister": "({{count}} pills each)",
|
||||||
|
"currentTotal": "Current total",
|
||||||
|
"newTotal": "New total",
|
||||||
|
"difference": "Difference",
|
||||||
|
"save": "Save Correction",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"success": "Stock corrected successfully"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About",
|
||||||
|
"appName": "MedAssist-ng",
|
||||||
|
"description": "Open-source medication tracking and planning application for self-hosted environments.",
|
||||||
|
"version": "Version",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"checkForUpdates": "Check for Updates",
|
||||||
|
"checking": "Checking...",
|
||||||
|
"upToDate": "You're up to date!",
|
||||||
|
"updateAvailable": "Update available",
|
||||||
|
"viewOnGitHub": "View on GitHub",
|
||||||
|
"downloadUpdate": "Download Update",
|
||||||
|
"checkFailed": "Could not check for updates",
|
||||||
|
"lastChecked": "Last checked",
|
||||||
|
"github": "GitHub",
|
||||||
|
"license": "MIT License",
|
||||||
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
|
"madeWith": "Made with ❤️ for better health management",
|
||||||
|
"techStack": "Built with React, Fastify & SQLite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,46 @@ button.secondary:hover {
|
|||||||
background: var(--bg-secondary);
|
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.) */
|
/* Ghost button (Cancel, etc.) */
|
||||||
button.ghost {
|
button.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -637,6 +677,7 @@ textarea.auto-resize {
|
|||||||
.past-days-toggle {
|
.past-days-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
@@ -662,6 +703,7 @@ textarea.auto-resize {
|
|||||||
.past-days-icon {
|
.past-days-icon {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.past-days-label {
|
.past-days-label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -669,6 +711,8 @@ textarea.auto-resize {
|
|||||||
.past-days-count {
|
.past-days-count {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.past-days-warning {
|
.past-days-warning {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -690,6 +734,35 @@ textarea.auto-resize {
|
|||||||
background: rgba(234, 179, 8, 0.08);
|
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 */
|
/* Past day blocks styling */
|
||||||
.day-block.past {
|
.day-block.past {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -1359,13 +1432,21 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.setting-row.language-row {
|
.setting-row.language-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row.language-row .setting-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-select {
|
.language-select {
|
||||||
width: auto;
|
flex: 1 1 auto;
|
||||||
min-width: 160px;
|
min-width: 140px;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -1634,11 +1715,42 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-tooltip:hover::after,
|
.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;
|
opacity: 1;
|
||||||
visibility: visible;
|
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 */
|
||||||
.channels-overview {
|
.channels-overview {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1947,7 +2059,8 @@ textarea.auto-resize {
|
|||||||
.schedule-row {
|
.schedule-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -1960,11 +2073,27 @@ textarea.auto-resize {
|
|||||||
|
|
||||||
.schedule-label {
|
.schedule-label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-value {
|
.schedule-value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
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) */
|
/* Legacy support for old channel-btn (can remove later) */
|
||||||
@@ -2061,10 +2190,13 @@ textarea.auto-resize {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact Setting Row */
|
/* Compact Setting Row - for inline toggles without card styling */
|
||||||
.setting-row.compact {
|
.setting-row.compact {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 0;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-row.compact .setting-label {
|
.setting-row.compact .setting-label {
|
||||||
@@ -2255,6 +2387,9 @@ textarea.auto-resize {
|
|||||||
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
|
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
|
||||||
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
|
.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 */
|
/* Table/Timeline cells with avatar */
|
||||||
.cell-with-avatar {
|
.cell-with-avatar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2856,14 +2991,22 @@ textarea.auto-resize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lightbox-image {
|
.lightbox-image {
|
||||||
max-width: 90vw;
|
max-width: 50vw;
|
||||||
max-height: 85vh;
|
max-height: 50vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
animation: zoomIn 0.3s ease;
|
animation: zoomIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: larger lightbox image */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.lightbox-image {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes zoomIn {
|
@keyframes zoomIn {
|
||||||
from { opacity: 0; transform: scale(0.8); }
|
from { opacity: 0; transform: scale(0.8); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
@@ -2903,6 +3046,22 @@ textarea.auto-resize {
|
|||||||
|
|
||||||
.med-detail-footer {
|
.med-detail-footer {
|
||||||
padding: 1rem 1.5rem;
|
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 {
|
.med-detail-grid {
|
||||||
@@ -3254,7 +3413,17 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
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;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
@@ -3560,6 +3729,239 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
About Modal
|
||||||
|
============================================================================= */
|
||||||
|
.about-modal {
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--accent-rgb, 59, 130, 246), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tagline {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-versions {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-row:not(:last-child) {
|
||||||
|
border-bottom: 1px dashed var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-section {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-primary);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.up-to-date {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.update-available {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status-text {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status-text strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-download-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-download-link:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-last-checked {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-links {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-footer {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-copyright {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-license {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
Share Dialog
|
Share Dialog
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
@@ -3688,6 +4090,299 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
align-items: center;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Edit Stock Modal (Correction)
|
||||||
|
============================================================================= */
|
||||||
|
.edit-stock-modal {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-modal h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-med-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-bg);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(252, 211, 77, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form label .hint-text {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row.difference.positive span:last-child {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row.difference.negative span:last-child {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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)
|
Shared Schedule Page (Public)
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
@@ -3977,3 +4672,60 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
// Read version from package.json at build time
|
||||||
|
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(packageJson.version || "unknown"),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
# ./scripts/release.sh minor # 1.0.0 -> 1.1.0 (new features)
|
# ./scripts/release.sh minor # 1.0.0 -> 1.1.0 (new features)
|
||||||
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
|
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
|
||||||
# ./scripts/release.sh 1.2.3 # explicit version
|
# ./scripts/release.sh 1.2.3 # explicit version
|
||||||
|
#
|
||||||
|
# This script creates a PR for the version bump (required due to branch protection),
|
||||||
|
# waits for CI, merges it, and then creates a signed tag for the release.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -18,11 +21,28 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# GitHub repo
|
||||||
|
GITHUB_REPO="DanielVolz/medassist-ng"
|
||||||
|
|
||||||
# Get script directory and project root
|
# Get script directory and project root
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Check for gh CLI
|
||||||
|
if ! command -v gh &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
|
||||||
|
echo "Install it with: brew install gh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check gh authentication
|
||||||
|
if ! gh auth status &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
|
||||||
|
echo "Run: gh auth login"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Check for uncommitted changes
|
# Check for uncommitted changes
|
||||||
if [[ -n $(git status --porcelain) ]]; then
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
||||||
@@ -30,6 +50,11 @@ if [[ -n $(git status --porcelain) ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Make sure we're on main and up to date
|
||||||
|
echo -e "${BLUE}Updating main branch...${NC}"
|
||||||
|
git checkout main
|
||||||
|
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
||||||
|
|
||||||
# Get current version from backend/package.json
|
# Get current version from backend/package.json
|
||||||
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
|
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
|
||||||
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
||||||
@@ -74,6 +99,19 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Branch name for the release
|
||||||
|
RELEASE_BRANCH="chore/release-${NEW_VERSION}"
|
||||||
|
|
||||||
|
# Check if branch already exists
|
||||||
|
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
|
||||||
|
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
|
||||||
|
git branch -D "${RELEASE_BRANCH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create release branch
|
||||||
|
echo -e "${BLUE}Creating release branch...${NC}"
|
||||||
|
git checkout -b "${RELEASE_BRANCH}"
|
||||||
|
|
||||||
# Update version in package.json files
|
# Update version in package.json files
|
||||||
echo -e "${BLUE}Updating package.json files...${NC}"
|
echo -e "${BLUE}Updating package.json files...${NC}"
|
||||||
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
|
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
|
||||||
@@ -84,23 +122,73 @@ echo -e "${BLUE}Committing version bump...${NC}"
|
|||||||
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
||||||
git commit -m "chore: release v${NEW_VERSION}"
|
git commit -m "chore: release v${NEW_VERSION}"
|
||||||
|
|
||||||
# Check if tag exists
|
# Push branch to GitHub
|
||||||
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
echo -e "${BLUE}Pushing release branch to GitHub...${NC}"
|
||||||
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists. Overwriting...${NC}"
|
git push -u origin "${RELEASE_BRANCH}" 2>/dev/null || git push -u github "${RELEASE_BRANCH}"
|
||||||
git tag -d "v${NEW_VERSION}"
|
|
||||||
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
# Create PR
|
||||||
|
echo -e "${BLUE}Creating Pull Request...${NC}"
|
||||||
|
PR_URL=$(gh pr create \
|
||||||
|
--repo "${GITHUB_REPO}" \
|
||||||
|
--head "${RELEASE_BRANCH}" \
|
||||||
|
--title "chore: release v${NEW_VERSION}" \
|
||||||
|
--body "## Release v${NEW_VERSION}
|
||||||
|
|
||||||
|
Automated version bump for release v${NEW_VERSION}.
|
||||||
|
|
||||||
|
This PR was created by the release script." \
|
||||||
|
2>&1)
|
||||||
|
|
||||||
|
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
|
||||||
|
|
||||||
|
# Extract PR number
|
||||||
|
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||||
|
|
||||||
|
# Wait for CI checks
|
||||||
|
echo -e "${BLUE}Waiting for CI checks to complete...${NC}"
|
||||||
|
if ! gh pr checks "${PR_NUMBER}" --repo "${GITHUB_REPO}" --watch; then
|
||||||
|
echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create and push tag
|
echo -e "${GREEN}CI checks passed!${NC}"
|
||||||
|
|
||||||
|
# Merge PR
|
||||||
|
echo -e "${BLUE}Merging PR...${NC}"
|
||||||
|
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
|
||||||
|
|
||||||
|
# Switch back to main and pull
|
||||||
|
echo -e "${BLUE}Updating main branch with merged changes...${NC}"
|
||||||
|
git checkout main
|
||||||
|
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if tag exists and delete it
|
||||||
|
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
||||||
|
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
|
||||||
|
git tag -d "v${NEW_VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if remote tag exists
|
||||||
|
if git ls-remote --tags origin "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}" || \
|
||||||
|
git ls-remote --tags github "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
|
||||||
|
echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}"
|
||||||
|
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||||
|
git push github ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create signed tag
|
||||||
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
||||||
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||||
|
|
||||||
# Push
|
# Push tag
|
||||||
echo -e "${BLUE}Pushing to origin (GitHub)...${NC}"
|
echo -e "${BLUE}Pushing tag to GitHub...${NC}"
|
||||||
git push origin main
|
git push origin "v${NEW_VERSION}" 2>/dev/null || git push github "v${NEW_VERSION}"
|
||||||
git push origin "v${NEW_VERSION}"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||||
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}"
|
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}"
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
|
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
|
||||||
echo -e "Track progress: ${YELLOW}https://github.com/DanielVolz/medassist-ng/actions${NC}"
|
echo -e "Track progress: ${YELLOW}https://github.com/${GITHUB_REPO}/actions${NC}"
|
||||||
|
echo -e "Release page: ${YELLOW}https://github.com/${GITHUB_REPO}/releases/tag/v${NEW_VERSION}${NC}"
|
||||||
|
|||||||