Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b68c0b0737 | |||
| 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 |
@@ -79,3 +79,43 @@ REMINDER_DAYS_BEFORE=7
|
||||
REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM
|
||||
REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder
|
||||
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,12 @@
|
||||
# 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.
|
||||
- **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
|
||||
|
||||
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
|
||||
@@ -41,25 +48,25 @@ cd backend && npm run test:coverage # Run with coverage report
|
||||
|
||||
## Testing (MANDATORY)
|
||||
|
||||
> ⚠️ **WICHTIG**: Jede neue Funktionalität MUSS mit Tests abgedeckt werden!
|
||||
> Pull Requests ohne Tests für neue Features werden nicht akzeptiert.
|
||||
> ⚠️ **IMPORTANT**: Every new feature MUST be covered by tests!
|
||||
> Pull Requests without tests for new features will not be accepted.
|
||||
|
||||
### Test-Framework
|
||||
- **Vitest 2.1** mit v8 Coverage
|
||||
### Test Framework
|
||||
- **Vitest 2.1** with v8 Coverage
|
||||
- Tests in `backend/src/test/*.test.ts`
|
||||
- Coverage-Ziel: Mindestens gleiche oder bessere Coverage nach Änderungen
|
||||
- Coverage goal: At least equal or better coverage after changes
|
||||
|
||||
### Test-Struktur
|
||||
| Datei | Testet |
|
||||
|-------|--------|
|
||||
| `routes.test.ts` | API-Endpunkte (Auth, Medications, Doses, Settings, Share, Planner) |
|
||||
| `services.test.ts` | Scheduler-Utilities (Timezone, Blisters, Usage-Berechnung) |
|
||||
| `db.test.ts` | Datenbank-Schema und Operationen |
|
||||
### Test Structure
|
||||
| File | Tests |
|
||||
|------|-------|
|
||||
| `routes.test.ts` | API endpoints (Auth, Medications, Doses, Settings, Share, Planner) |
|
||||
| `services.test.ts` | Scheduler utilities (Timezone, Blisters, Usage calculation) |
|
||||
| `db.test.ts` | Database schema and operations |
|
||||
|
||||
### Tests schreiben
|
||||
### Writing Tests
|
||||
|
||||
```typescript
|
||||
// Backend Test Beispiel (backend/src/test/example.test.ts)
|
||||
// Backend Test Example (backend/src/test/example.test.ts)
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
|
||||
|
||||
@@ -90,21 +97,24 @@ describe('Feature Name', () => {
|
||||
});
|
||||
```
|
||||
|
||||
### Test-Commands
|
||||
### Test Commands
|
||||
```bash
|
||||
cd backend
|
||||
npm test # Alle Tests ausführen
|
||||
npm run test:coverage # Mit Coverage-Report
|
||||
npm test -- --watch # Watch-Mode für Entwicklung
|
||||
npm test -- -t "test name" # Einzelnen Test ausführen
|
||||
CI=true npm test # Run tests once (ALWAYS run this way!)
|
||||
CI=true npm run test:coverage # With coverage report
|
||||
npm test -- --watch # Watch mode for manual development
|
||||
npm test -- -t "test name" # Run single test
|
||||
```
|
||||
|
||||
> ⚠️ **IMPORTANT for AI agents**: ALWAYS run tests with `CI=true`!
|
||||
> Without `CI=true`, Vitest runs in watch mode and waits for input.
|
||||
|
||||
## CI/CD Pipeline (GitHub Actions)
|
||||
|
||||
### Workflow-Übersicht
|
||||
### Workflow Overview
|
||||
|
||||
```
|
||||
Pull Request erstellt
|
||||
Pull Request created
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ test.yml │
|
||||
@@ -116,65 +126,161 @@ Pull Request erstellt
|
||||
│ ├─ npm ci │
|
||||
│ └─ npm run build │
|
||||
└─────────────────────────────────────┘
|
||||
↓ Tests müssen bestehen
|
||||
PR kann gemerged werden
|
||||
↓ Tests must pass
|
||||
PR can be merged
|
||||
↓
|
||||
Push to main / Tag erstellt
|
||||
Push to main / Tag created
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ docker-build.yml │
|
||||
│ ├─ backend-test (parallel) │
|
||||
│ ├─ frontend-build (parallel) │
|
||||
│ └─ build-and-push (nach Tests) │
|
||||
│ ├─ Docker Images bauen │
|
||||
│ └─ Push zu GHCR │
|
||||
│ └─ build-and-push (after tests) │
|
||||
│ ├─ Build Docker images │
|
||||
│ └─ Push to GHCR │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Branch Protection
|
||||
|
||||
> ⚠️ **WICHTIG**: Der `main` Branch ist geschützt!
|
||||
> Direktes Pushen nach `main` ist **nicht möglich** - GitHub lehnt den Push ab.
|
||||
> Alle Änderungen müssen über Pull Requests erfolgen.
|
||||
> ⚠️ **IMPORTANT**: The `main` branch is protected!
|
||||
> Direct pushing to `main` is **not possible** - GitHub will reject the push.
|
||||
> All changes must go through Pull Requests.
|
||||
|
||||
- **main** Branch ist geschützt (Repository Rules)
|
||||
- Direktes Pushen wird von GitHub abgelehnt mit: `GH013: Repository rule violations`
|
||||
- PRs benötigen:
|
||||
- ✅ `backend-test` Status Check bestanden
|
||||
- ✅ `frontend-build` Status Check bestanden
|
||||
- Nach erfolgreichem Merge wird der Feature-Branch automatisch gelöscht
|
||||
- **main** branch is protected (Repository Rules)
|
||||
- Direct pushing is rejected by GitHub with: `GH013: Repository rule violations`
|
||||
- PRs require:
|
||||
- ✅ `backend-test` Status Check passed
|
||||
- ✅ `frontend-build` Status Check passed
|
||||
- After successful merge, the feature branch is automatically deleted
|
||||
|
||||
**Workflow für Änderungen:**
|
||||
**Workflow for changes:**
|
||||
```bash
|
||||
# 1. Feature Branch erstellen
|
||||
git checkout -b feat/mein-feature
|
||||
# 1. Create feature branch
|
||||
git checkout -b feat/my-feature
|
||||
|
||||
# 2. Änderungen committen und pushen
|
||||
git add . && git commit -m "feat: Beschreibung"
|
||||
git push -u origin feat/mein-feature
|
||||
# 2. Commit and push changes
|
||||
git add . && git commit -m "feat: Description"
|
||||
git push -u origin feat/my-feature
|
||||
|
||||
# 3. PR erstellen (via GitHub CLI oder Web)
|
||||
gh pr create --title "Mein Feature" --body "Beschreibung"
|
||||
# 3. Create PR (via GitHub CLI or Web)
|
||||
gh pr create --title "My Feature" --body "Description"
|
||||
|
||||
# 4. Warten bis CI grün ist, dann mergen
|
||||
# 4. Wait until CI is green, then merge
|
||||
gh pr merge --squash --delete-branch
|
||||
```
|
||||
|
||||
### Workflow-Dateien
|
||||
| Datei | Trigger | Zweck |
|
||||
|-------|---------|-------|
|
||||
| `.github/workflows/test.yml` | Pull Requests | Tests ausführen, PR blockieren bei Fehlern |
|
||||
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Docker Images bauen und pushen |
|
||||
### Workflow Files
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|--------|
|
||||
| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
|
||||
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images |
|
||||
|
||||
### Neuen Code hinzufügen - Checkliste
|
||||
1. ✅ Feature implementieren
|
||||
2. ✅ Tests für das Feature schreiben
|
||||
3. ✅ Lokal `npm run test:coverage` ausführen
|
||||
4. ✅ Coverage darf nicht sinken
|
||||
5. ✅ Feature Branch erstellen und pushen
|
||||
6. ✅ Pull Request erstellen
|
||||
7. ✅ Warten bis CI grün ist
|
||||
8. ✅ PR mergen (Branch wird automatisch gelöscht)
|
||||
### Adding New Code - Checklist
|
||||
1. ✅ Implement feature
|
||||
2. ✅ Write tests for the feature
|
||||
3. ✅ Run `npm run test:coverage` locally
|
||||
4. ✅ Coverage must not decrease
|
||||
5. ✅ Create and push feature branch
|
||||
6. ✅ Create Pull Request
|
||||
7. ✅ Wait until CI is green
|
||||
8. ✅ Merge PR (branch is automatically deleted)
|
||||
|
||||
## GitHub Releases
|
||||
|
||||
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
|
||||
|
||||
### Creating Release Notes
|
||||
|
||||
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||
> Not just auto-generated commit lists, but a brief descriptive text.
|
||||
|
||||
**Keep it informative but concise.** Users want to know what changed and where to find it.
|
||||
|
||||
**Required structure of release notes:**
|
||||
|
||||
1. **"What's New"** (1-2 sentences): Brief intro explaining the main change
|
||||
2. **"New Features" / "Improvements"**: Grouped bullet points with **bold feature names** and descriptions
|
||||
3. **"Where to Find It"**: Tell users where they can access the new feature
|
||||
4. **Breaking Changes Warning** (if applicable): See below
|
||||
|
||||
**Style guidelines:**
|
||||
- Use `### Heading` for sections (New Features, Improvements, Security, etc.)
|
||||
- Use **bold** for feature names in bullet points
|
||||
- Keep descriptions on the same line as the feature name
|
||||
- Minimal emoji usage (sparingly, not on every line)
|
||||
- Always end with "Where to Find It" section
|
||||
|
||||
**DO NOT include:**
|
||||
- ❌ Technical implementation details (new columns, endpoints, database changes)
|
||||
- ❌ Number of tests added
|
||||
- ❌ Internal API changes (unless breaking)
|
||||
- ❌ Excessive emoji on every bullet point
|
||||
|
||||
**Example of good release notes:**
|
||||
|
||||
```markdown
|
||||
## What's New
|
||||
|
||||
This release introduces a medication refill tracking feature and improves the mobile user experience.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Medication Refill**: Track when you refill your medications with a single click. Add full packs or individual pills and view complete refill history.
|
||||
- **Automatic Stock Updates**: Stock levels are automatically recalculated after each refill.
|
||||
- **Refill History**: Each medication shows a complete history of all refills with timestamps.
|
||||
|
||||
### Mobile Improvements
|
||||
|
||||
- **Centered Tooltips**: Info tooltips now display centered on screen for better readability.
|
||||
- **Touch-friendly**: Tooltips close automatically when scrolling on touch devices.
|
||||
|
||||
### Where to Find It
|
||||
|
||||
The refill button appears in the medication detail modal and in the edit form for each medication.
|
||||
|
||||
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/v1.2.3...v1.3.0
|
||||
```
|
||||
|
||||
### Breaking Changes Warning (CRITICAL!)
|
||||
|
||||
> ⚠️ **MANDATORY**: If an update breaks existing configurations or stored data, it MUST be prominently warned about in the release notes!
|
||||
|
||||
**Breaking Changes include:**
|
||||
- Database schema changes without automatic migration
|
||||
- Removed or renamed ENV variables
|
||||
- Changed API endpoints
|
||||
- Incompatible `.env` format changes
|
||||
- Loss of stored data after update
|
||||
|
||||
**Format for Breaking Changes:**
|
||||
|
||||
```markdown
|
||||
## ⚠️ BREAKING CHANGES - Please read before updating!
|
||||
|
||||
**Database migration required**: This update changes the database schema.
|
||||
Existing installations need to:
|
||||
1. Create backup of `data/` folder
|
||||
2. Stop containers
|
||||
3. Perform update
|
||||
4. If issues occur: Rollback using backup
|
||||
|
||||
**ENV variables changed**:
|
||||
- `OLD_VAR` was renamed to `NEW_VAR`
|
||||
- `REMOVED_VAR` is no longer supported
|
||||
|
||||
**Medication data**: Intake schedules with only one time entry will be automatically
|
||||
migrated. Please verify all times are correct after update.
|
||||
```
|
||||
|
||||
**What is NOT a Breaking Change:**
|
||||
- ✅ New optional columns with DEFAULT values
|
||||
- ✅ New ENV variables (with sensible defaults)
|
||||
- ✅ New features that don't affect existing data
|
||||
- ✅ Bug fixes that correct behavior
|
||||
|
||||
**Rule of thumb**: If a user can simply run `docker compose pull && docker compose up -d`
|
||||
without adjusting anything → Not a Breaking Change.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -283,9 +389,25 @@ Each blister defines a recurring intake:
|
||||
| Stock Thresholds | Warning days, critical days, expiry warning days |
|
||||
| Email Notifications | Enable, email address, 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) |
|
||||
|
||||
### 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`)
|
||||
|
||||
| Table | Description |
|
||||
@@ -347,14 +469,75 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
||||
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
||||
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
||||
|
||||
## Database Schema Changes
|
||||
## 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
|
||||
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
|
||||
4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart
|
||||
### ⚠️ MANDATORY for EVERY New Feature
|
||||
|
||||
**Before implementing ANY feature that touches user data or settings:**
|
||||
|
||||
1. **Check if new DB columns are needed** - Does the feature require storing new data?
|
||||
2. **If YES → Follow ALL steps below** - Schema.ts + Drizzle migration + ALTER migration + NULL-safe code
|
||||
3. **NEVER skip the ALTER migration** - This is the #1 cause of production 500 errors!
|
||||
|
||||
**Common mistake:** Adding a column to `schema.ts` and forgetting the ALTER migration in `client.ts`.
|
||||
The Drizzle migration only works for NEW databases. Existing production databases need the ALTER migration!
|
||||
|
||||
### Schema Management with Drizzle Kit
|
||||
|
||||
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
|
||||
|
||||
- **`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
|
||||
|
||||
@@ -362,6 +545,8 @@ When adding new database columns:
|
||||
|---------|----------|
|
||||
| Backend entry | `backend/src/index.ts` |
|
||||
| Database schema | `backend/src/db/schema.ts` |
|
||||
| Drizzle migrations | `backend/drizzle/*.sql` |
|
||||
| Drizzle config | `backend/drizzle.config.ts` |
|
||||
| Backend routes | `backend/src/routes/*.ts` |
|
||||
| Backend services | `backend/src/services/*.ts` |
|
||||
| Frontend app | `frontend/src/App.tsx` |
|
||||
|
||||
@@ -3,6 +3,11 @@ name: Build and Push Docker Images
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- 'docker-compose*.yml'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -113,6 +118,8 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
# =============================================================================
|
||||
# Create GitHub Release (only on tag push)
|
||||
|
||||
@@ -16,11 +16,25 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get previous tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
# Get all tags sorted by version, find the one before current
|
||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
||||
|
||||
# If no previous tag found (first release), use empty
|
||||
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||
PREV_TAG=""
|
||||
fi
|
||||
|
||||
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Current tag: $CURRENT_TAG, Previous tag: $PREV_TAG"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get previous tag
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
PREV_TAG="${{ steps.prev_tag.outputs.previous_tag }}"
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
# First release - get all commits
|
||||
@@ -37,6 +51,6 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body_path: changelog.txt
|
||||
generate_release_notes: true
|
||||
generate_release_notes: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
> **Think of this app as a helpful tool, but make all health decisions independently!**
|
||||
|
||||
- [Features](#features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
@@ -38,11 +39,91 @@
|
||||
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
|
||||
</p>
|
||||
|
||||
<a id="screenshots"></a>
|
||||
<details>
|
||||
<summary><strong>Screenshots</strong></summary>
|
||||
<blockquote>
|
||||
|
||||
<details>
|
||||
<summary>Dashboard</summary>
|
||||
|
||||
Overview with stock status, reorder reminders, and upcoming schedules.
|
||||
|
||||
<img src="docs/screenshots/dashboard-desktop.png" alt="Dashboard" width="100%" />
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Medication Detail</summary>
|
||||
|
||||
View medication details, stock information, and intake schedule.
|
||||
|
||||
<img src="docs/screenshots/medication-detail-modal.png" alt="Medication Detail Modal" width="100%" />
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Medications & Edit Form</summary>
|
||||
|
||||
Manage your medications with the edit form and refill feature.
|
||||
|
||||
<img src="docs/screenshots/medications-edit-desktop.png" alt="Medications Edit Form" width="100%" />
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Demand Calculator (Planner)</summary>
|
||||
|
||||
Calculate how many pills you need for a specific date range.
|
||||
|
||||
<img src="docs/screenshots/planner-desktop.png" alt="Planner - Demand Calculator" width="100%" />
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Shared Schedule</summary>
|
||||
|
||||
Share your medication schedule with others via a public link.
|
||||
|
||||
<img src="docs/screenshots/share-schedule-desktop.png" alt="Shared Schedule" width="100%" />
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Mobile Views</summary>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%">
|
||||
<strong>Dashboard</strong><br>
|
||||
<img src="docs/screenshots/dashboard-mobile.png" alt="Mobile Dashboard" width="100%" />
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<strong>Medications</strong><br>
|
||||
<img src="docs/screenshots/medications-mobile.png" alt="Mobile Medications" width="100%" />
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<strong>Schedule</strong><br>
|
||||
<img src="docs/screenshots/schedule-mobile.png" alt="Mobile Schedule" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
</blockquote>
|
||||
</details>
|
||||
|
||||
### Smart Inventory
|
||||
- Track exact stock: packs, blisters, and loose pills
|
||||
- Display remaining days of supply
|
||||
- Automatic calculation based on intake schedule
|
||||
|
||||
### Medication Refill
|
||||
- One-click refill with pack or loose pill options
|
||||
- Complete refill history per medication
|
||||
- Automatic stock updates after each refill
|
||||
|
||||
### Flexible Schedules
|
||||
- Daily, weekly, or custom intervals per medication
|
||||
- Independent schedules for each medication
|
||||
@@ -60,9 +141,14 @@
|
||||
- Manage medications for multiple people
|
||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||
|
||||
### Data Export & Import
|
||||
- Export all your data (medications, dose history, settings) as JSON
|
||||
- Import previously exported data with automatic ID remapping
|
||||
- Choose whether to include sensitive data in exports
|
||||
|
||||
### Notifications
|
||||
- Email via SMTP
|
||||
- Push notifications via ntfy, Gotify, Telegram, Discord (Shoutrrr)
|
||||
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
||||
- Supports both stock warnings and intake reminders
|
||||
|
||||
### Privacy & Security
|
||||
@@ -148,6 +234,54 @@ Generate secrets with: `openssl rand -hex 32`
|
||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
||||
|
||||
### Push Notifications (Shoutrrr)
|
||||
|
||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||
|
||||
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
|
||||
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
|
||||
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
|
||||
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
|
||||
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
|
||||
|
||||
#### URL Examples
|
||||
|
||||
**ntfy** (free, self-hostable):
|
||||
```
|
||||
ntfy://ntfy.sh/your-topic
|
||||
ntfy://user:password@your-server.com/topic
|
||||
```
|
||||
|
||||
**Pushover** (free app for iOS/Android):
|
||||
```
|
||||
pushover://shoutrrr:API_TOKEN@USER_KEY/
|
||||
```
|
||||
Get your keys at [pushover.net](https://pushover.net/):
|
||||
- **User Key**: Shown on your dashboard (top right)
|
||||
- **API Token**: Create an application → copy the API Token
|
||||
|
||||
**Gotify** (self-hosted):
|
||||
```
|
||||
gotify://your-server.com/TOKEN
|
||||
```
|
||||
|
||||
**Discord**:
|
||||
```
|
||||
discord://TOKEN@WEBHOOK_ID
|
||||
```
|
||||
|
||||
**Telegram**:
|
||||
```
|
||||
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||
```
|
||||
|
||||
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
|
||||
# Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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",
|
||||
"version": "1.0.2",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@libsql/client": "^0.10.0",
|
||||
"argon2": "^0.40.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.32.2",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fastify": "^5.0.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"openid-client": "^6.8.1",
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4",
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import dotenv from "dotenv";
|
||||
import { getTableCreationSQL } from "./schema-sql.js";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
@@ -44,19 +50,69 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
|
||||
export { getTableCreationSQL } from "./schema-sql.js";
|
||||
/** Run drizzle-kit migrations on the database */
|
||||
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run table creation migrations on a client */
|
||||
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const tableCreations = getTableCreationSQL();
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
// 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 {
|
||||
await client.execute(sql);
|
||||
} 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)
|
||||
async function runMigrations() {
|
||||
const result = await runTableMigrations(client);
|
||||
if (result.errors.length > 0) {
|
||||
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
|
||||
// Run drizzle-kit generated migrations
|
||||
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||
const migrateResult = await runDrizzleMigrations(db);
|
||||
if (!migrateResult.success) {
|
||||
console.error(`[DB] Migration error:`, migrateResult.error);
|
||||
} else {
|
||||
console.log(`[DB] Drizzle migrations completed`);
|
||||
}
|
||||
|
||||
// Run ALTER TABLE migrations for backward compatibility
|
||||
const alterResult = await runAlterMigrations(client);
|
||||
if (alterResult.errors.length > 0) {
|
||||
alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err));
|
||||
}
|
||||
console.log(`[DB] Tables verified/created`);
|
||||
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getTableCreationSQL } from "./schema-sql.js";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
|
||||
/** Get the full migration SQL string (re-exported from schema-sql) */
|
||||
export { getTableCreationSQL };
|
||||
|
||||
/** Split SQL string into individual statements */
|
||||
/** Split SQL string into individual statements (for backwards compatibility with tests) */
|
||||
export function splitSQLStatements(sql: string): string[] {
|
||||
return sql.split(';').filter(s => s.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Execute migration statements on a client */
|
||||
/** Execute drizzle migrations on a database */
|
||||
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||
const statements = getTableCreationSQL();
|
||||
const errors: string[] = [];
|
||||
let executed = 0;
|
||||
const db = drizzle(client);
|
||||
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await client.execute(stmt);
|
||||
executed++;
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
}
|
||||
try {
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
// Count tables as a proxy for "executed" statements
|
||||
const tables = await client.execute(
|
||||
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
|
||||
);
|
||||
const executed = Number(tables.rows[0].count) || 0;
|
||||
|
||||
return { success: true, executed, errors };
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
return { success: false, executed: 0, errors };
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, executed, errors };
|
||||
}
|
||||
|
||||
/** Get a preview of statement (first N characters) */
|
||||
@@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db";
|
||||
async function main() {
|
||||
console.log("Starting database setup...");
|
||||
console.log("Database URL:", url);
|
||||
console.log("Migrations folder:", migrationsFolder);
|
||||
|
||||
const client = createClient({ url });
|
||||
const db = drizzle(client);
|
||||
|
||||
const statements = getTableCreationSQL();
|
||||
|
||||
for (const stmt of statements) {
|
||||
console.log("Executing:", getStatementPreview(stmt));
|
||||
await client.execute(stmt);
|
||||
}
|
||||
console.log("Running drizzle migrations...");
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
console.log("Database setup complete!");
|
||||
process.exit(0);
|
||||
|
||||
@@ -55,6 +55,10 @@ export function getTableCreationSQL(): string[] {
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
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,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
@@ -93,6 +97,17 @@ export function getTableCreationSQL(): string[] {
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
packs_added integer NOT NULL DEFAULT 0,
|
||||
loose_pills_added integer NOT NULL DEFAULT 0,
|
||||
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
|
||||
@@ -29,7 +29,9 @@ export const medications = sqliteTable("medications", {
|
||||
packCount: integer("pack_count").notNull().default(1),
|
||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||
looseTablets: integer("loose_tablets").notNull().default(0),
|
||||
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
|
||||
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
|
||||
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
|
||||
pillWeightMg: integer("pill_weight_mg"),
|
||||
usageJson: text("usage_json").notNull().default("[]"),
|
||||
everyJson: text("every_json").notNull().default("[]"),
|
||||
@@ -60,10 +62,15 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
// Reminder settings
|
||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||
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)
|
||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||
// UI preferences
|
||||
language: text("language", { length: 10 }).notNull().default("en"),
|
||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||
@@ -111,4 +118,17 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Refill History - Tracks when medication stock was refilled
|
||||
// =============================================================================
|
||||
export const refillHistory = sqliteTable("refill_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
packsAdded: integer("packs_added").notNull().default(0),
|
||||
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
||||
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ import { settingsRoutes } from "./routes/settings.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { shareRoutes } from "./routes/share.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
import { exportRoutes } from "./routes/export.js";
|
||||
import { refillRoutes } from "./routes/refills.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||
|
||||
@@ -113,6 +115,8 @@ export async function createApp(options?: {
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -181,6 +185,8 @@ await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
});
|
||||
|
||||
const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -127,6 +132,103 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
|
||||
const { doseIds } = parsed.data;
|
||||
|
||||
// Insert dismissed records for each dose that doesn't exist yet
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
// Check if already exists (taken or dismissed)
|
||||
const [existing] = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Already exists - update to dismissed if not already
|
||||
if (!existing.dismissed) {
|
||||
await db.update(doseTracking)
|
||||
.set({ dismissed: true })
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
// Create new dismissed record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
dismissed: true,
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, dismissedCount };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Delete all dismissed-only records (not taken ones)
|
||||
// For taken+dismissed, just remove the dismissed flag
|
||||
const dismissed = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.dismissed, true)
|
||||
)
|
||||
);
|
||||
|
||||
for (const d of dismissed) {
|
||||
if (d.markedBy !== null || d.takenAt) {
|
||||
// This was also marked as taken - just remove dismissed flag
|
||||
await db.update(doseTracking)
|
||||
.set({ dismissed: false })
|
||||
.where(eq(doseTracking.id, d.id));
|
||||
} else {
|
||||
// This was only dismissed - delete it
|
||||
await db.delete(doseTracking)
|
||||
.where(eq(doseTracking.id, d.id));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, clearedCount: dismissed.length };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,6 +253,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 { 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) {
|
||||
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,
|
||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||
looseTablets: row.looseTablets ?? 0,
|
||||
stockAdjustment: row.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: row.pillWeightMg,
|
||||
blisters: parseBlisters(row),
|
||||
imageUrl: row.imageUrl,
|
||||
@@ -147,6 +149,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
blistersPerPack: inserted.blistersPerPack,
|
||||
pillsPerBlister: inserted.pillsPerBlister,
|
||||
looseTablets: inserted.looseTablets,
|
||||
stockAdjustment: inserted.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: inserted.pillWeightMg,
|
||||
blisters,
|
||||
imageUrl: inserted.imageUrl,
|
||||
@@ -235,6 +239,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
blistersPerPack: result[0].blistersPerPack,
|
||||
pillsPerBlister: result[0].pillsPerBlister,
|
||||
looseTablets: result[0].looseTablets,
|
||||
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: result[0].pillWeightMg,
|
||||
blisters,
|
||||
imageUrl: result[0].imageUrl,
|
||||
@@ -245,6 +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) => {
|
||||
const idNum = Number(req.params.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 blistersPerPack = row.blistersPerPack ?? 1;
|
||||
const looseTablets = row.looseTablets ?? 0;
|
||||
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
const stockAdjustment = row.stockAdjustment ?? 0;
|
||||
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||
|
||||
// Calculate consumption up to now (same logic as frontend)
|
||||
let consumedUntilNow = 0;
|
||||
|
||||
@@ -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;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
@@ -45,6 +49,10 @@ type SettingsBody = {
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
language: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
};
|
||||
@@ -57,37 +65,58 @@ type TestShoutrrrBody = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
// Default settings for new users
|
||||
const defaultSettings = {
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailStockReminders: true,
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic" as const,
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
// Helper to parse boolean env vars
|
||||
function envBool(key: string, defaultVal: boolean): boolean {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
return val === "true" || val === "1";
|
||||
}
|
||||
|
||||
// Helper to parse integer env vars
|
||||
function envInt(key: string, defaultVal: number): number {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
const parsed = parseInt(val, 10);
|
||||
return isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
|
||||
// Default settings for new users - read from ENV with fallbacks
|
||||
function getDefaultSettings() {
|
||||
return {
|
||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||
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
|
||||
async function getOrCreateUserSettings(userId: number) {
|
||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings for user
|
||||
// Create default settings for user (using ENV defaults)
|
||||
[settings] = await db.insert(userSettings).values({
|
||||
userId,
|
||||
...defaultSettings,
|
||||
...getDefaultSettings(),
|
||||
}).returning();
|
||||
}
|
||||
|
||||
@@ -109,6 +138,10 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
@@ -135,6 +168,10 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
@@ -187,6 +224,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||
// SMTP settings (from .env - shared/server-configured)
|
||||
@@ -233,6 +274,10 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||||
reminderDaysBefore: body.reminderDaysBefore,
|
||||
repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: body.maxNaggingReminders ?? 5,
|
||||
lowStockDays: body.lowStockDays ?? 30,
|
||||
normalStockDays: body.normalStockDays ?? 90,
|
||||
highStockDays: body.highStockDays ?? 180,
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// Parse takenBy JSON array
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
|
||||
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, gte, lte } from "drizzle-orm";
|
||||
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 { resolve } from "path";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
parseBlisters,
|
||||
parseTakenByJson,
|
||||
getUpcomingIntakes,
|
||||
getTodaysIntakes,
|
||||
parseIntakeReminderState,
|
||||
createDefaultIntakeReminderState,
|
||||
cleanOldIntakeReminders,
|
||||
@@ -46,7 +47,13 @@ function parseBlistersFromRow(row: { usageJson: string; everyJson: string; start
|
||||
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 smtpUser = process.env.SMTP_USER;
|
||||
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
|
||||
: 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 = `
|
||||
<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);">
|
||||
<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;">
|
||||
<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}
|
||||
|
||||
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
|
||||
${description}
|
||||
|
||||
${intakes.map((i) => {
|
||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||
@@ -152,7 +164,9 @@ ${intakes.map((i) => {
|
||||
---
|
||||
${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 {
|
||||
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> {
|
||||
logger.info(`[IntakeReminder] Checking for intake reminders...`);
|
||||
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
|
||||
if (allUserSettings.length === 0) {
|
||||
logger.info(`[IntakeReminder] No users with settings found`);
|
||||
return; // No users with settings
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
|
||||
|
||||
for (const userSettings of allUserSettings) {
|
||||
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
||||
}
|
||||
@@ -200,56 +219,190 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
const language = settings.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)
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||
|
||||
if (!emailEnabled && !shoutrrrEnabled) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`);
|
||||
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
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id);
|
||||
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
|
||||
|
||||
if (medsWithReminders.length === 0) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
||||
return; // No medications have reminders enabled for this user
|
||||
}
|
||||
|
||||
const state = loadIntakeReminderState();
|
||||
const allUpcoming: UpcomingIntake[] = [];
|
||||
const locale = getDateLocale(language);
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`);
|
||||
|
||||
// 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) {
|
||||
const blisters = parseBlistersFromRow(med);
|
||||
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) {
|
||||
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)
|
||||
const newReminders = allUpcoming.filter(intake => {
|
||||
// Determine which doses need reminders (new or repeated)
|
||||
const nowMs = Date.now();
|
||||
let remindersToSend: typeof allUpcoming = [];
|
||||
|
||||
for (const intake of allUpcoming) {
|
||||
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
||||
return !state.sentReminders.includes(key);
|
||||
});
|
||||
const existingEntry = state.reminders[key];
|
||||
const intakeTimeMs = intake.intakeTime.getTime();
|
||||
const isIntakePast = intakeTimeMs < nowMs;
|
||||
|
||||
if (newReminders.length === 0) {
|
||||
return; // All reminders already sent
|
||||
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
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${newReminders.length} upcoming intakes...`);
|
||||
if (remindersToSend.length === 0) {
|
||||
return; // All reminders already sent and no repeats needed
|
||||
}
|
||||
|
||||
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
|
||||
if (settings.skipRemindersForTakenDoses) {
|
||||
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
|
||||
const takenToday = await db.select().from(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, settings.userId),
|
||||
gte(doseTracking.takenAt, todayStart),
|
||||
lte(doseTracking.takenAt, todayEnd)
|
||||
)
|
||||
);
|
||||
|
||||
const takenDoseIds = new Set(takenToday.map(d => d.doseId));
|
||||
|
||||
// Filter out reminders for doses that were already taken
|
||||
remindersToSend = remindersToSend.filter(intake => {
|
||||
const timestamp = intake.intakeTime.getTime();
|
||||
|
||||
// Check both with and without person suffix
|
||||
if (intake.takenBy.length > 0) {
|
||||
// For multi-person medications, check if any person has taken it
|
||||
const anyTaken = intake.takenBy.some(person => {
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`;
|
||||
return takenDoseIds.has(doseId);
|
||||
});
|
||||
return !anyTaken; // Skip if any person has taken it
|
||||
} else {
|
||||
// For non-person-specific medications
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`;
|
||||
return !takenDoseIds.has(doseId);
|
||||
}
|
||||
});
|
||||
|
||||
if (remindersToSend.length === 0) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
|
||||
|
||||
// Determine if this is a repeat reminder:
|
||||
// - Any intake already has a state entry AND is past (repeat after first reminder)
|
||||
// - OR intake is past even without state entry (missed the 15-min window)
|
||||
const isRepeatReminder = remindersToSend.some(intake => {
|
||||
const intakeTimeMs = intake.intakeTime.getTime();
|
||||
const isIntakePast = intakeTimeMs < nowMs;
|
||||
return isIntakePast; // Use repeat message for ANY missed intake
|
||||
});
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
// Send email if enabled for intake reminders
|
||||
if (emailEnabled) {
|
||||
const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language);
|
||||
const result = await sendIntakeReminderEmail(
|
||||
settings.notificationEmail!,
|
||||
remindersToSend,
|
||||
language,
|
||||
isRepeatReminder,
|
||||
settings.reminderRepeatIntervalMinutes
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
||||
@@ -260,8 +413,15 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
// Send Shoutrrr notification if enabled for intake reminders
|
||||
if (shoutrrrEnabled) {
|
||||
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
|
||||
const message = newReminders
|
||||
const title = isRepeatReminder
|
||||
? (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) => {
|
||||
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}`;
|
||||
@@ -271,7 +431,7 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
}
|
||||
return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
|
||||
})
|
||||
.join("\n");
|
||||
.join("\n") + repeatNote;
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
@@ -284,14 +444,32 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
// Update state if any notification was sent successfully
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`);
|
||||
// Update or create entries for sent reminders
|
||||
for (const intake of remindersToSend) {
|
||||
const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`;
|
||||
const existing = state.reminders[key];
|
||||
|
||||
// Clean up old entries (older than 24 hours)
|
||||
const cleanedReminders = cleanOldIntakeReminders(state.sentReminders);
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
saveIntakeReminderState({
|
||||
sentReminders: [...cleanedReminders, ...newKeys],
|
||||
});
|
||||
// Clean up old entries (remove doses from past days)
|
||||
state.reminders = cleanOldIntakeReminders(state.reminders, tz);
|
||||
|
||||
saveIntakeReminderState(state);
|
||||
|
||||
// Update global reminder state for UI display
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
|
||||
@@ -93,7 +93,7 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
|
||||
|
||||
for (const row of rows) {
|
||||
const blisters = parseBlistersFromRow(row);
|
||||
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
|
||||
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||
|
||||
// Check if medication runs out within reminderDaysBefore days
|
||||
|
||||
@@ -1,45 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { mkdirSync, rmSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { resolve, dirname } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Import the exported utility functions from client.ts
|
||||
import {
|
||||
buildDbUrl,
|
||||
getDbPaths,
|
||||
ensureDataDirectory,
|
||||
getTableCreationSQL,
|
||||
runTableMigrations,
|
||||
runDrizzleMigrations,
|
||||
runAlterMigrations,
|
||||
ensureDefaultUser,
|
||||
} from "../db/client.js";
|
||||
|
||||
// Import the exported utility functions from migrate.ts
|
||||
import {
|
||||
getTableCreationSQL as getTableCreationSQLFromMigrate,
|
||||
splitSQLStatements,
|
||||
executeMigration,
|
||||
getStatementPreview,
|
||||
} from "../db/migrate.js";
|
||||
|
||||
// Get migrations folder path
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
describe("Migration Script Utilities", () => {
|
||||
describe("getTableCreationSQL", () => {
|
||||
it("should return a non-empty array of SQL statements", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
expect(Array.isArray(statements)).toBe(true);
|
||||
expect(statements.length).toBeGreaterThan(0);
|
||||
describe("executeMigration", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
});
|
||||
|
||||
it("should contain all table definitions", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const allSQL = statements.join(" ");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens");
|
||||
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking");
|
||||
it("should execute all migrations successfully", async () => {
|
||||
const result = await executeMigration(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executed).toBeGreaterThan(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create all tables", async () => {
|
||||
await executeMigration(client);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
|
||||
);
|
||||
|
||||
const tableNames = tables.rows.map(r => r.name);
|
||||
expect(tableNames).toContain("users");
|
||||
expect(tableNames).toContain("medications");
|
||||
expect(tableNames).toContain("user_settings");
|
||||
expect(tableNames).toContain("refresh_tokens");
|
||||
expect(tableNames).toContain("share_tokens");
|
||||
expect(tableNames).toContain("dose_tracking");
|
||||
expect(tableNames).toContain("refill_history");
|
||||
});
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
await executeMigration(client);
|
||||
const result = await executeMigration(client);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow inserting data after migration", async () => {
|
||||
await executeMigration(client);
|
||||
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
const result = await client.execute("SELECT * FROM users");
|
||||
expect(result.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,11 +95,6 @@ describe("Migration Script Utilities", () => {
|
||||
expect(statements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should handle getTableCreationSQL output correctly", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
expect(statements).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should preserve whitespace within statements", () => {
|
||||
const sql = "CREATE TABLE test (\n id INTEGER\n);";
|
||||
const statements = splitSQLStatements(sql);
|
||||
@@ -103,52 +131,6 @@ describe("Migration Script Utilities", () => {
|
||||
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeMigration", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
});
|
||||
|
||||
it("should execute all migrations successfully", async () => {
|
||||
const result = await executeMigration(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executed).toBe(6);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create all tables", async () => {
|
||||
await executeMigration(client);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
);
|
||||
|
||||
const tableNames = tables.rows.map(r => r.name);
|
||||
expect(tableNames).toContain("users");
|
||||
expect(tableNames).toContain("medications");
|
||||
expect(tableNames).toContain("user_settings");
|
||||
expect(tableNames).toContain("refresh_tokens");
|
||||
expect(tableNames).toContain("share_tokens");
|
||||
expect(tableNames).toContain("dose_tracking");
|
||||
});
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
await executeMigration(client);
|
||||
const result = await executeMigration(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executed).toBe(6);
|
||||
});
|
||||
|
||||
it("should allow inserting data after migration", async () => {
|
||||
await executeMigration(client);
|
||||
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
const result = await client.execute("SELECT * FROM users");
|
||||
expect(result.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Database Client Utilities", () => {
|
||||
@@ -218,63 +200,7 @@ describe("Database Client Utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTableCreationSQL", () => {
|
||||
it("should return array of SQL statements", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
expect(Array.isArray(statements)).toBe(true);
|
||||
expect(statements.length).toBe(6);
|
||||
});
|
||||
|
||||
it("should include users table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users"));
|
||||
expect(usersSQL).toBeDefined();
|
||||
expect(usersSQL).toContain("username text NOT NULL UNIQUE");
|
||||
expect(usersSQL).toContain("password_hash text");
|
||||
expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'");
|
||||
});
|
||||
|
||||
it("should include medications table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications"));
|
||||
expect(medsSQL).toBeDefined();
|
||||
expect(medsSQL).toContain("user_id integer NOT NULL");
|
||||
expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'");
|
||||
expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE");
|
||||
});
|
||||
|
||||
it("should include user_settings table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings"));
|
||||
expect(settingsSQL).toBeDefined();
|
||||
expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0");
|
||||
expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'");
|
||||
});
|
||||
|
||||
it("should include refresh_tokens table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens"));
|
||||
expect(tokensSQL).toBeDefined();
|
||||
expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE");
|
||||
});
|
||||
|
||||
it("should include share_tokens table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens"));
|
||||
expect(shareSQL).toBeDefined();
|
||||
expect(shareSQL).toContain("taken_by text NOT NULL");
|
||||
});
|
||||
|
||||
it("should include dose_tracking table", () => {
|
||||
const statements = getTableCreationSQL();
|
||||
const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking"));
|
||||
expect(doseSQL).toBeDefined();
|
||||
expect(doseSQL).toContain("dose_id text NOT NULL");
|
||||
expect(doseSQL).toContain("marked_by text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTableMigrations", () => {
|
||||
describe("runDrizzleMigrations", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -282,23 +208,24 @@ describe("Database Client Utilities", () => {
|
||||
});
|
||||
|
||||
it("should create all tables successfully", async () => {
|
||||
const result = await runTableMigrations(client);
|
||||
const db = drizzle(client);
|
||||
const result = await runDrizzleMigrations(db);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should be idempotent (run twice without errors)", async () => {
|
||||
await runTableMigrations(client);
|
||||
const result = await runTableMigrations(client);
|
||||
const db = drizzle(client);
|
||||
await runDrizzleMigrations(db);
|
||||
const result = await runDrizzleMigrations(db);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create all 6 tables", async () => {
|
||||
await runTableMigrations(client);
|
||||
it("should create all 7 tables", async () => {
|
||||
const db = drizzle(client);
|
||||
await runDrizzleMigrations(db);
|
||||
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
|
||||
);
|
||||
|
||||
const tableNames = tables.rows.map(r => r.name);
|
||||
@@ -308,6 +235,29 @@ describe("Database Client Utilities", () => {
|
||||
expect(tableNames).toContain("refresh_tokens");
|
||||
expect(tableNames).toContain("share_tokens");
|
||||
expect(tableNames).toContain("dose_tracking");
|
||||
expect(tableNames).toContain("refill_history");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAlterMigrations", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
});
|
||||
|
||||
it("should run without errors on a fresh database", async () => {
|
||||
const result = await runAlterMigrations(client);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
await runAlterMigrations(client);
|
||||
const result = await runAlterMigrations(client);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,7 +266,8 @@ describe("Database Client Utilities", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await runTableMigrations(client);
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
});
|
||||
|
||||
it("should create default user when auth is disabled", async () => {
|
||||
@@ -386,246 +337,83 @@ describe("Database Client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Table Schema Creation", () => {
|
||||
describe("Table Schema via Drizzle Migrations", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
});
|
||||
|
||||
it("should create users table", async () => {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
avatar_url text,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
oidc_subject text,
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
last_login_at integer,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)
|
||||
`);
|
||||
it("should have users table with correct columns", async () => {
|
||||
const columns = await client.execute("PRAGMA table_info(users)");
|
||||
const columnNames = columns.rows.map(r => r.name);
|
||||
|
||||
// Verify table exists
|
||||
const tables = await client.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
);
|
||||
expect(tables.rows).toHaveLength(1);
|
||||
expect(columnNames).toContain("id");
|
||||
expect(columnNames).toContain("username");
|
||||
expect(columnNames).toContain("password_hash");
|
||||
expect(columnNames).toContain("auth_provider");
|
||||
});
|
||||
|
||||
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'
|
||||
)
|
||||
`);
|
||||
it("should have medications table with correct columns", async () => {
|
||||
const columns = await client.execute("PRAGMA table_info(medications)");
|
||||
const columnNames = columns.rows.map(r => r.name);
|
||||
|
||||
await 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);
|
||||
expect(columnNames).toContain("id");
|
||||
expect(columnNames).toContain("user_id");
|
||||
expect(columnNames).toContain("name");
|
||||
expect(columnNames).toContain("taken_by_json");
|
||||
expect(columnNames).toContain("pack_count");
|
||||
expect(columnNames).toContain("usage_json");
|
||||
});
|
||||
|
||||
it("should 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'
|
||||
)
|
||||
`);
|
||||
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);
|
||||
|
||||
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);
|
||||
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 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'
|
||||
)
|
||||
`);
|
||||
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);
|
||||
|
||||
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);
|
||||
expect(columnNames).toContain("id");
|
||||
expect(columnNames).toContain("user_id");
|
||||
expect(columnNames).toContain("token_id");
|
||||
});
|
||||
|
||||
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'
|
||||
)
|
||||
`);
|
||||
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);
|
||||
|
||||
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);
|
||||
expect(columnNames).toContain("id");
|
||||
expect(columnNames).toContain("token");
|
||||
expect(columnNames).toContain("taken_by");
|
||||
});
|
||||
|
||||
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'
|
||||
)
|
||||
`);
|
||||
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);
|
||||
|
||||
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);
|
||||
expect(columnNames).toContain("id");
|
||||
expect(columnNames).toContain("dose_id");
|
||||
expect(columnNames).toContain("marked_by");
|
||||
});
|
||||
|
||||
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'
|
||||
)
|
||||
`);
|
||||
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);
|
||||
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
expires_at integer NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await client.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||
)
|
||||
).rejects.toThrow();
|
||||
expect(columnNames).toContain("id");
|
||||
expect(columnNames).toContain("medication_id");
|
||||
expect(columnNames).toContain("packs_added");
|
||||
expect(columnNames).toContain("loose_pills_added");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -634,15 +422,8 @@ describe("Database Client", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local',
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)
|
||||
`);
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
});
|
||||
|
||||
it("should use default values for auth_provider", async () => {
|
||||
@@ -656,16 +437,8 @@ describe("Database Client", () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
|
||||
expect(result.rows[0].is_active).toBe(1);
|
||||
});
|
||||
|
||||
it("should generate created_at timestamp", async () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'");
|
||||
expect(typeof result.rows[0].created_at).toBe("number");
|
||||
// Should be a reasonable Unix timestamp (after year 2020)
|
||||
expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800);
|
||||
// SQLite stores booleans as integers
|
||||
expect(result.rows[0].is_active).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -674,40 +447,18 @@ describe("Database Client", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
shoutrrr_enabled integer NOT NULL DEFAULT 0,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
repeat_daily_reminders integer NOT NULL DEFAULT 0,
|
||||
low_stock_days integer NOT NULL DEFAULT 30,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it("should use default notification settings", async () => {
|
||||
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
|
||||
|
||||
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
|
||||
expect(result.rows[0].email_enabled).toBe(0);
|
||||
expect(result.rows[0].shoutrrr_enabled).toBe(0);
|
||||
// SQLite stores booleans as integers (false = 0)
|
||||
expect(result.rows[0].email_enabled).toBeFalsy();
|
||||
expect(result.rows[0].shoutrrr_enabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should use default stock threshold settings", async () => {
|
||||
@@ -717,7 +468,6 @@ describe("Database Client", () => {
|
||||
expect(result.rows[0].low_stock_days).toBe(30);
|
||||
expect(result.rows[0].normal_stock_days).toBe(90);
|
||||
expect(result.rows[0].high_stock_days).toBe(180);
|
||||
expect(result.rows[0].expiry_warning_days).toBe(90);
|
||||
});
|
||||
|
||||
it("should use default language (en)", async () => {
|
||||
@@ -747,32 +497,9 @@ describe("Database Client", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
it("should use default inventory values", async () => {
|
||||
@@ -795,11 +522,11 @@ describe("Database Client", () => {
|
||||
expect(result.rows[0].start_json).toBe("[]");
|
||||
});
|
||||
|
||||
it("should default intake_reminders_enabled to false (0)", async () => {
|
||||
it("should default intake_reminders_enabled to false", async () => {
|
||||
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
|
||||
|
||||
const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'");
|
||||
expect(result.rows[0].intake_reminders_enabled).toBe(0);
|
||||
expect(result.rows[0].intake_reminders_enabled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -810,21 +537,8 @@ describe("Database Client", () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
// Enable foreign keys
|
||||
await client.execute("PRAGMA foreign_keys = ON");
|
||||
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE
|
||||
)
|
||||
`);
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
});
|
||||
|
||||
it("should cascade delete medications when user is deleted", async () => {
|
||||
@@ -845,18 +559,44 @@ describe("Database Client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unique Constraints", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on username", async () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
|
||||
await expect(
|
||||
client.execute("INSERT INTO users (username) VALUES ('testuser')")
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on refresh token_id", async () => {
|
||||
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
|
||||
await client.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default User Creation (Auth Disabled)", () => {
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = createClient({ url: ":memory:" });
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
auth_provider text NOT NULL DEFAULT 'local'
|
||||
)
|
||||
`);
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
});
|
||||
|
||||
it("should be able to create a default user with ID 1", async () => {
|
||||
|
||||
@@ -80,6 +80,45 @@ async function registerDoseRoutes(ctx: TestContext) {
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// POST /doses/dismiss - Dismiss missed doses without deducting stock
|
||||
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
|
||||
const userId = 1;
|
||||
const { doseIds } = request.body || {};
|
||||
|
||||
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
|
||||
return reply.status(400).send({ error: "doseIds array is required" });
|
||||
}
|
||||
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
// Check if already exists
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Update to dismissed if not already
|
||||
if (!existing.rows[0].dismissed) {
|
||||
await client.execute({
|
||||
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
|
||||
args: [existing.rows[0].id],
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
// Insert new dismissed record
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, dismissedCount };
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -361,4 +400,101 @@ describe("Dose Tracking API", () => {
|
||||
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dismiss Doses Tests (POST /doses/dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /doses/dismiss", () => {
|
||||
it("should dismiss multiple doses", async () => {
|
||||
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should not double-count already dismissed doses", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Dismiss once
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
// Dismiss again
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
|
||||
});
|
||||
|
||||
it("should reject empty doseIds array", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||
});
|
||||
|
||||
it("should reject missing doseIds", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||
});
|
||||
|
||||
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// First mark as taken
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
// Then dismiss it
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/dismiss",
|
||||
payload: { doseIds: [doseId] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
|
||||
|
||||
// Verify it's now dismissed
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows[0].dismissed).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,8 @@ const { shareRoutes } = await import("../routes/share.js");
|
||||
const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { settingsRoutes } = await import("../routes/settings.js");
|
||||
const { healthRoutes } = await import("../routes/health.js");
|
||||
const { refillRoutes } = await import("../routes/refills.js");
|
||||
const { exportRoutes } = await import("../routes/export.js");
|
||||
|
||||
// =============================================================================
|
||||
// Test Setup
|
||||
@@ -83,6 +85,8 @@ async function createSchema(client: Client) {
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||
last_stock_correction_at integer,
|
||||
pill_weight_mg integer,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
@@ -107,6 +111,10 @@ async function createSchema(client: Client) {
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
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,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
@@ -135,6 +143,17 @@ async function createSchema(client: Client) {
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
packs_added integer NOT NULL DEFAULT 0,
|
||||
loose_pills_added integer NOT NULL DEFAULT 0,
|
||||
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
@@ -145,6 +164,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM refill_history");
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
@@ -225,6 +245,8 @@ describe("E2E Tests with Real Routes", () => {
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(healthRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(exportRoutes);
|
||||
|
||||
await app.ready();
|
||||
});
|
||||
@@ -556,6 +578,9 @@ describe("E2E Tests with Real Routes", () => {
|
||||
url: "/settings",
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
console.error("GET /settings error:", response.body);
|
||||
}
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
// Check default values
|
||||
@@ -720,7 +745,10 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
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.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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||
last_stock_correction_at integer,
|
||||
pill_weight_mg integer,
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
@@ -104,6 +106,10 @@ async function createSchema(client: Client) {
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
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,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
@@ -132,6 +138,7 @@ async function createSchema(client: Client) {
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
|
||||
@@ -94,6 +94,10 @@ async function createSchema(client: Client) {
|
||||
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
|
||||
reminder_days_before integer NOT NULL DEFAULT 7,
|
||||
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,
|
||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||
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,
|
||||
calculateDepletionInfo,
|
||||
getUpcomingIntakes,
|
||||
getTodaysIntakes,
|
||||
createDefaultReminderState,
|
||||
createDefaultIntakeReminderState,
|
||||
parseReminderState,
|
||||
@@ -381,6 +382,94 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
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", () => {
|
||||
@@ -399,7 +488,7 @@ describe("Scheduler Utils - State Management", () => {
|
||||
describe("createDefaultIntakeReminderState", () => {
|
||||
it("should create default intake reminder state", () => {
|
||||
const state = createDefaultIntakeReminderState();
|
||||
expect(state.sentReminders).toEqual([]);
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,62 +528,91 @@ describe("Scheduler Utils - State Management", () => {
|
||||
});
|
||||
|
||||
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 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", () => {
|
||||
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("{}");
|
||||
expect(state.sentReminders).toEqual([]);
|
||||
expect(state.reminders).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanOldIntakeReminders", () => {
|
||||
it("should remove entries older than maxAgeMs", () => {
|
||||
const now = Date.now();
|
||||
const oldTimestamp = now - 25 * 60 * 60 * 1000; // 25 hours ago
|
||||
const recentTimestamp = now - 1 * 60 * 60 * 1000; // 1 hour ago
|
||||
it("should remove entries from past days (timezone-aware)", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const now = new Date();
|
||||
const today = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
today.setHours(12, 0, 0, 0);
|
||||
|
||||
const reminders = [
|
||||
`med1:${oldTimestamp}`,
|
||||
`med2:${recentTimestamp}`,
|
||||
];
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
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);
|
||||
expect(cleaned[0]).toContain("med2");
|
||||
const cleaned = cleanOldIntakeReminders(reminders, tz);
|
||||
|
||||
expect(Object.keys(cleaned)).toHaveLength(1);
|
||||
expect(cleaned[`med2:${today.getTime()}`]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should keep all entries if none are old", () => {
|
||||
const now = Date.now();
|
||||
const reminders = [
|
||||
`med1:${now - 1000}`,
|
||||
`med2:${now - 2000}`,
|
||||
];
|
||||
it("should keep all entries from today", () => {
|
||||
const tz = "Europe/Berlin";
|
||||
const now = new Date();
|
||||
const morning = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
morning.setHours(8, 0, 0, 0);
|
||||
|
||||
const cleaned = cleanOldIntakeReminders(reminders);
|
||||
expect(cleaned).toHaveLength(2);
|
||||
const evening = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
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", () => {
|
||||
const cleaned = cleanOldIntakeReminders([]);
|
||||
expect(cleaned).toEqual([]);
|
||||
it("should handle empty reminders", () => {
|
||||
const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin");
|
||||
expect(cleaned).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle malformed entries (invalid timestamp)", () => {
|
||||
const reminders = ["med1:invalid", "med2:notanumber"];
|
||||
const cleaned = cleanOldIntakeReminders(reminders);
|
||||
// NaN from parseInt will cause these to be filtered out (0 < cutoff)
|
||||
expect(cleaned).toEqual([]);
|
||||
it("should handle malformed entries (invalid timestamp in key)", () => {
|
||||
const reminders = {
|
||||
"med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 },
|
||||
"med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 }
|
||||
};
|
||||
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,
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
@@ -62,6 +66,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders),
|
||||
reminderDaysBefore: s.reminder_days_before,
|
||||
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,
|
||||
normalStockDays: s.normal_stock_days,
|
||||
highStockDays: s.high_stock_days,
|
||||
@@ -84,6 +92,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
shoutrrrIntakeReminders?: boolean;
|
||||
reminderDaysBefore?: number;
|
||||
repeatDailyReminders?: boolean;
|
||||
skipRemindersForTakenDoses?: boolean;
|
||||
repeatRemindersEnabled?: boolean;
|
||||
reminderRepeatIntervalMinutes?: number;
|
||||
maxNaggingReminders?: number;
|
||||
lowStockDays?: number;
|
||||
normalStockDays?: number;
|
||||
highStockDays?: number;
|
||||
@@ -111,6 +123,12 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) {
|
||||
return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" });
|
||||
}
|
||||
if (body.reminderRepeatIntervalMinutes !== undefined && (body.reminderRepeatIntervalMinutes < 5 || body.reminderRepeatIntervalMinutes > 480)) {
|
||||
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
|
||||
const existing = await client.execute({
|
||||
@@ -126,10 +144,11 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
email_stock_reminders, email_intake_reminders,
|
||||
shoutrrr_enabled, shoutrrr_url,
|
||||
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,
|
||||
expiry_warning_days, language, stock_calculation_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
userId,
|
||||
body.emailEnabled ? 1 : 0,
|
||||
@@ -142,6 +161,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||
body.reminderDaysBefore ?? 7,
|
||||
body.repeatDailyReminders ? 1 : 0,
|
||||
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||
body.repeatRemindersEnabled ? 1 : 0,
|
||||
body.reminderRepeatIntervalMinutes ?? 30,
|
||||
body.maxNaggingReminders ?? 5,
|
||||
body.lowStockDays ?? 30,
|
||||
body.normalStockDays ?? 90,
|
||||
body.highStockDays ?? 180,
|
||||
@@ -164,6 +187,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
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 = ?,
|
||||
@@ -183,6 +210,10 @@ async function registerSettingsRoutes(ctx: TestContext) {
|
||||
body.shoutrrrIntakeReminders !== false ? 1 : 0,
|
||||
body.reminderDaysBefore ?? 7,
|
||||
body.repeatDailyReminders ? 1 : 0,
|
||||
body.skipRemindersForTakenDoses ? 1 : 0,
|
||||
body.repeatRemindersEnabled ? 1 : 0,
|
||||
body.reminderRepeatIntervalMinutes ?? 30,
|
||||
body.maxNaggingReminders ?? 5,
|
||||
body.lowStockDays ?? 30,
|
||||
body.normalStockDays ?? 90,
|
||||
body.highStockDays ?? 180,
|
||||
@@ -507,4 +538,137 @@ describe("Settings API", () => {
|
||||
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 { createClient, Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import { getTableCreationSQL } from "../db/schema-sql.js";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Get migrations folder path
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
// Type for our test database
|
||||
export type TestDb = ReturnType<typeof drizzle>;
|
||||
@@ -61,14 +68,11 @@ export async function buildTestApp(): Promise<TestContext> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test database schema
|
||||
* Create test database schema using drizzle-kit migrations
|
||||
*/
|
||||
async function runTestMigrations(client: Client): Promise<void> {
|
||||
const tableCreations = getTableCreationSQL();
|
||||
|
||||
for (const sql of tableCreations) {
|
||||
await client.execute(sql);
|
||||
}
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -107,6 +111,9 @@ export interface CreateMedicationOptions {
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
pillWeightMg?: number;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
/** Array of { usage, every, start } for each blister schedule */
|
||||
blisters?: Array<{ usage: number; every: number; start: string }>;
|
||||
}
|
||||
@@ -128,6 +135,9 @@ export async function createTestMedication(
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
pillWeightMg = null,
|
||||
expiryDate = null,
|
||||
notes = null,
|
||||
intakeRemindersEnabled = false,
|
||||
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
||||
} = options;
|
||||
|
||||
@@ -141,8 +151,8 @@ export async function createTestMedication(
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
pill_weight_mg, usage_json, every_json, start_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
userId,
|
||||
name,
|
||||
@@ -156,6 +166,9 @@ export async function createTestMedication(
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate,
|
||||
notes,
|
||||
intakeRemindersEnabled ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -273,6 +286,7 @@ export async function closeTestApp(ctx: TestContext): Promise<void> {
|
||||
*/
|
||||
export async function clearTestData(client: Client): Promise<void> {
|
||||
// Order matters due to foreign keys
|
||||
await client.execute("DELETE FROM refill_history");
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
await client.execute("DELETE FROM refresh_tokens");
|
||||
|
||||
@@ -188,6 +188,70 @@ export type UpcomingIntake = {
|
||||
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.
|
||||
* Returns intakes that should be notified about right now.
|
||||
@@ -277,8 +341,14 @@ export type ReminderState = {
|
||||
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 = {
|
||||
sentReminders: string[];
|
||||
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||
};
|
||||
|
||||
/** Create default reminder state */
|
||||
@@ -295,7 +365,7 @@ export function createDefaultReminderState(): ReminderState {
|
||||
|
||||
/** Create default intake reminder state */
|
||||
export function createDefaultIntakeReminderState(): IntakeReminderState {
|
||||
return { sentReminders: [] };
|
||||
return { reminders: {} };
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
try {
|
||||
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 {
|
||||
sentReminders: saved.sentReminders ?? [],
|
||||
reminders: saved.reminders ?? {},
|
||||
};
|
||||
} catch {
|
||||
return createDefaultIntakeReminderState();
|
||||
@@ -328,10 +414,21 @@ export function parseIntakeReminderState(json: string): IntakeReminderState {
|
||||
}
|
||||
|
||||
/** Clean up old intake reminder entries (older than given milliseconds) */
|
||||
export function cleanOldIntakeReminders(sentReminders: string[], maxAgeMs: number = 24 * 60 * 60 * 1000): string[] {
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
return sentReminders.filter(key => {
|
||||
/** Clean up old intake reminder entries (using timezone-aware day check) */
|
||||
export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
|
||||
// Get start of today in user's timezone
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
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);
|
||||
return timestamp > cutoff;
|
||||
});
|
||||
if (timestamp >= todayStartMs) {
|
||||
cleaned[key] = entry;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
|
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 Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Allow larger file uploads (for medication images)
|
||||
client_max_body_size 10M;
|
||||
# Allow larger file uploads (for medication images and data import/export)
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1713,9 +1713,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
|
||||
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -1735,12 +1735,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
|
||||
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
|
||||
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.11.0"
|
||||
"react-router": "7.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -15,7 +15,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
||||
{t("auth.register", "Create Account")}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{/* SSO Login Button - also show on registration */}
|
||||
{authState?.oidcEnabled && (
|
||||
<div className="auth-sso">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary auth-submit sso-btn"
|
||||
onClick={() => window.location.href = "/api/auth/oidc/login"}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sso-icon">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
||||
</button>
|
||||
{authState?.localAuthEnabled && (
|
||||
<div className="auth-divider">
|
||||
<span>{t("auth.or", "or")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Registration Form - only show if local auth is enabled */}
|
||||
{authState?.localAuthEnabled && (
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
||||
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{onSwitchToLogin && (
|
||||
<div className="auth-links">
|
||||
|
||||
@@ -38,7 +38,14 @@
|
||||
"pastDaysCount": "{{count}} Tag",
|
||||
"pastDaysCount_other": "{{count}} Tage",
|
||||
"missedDoses": "{{count}} verpasste Dosis",
|
||||
"missedDoses_other": "{{count}} verpasste Dosen"
|
||||
"missedDoses_other": "{{count}} verpasste Dosen",
|
||||
"clearMissed": "Verpasste löschen",
|
||||
"clearMissedConfirmTitle": "Verpasste Dosen löschen?",
|
||||
"clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||
"clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||
"clearMissedConfirm": "Ja, löschen",
|
||||
"clearMissedCancel": "Abbrechen",
|
||||
"clearMissedSuccess": "{{count}} verpasste Dosen gelöscht"
|
||||
},
|
||||
"reminders": {
|
||||
"active": "Automatische Erinnerungen aktiv",
|
||||
@@ -91,8 +98,8 @@
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"newEntry": "Neuer Eintrag",
|
||||
"editEntry": "Medikament bearbeiten",
|
||||
"newEntry": "Neues Medikament",
|
||||
"badge": "Packungen + lose Tabletten",
|
||||
"commercialName": "Handelsname",
|
||||
"genericName": "Wirkstoff",
|
||||
@@ -157,7 +164,15 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Bestands-Erinnerungen",
|
||||
"intakeReminders": "Einnahme-Erinnerungen",
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten."
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
||||
"repeatRemindersTooltip": "Sende automatisch wiederholte Erinnerungen für Dosen, die noch nicht als genommen markiert wurden",
|
||||
"reminderInterval": "Erinnerungsintervall (Minuten)",
|
||||
"reminderIntervalTooltip": "Wie oft wiederholte Erinnerungen für verpasste Dosen gesendet werden sollen",
|
||||
"maxNaggingReminders": "Max. Erinnerungen pro Dosis",
|
||||
"maxNaggingRemindersTooltip": "Wiederholungserinnerungen nach dieser Anzahl Versuchen stoppen (1-20)"
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Empfänger",
|
||||
@@ -165,7 +180,9 @@
|
||||
},
|
||||
"push": {
|
||||
"url": "URL",
|
||||
"supports": "Unterstützt ntfy, Discord, Telegram, Slack"
|
||||
"urlPlaceholder": "ntfy://topic oder pushover://:token@userkey/",
|
||||
"supports": "Unterstützt ntfy, Pushover, Gotify, Discord, Telegram, Slack & mehr",
|
||||
"docsLink": "Siehe shoutrrr.dev für alle Services"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Erinnerungsplan",
|
||||
@@ -209,7 +226,7 @@
|
||||
"pillWeight": "Tablettengewicht",
|
||||
"expiryDate": "Ablaufdatum",
|
||||
"intakeSchedule": "Einnahmeplan",
|
||||
"coverageStatus": "Reichweite",
|
||||
"coverageStatus": "Bestand",
|
||||
"daysLeft": "Tage übrig",
|
||||
"runsOut": "Aufgebraucht",
|
||||
"notes": "Notizen",
|
||||
@@ -313,7 +330,8 @@
|
||||
"fullBlister": "voller Blister",
|
||||
"fullBlisters": "volle Blister",
|
||||
"inBlister": "in 1 Blister",
|
||||
"total": "gesamt"
|
||||
"total": "gesamt",
|
||||
"max": "max"
|
||||
},
|
||||
"share": {
|
||||
"button": "Teilen",
|
||||
@@ -340,5 +358,88 @@
|
||||
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
||||
"expiredOn": "Abgelaufen am: {{date}}"
|
||||
}
|
||||
},
|
||||
"exportImport": {
|
||||
"title": "Daten Export / Import",
|
||||
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
|
||||
"exportTitle": "Export",
|
||||
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
|
||||
"importTitle": "Import",
|
||||
"importDesc": "Stelle Daten aus einer Sicherung wieder her. Dies ersetzt alle bestehenden Daten.",
|
||||
"export": "Daten exportieren",
|
||||
"exporting": "Exportiere...",
|
||||
"import": "Datei auswählen",
|
||||
"importing": "Importiere...",
|
||||
"selectFile": "Datei auswählen",
|
||||
"includeSensitive": "Sensible Daten einschließen (Benachrichtigungs-URLs)",
|
||||
"sensitiveWarning": "Benachrichtigungs-URLs können Passwörter enthalten und werden im Klartext gespeichert.",
|
||||
"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_other": "{{count}} days",
|
||||
"missedDoses": "{{count}} missed dose",
|
||||
"missedDoses_other": "{{count}} missed doses"
|
||||
"missedDoses_other": "{{count}} missed doses",
|
||||
"clearMissed": "Clear missed",
|
||||
"clearMissedConfirmTitle": "Clear Missed Doses?",
|
||||
"clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.",
|
||||
"clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.",
|
||||
"clearMissedConfirm": "Yes, Clear",
|
||||
"clearMissedCancel": "Cancel",
|
||||
"clearMissedSuccess": "Cleared {{count}} missed doses"
|
||||
},
|
||||
"reminders": {
|
||||
"active": "Automatic reminders active",
|
||||
@@ -93,8 +100,8 @@
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Edit entry",
|
||||
"newEntry": "New entry",
|
||||
"editEntry": "Edit medication",
|
||||
"newEntry": "New medication",
|
||||
"badge": "Packs + loose pills",
|
||||
"commercialName": "Commercial Name",
|
||||
"genericName": "Generic Name",
|
||||
@@ -159,7 +166,15 @@
|
||||
"push": "Push",
|
||||
"stockReminders": "Stock Reminders",
|
||||
"intakeReminders": "Intake Reminders",
|
||||
"enableHint": "Enable at least one channel below to receive notifications."
|
||||
"enableHint": "Enable at least one channel below to receive notifications.",
|
||||
"skipTakenDoses": "Skip reminders for taken doses",
|
||||
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
|
||||
"repeatReminders": "Repeat reminders for missed doses",
|
||||
"repeatRemindersTooltip": "Automatically send repeated reminders for doses that haven't been marked as taken",
|
||||
"reminderInterval": "Reminder interval (minutes)",
|
||||
"reminderIntervalTooltip": "How often to send repeated reminders for missed doses",
|
||||
"maxNaggingReminders": "Max reminders per dose",
|
||||
"maxNaggingRemindersTooltip": "Stop sending repeat reminders after this many attempts (1-20)"
|
||||
},
|
||||
"email": {
|
||||
"recipient": "Recipient",
|
||||
@@ -167,7 +182,9 @@
|
||||
},
|
||||
"push": {
|
||||
"url": "URL",
|
||||
"supports": "Supports ntfy, Discord, Telegram, Slack"
|
||||
"urlPlaceholder": "ntfy://topic or pushover://:token@userkey/",
|
||||
"supports": "Supports ntfy, Pushover, Gotify, Discord, Telegram, Slack & more",
|
||||
"docsLink": "See shoutrrr.dev for all services"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Reminder Schedule",
|
||||
@@ -315,7 +332,8 @@
|
||||
"fullBlister": "full blister",
|
||||
"fullBlisters": "full blisters",
|
||||
"inBlister": "in 1 blister",
|
||||
"total": "total"
|
||||
"total": "total",
|
||||
"max": "max"
|
||||
},
|
||||
"share": {
|
||||
"button": "Share",
|
||||
@@ -342,5 +360,88 @@
|
||||
"contact": "Please contact {{username}} to request a new link.",
|
||||
"expiredOn": "Expired on: {{date}}"
|
||||
}
|
||||
},
|
||||
"exportImport": {
|
||||
"title": "Data Export / Import",
|
||||
"description": "Backup your data or transfer it to another device.",
|
||||
"exportTitle": "Export",
|
||||
"exportDesc": "Download all your data as a JSON file.",
|
||||
"importTitle": "Import",
|
||||
"importDesc": "Restore data from a backup file. This will replace all existing data.",
|
||||
"export": "Export Data",
|
||||
"exporting": "Exporting...",
|
||||
"import": "Select File",
|
||||
"importing": "Importing...",
|
||||
"selectFile": "Select File",
|
||||
"includeSensitive": "Include sensitive data (notification URLs)",
|
||||
"sensitiveWarning": "Notification URLs may contain passwords and will be stored in plain text.",
|
||||
"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);
|
||||
}
|
||||
|
||||
/* Success button (Refill, etc.) */
|
||||
button.success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button.success:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
button.success:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary/Accent button (New entry, Add intake, etc.) */
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
button.primary:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Info button (Edit, secondary actions) */
|
||||
button.info {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button.info:hover {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
/* Ghost button (Cancel, etc.) */
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
@@ -637,6 +677,7 @@ textarea.auto-resize {
|
||||
.past-days-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
@@ -662,6 +703,7 @@ textarea.auto-resize {
|
||||
.past-days-icon {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.past-days-label {
|
||||
font-weight: 500;
|
||||
@@ -669,6 +711,8 @@ textarea.auto-resize {
|
||||
.past-days-count {
|
||||
opacity: 0.6;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.past-days-warning {
|
||||
margin-left: auto;
|
||||
@@ -690,6 +734,35 @@ textarea.auto-resize {
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
}
|
||||
|
||||
/* Past days header container - toggle + clear button */
|
||||
.past-days-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.past-days-header .past-days-toggle {
|
||||
flex: 1;
|
||||
}
|
||||
.clear-missed-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--warning);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: var(--btn-radius);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 150ms ease, transform 100ms ease;
|
||||
}
|
||||
.clear-missed-btn:hover {
|
||||
background: rgba(234, 179, 8, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.clear-missed-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Past day blocks styling */
|
||||
.day-block.past {
|
||||
opacity: 0.7;
|
||||
@@ -1359,13 +1432,21 @@ textarea.auto-resize {
|
||||
}
|
||||
|
||||
.setting-row.language-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-row.language-row .setting-label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
max-width: 200px;
|
||||
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
@@ -1634,11 +1715,42 @@ textarea.auto-resize {
|
||||
}
|
||||
|
||||
.info-tooltip:hover::after,
|
||||
.info-tooltip:hover::before {
|
||||
.info-tooltip:hover::before,
|
||||
.info-tooltip:focus::after,
|
||||
.info-tooltip:focus::before,
|
||||
.info-tooltip.tooltip-active::after,
|
||||
.info-tooltip.tooltip-active::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Mobile tooltip - disable hover, use click only */
|
||||
@media (max-width: 640px) {
|
||||
.info-tooltip:hover::after,
|
||||
.info-tooltip:hover::before {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.info-tooltip.tooltip-active::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: calc(100vw - 32px);
|
||||
width: max-content;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.info-tooltip::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Channels Overview */
|
||||
.channels-overview {
|
||||
display: flex;
|
||||
@@ -1947,7 +2059,8 @@ textarea.auto-resize {
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -1960,11 +2073,27 @@ textarea.auto-resize {
|
||||
|
||||
.schedule-label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Mobile: stack schedule rows vertically when text is long */
|
||||
@media (max-width: 400px) {
|
||||
.schedule-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.schedule-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy support for old channel-btn (can remove later) */
|
||||
@@ -2061,10 +2190,13 @@ textarea.auto-resize {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Compact Setting Row */
|
||||
/* Compact Setting Row - for inline toggles without card styling */
|
||||
.setting-row.compact {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.75rem 0;
|
||||
margin-top: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.setting-row.compact .setting-label {
|
||||
@@ -2255,6 +2387,9 @@ textarea.auto-resize {
|
||||
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
|
||||
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
|
||||
|
||||
.med-avatar.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
|
||||
.med-avatar.clickable:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||
|
||||
/* Table/Timeline cells with avatar */
|
||||
.cell-with-avatar {
|
||||
display: flex;
|
||||
@@ -2856,14 +2991,22 @@ textarea.auto-resize {
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
max-width: 50vw;
|
||||
max-height: 50vh;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
animation: zoomIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* Mobile: larger lightbox image */
|
||||
@media (max-width: 768px) {
|
||||
.lightbox-image {
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from { opacity: 0; transform: scale(0.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
@@ -2903,6 +3046,22 @@ textarea.auto-resize {
|
||||
|
||||
.med-detail-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.med-detail-footer > button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.med-detail-footer .footer-actions {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.med-detail-footer button {
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.med-detail-grid {
|
||||
@@ -3254,7 +3413,17 @@ h3 .reminder-icon.info-tooltip {
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.user-menu:hover .user-dropdown {
|
||||
/* Only use hover on devices that support it (not touch) */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.user-menu:hover .user-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Click-based open for all devices */
|
||||
.user-menu.open .user-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
@@ -3560,6 +3729,239 @@ h3 .reminder-icon.info-tooltip {
|
||||
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
|
||||
============================================================================= */
|
||||
@@ -3688,6 +4090,299 @@ h3 .reminder-icon.info-tooltip {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Refill Modal & History
|
||||
============================================================================= */
|
||||
.refill-modal {
|
||||
max-width: 500px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.refill-modal h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.refill-med-name {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.refill-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.refill-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.refill-form input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.refill-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refill-footer-right .refill-preview {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
/* Refill modal footer mobile */
|
||||
@media (max-width: 640px) {
|
||||
.refill-modal .modal-footer {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.refill-modal .modal-footer > button,
|
||||
.refill-modal .modal-footer .refill-footer-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refill-modal .modal-footer .refill-footer-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.refill-modal .modal-footer .refill-footer-right button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline refill form in edit modal */
|
||||
.refill-form-inline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.refill-form-inline label {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.refill-form-inline label input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refill-form-inline button {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0;
|
||||
align-self: flex-end;
|
||||
height: 42px;
|
||||
padding: 0 1rem;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.refill-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
padding: 0 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--success);
|
||||
border-radius: 6px;
|
||||
color: var(--success);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.refill-section {
|
||||
border-left: 3px solid var(--success);
|
||||
background: var(--bg-secondary);
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 1rem 1rem 1.25rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.refill-section .refill-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.refill-section .refill-form-inline button {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.refill-section .refill-form-inline button:hover:not(:disabled) {
|
||||
background: var(--success-hover, #3aa865);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.refill-section .refill-form-inline button:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
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)
|
||||
============================================================================= */
|
||||
@@ -3977,3 +4672,60 @@ h3 .reminder-icon.info-tooltip {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Action Cards (for Export/Import etc.) - similar to radio-card */
|
||||
.action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.action-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-card-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.action-card-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.action-card button,
|
||||
.action-card .btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.action-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.action-card button,
|
||||
.action-card .btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
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({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(packageJson.version || "unknown"),
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
|
||||