Compare commits

..

32 Commits

Author SHA1 Message Date
Daniel Volz dbd2661498 Merge branch 'main' of github.com:DanielVolz/medassist-ng
* 'main' of github.com:DanielVolz/medassist-ng:
  chore: release v1.4.1 (#59)
2026-01-20 19:35:03 +01:00
Daniel Volz 89edd74de3 chore: release v1.4.1 (#59)
* chore: release v1.4.0

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

* fix: Add drizzle migrations folder to production Docker image

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-01-20 18:31:28 +01:00
Daniel Volz e24a540f17 fix: show package size in user medications modal (#54)
The user medications modal (clicking on a 'taken by' badge) was showing
the adjusted stock as total (e.g. 152/152) instead of the package size
(e.g. 152/196).

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

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

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

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

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

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

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

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

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

* fix: correct i18n interpolation in About modal

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

* fix: correct license to MIT

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

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

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

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

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

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

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

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

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

* feat: add back-swipe support for all modals

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

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

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

* fix: improve schedule row layout for mobile screens

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

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

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

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

* docs: move screenshots below GIF and add visual indentation
2026-01-17 22:06:34 +01:00
Daniel Volz d5f6ceba19 docs: Update README with new features and screenshots (#32)
- Add Medication Refill and Data Export/Import to features
- Add collapsible screenshots section with 8 new screenshots
- Include desktop views: Dashboard, Medication Detail, Edit Form, Planner, Shared Schedule
- Include mobile views: Dashboard, Medications, Schedule
- Remove old dashboard.png, add properly named screenshots
2026-01-17 21:57:29 +01:00
Daniel Volz 6f0553d7dd docs: update release notes style guide (#31)
- Add structured format with What's New, Features, Where to Find It
- Prefer bold feature names with inline descriptions
- Minimize emoji usage
- Add comprehensive example based on v1.3.0 release
2026-01-17 21:18:10 +01:00
Daniel Volz 82b2be48cd feat: Add Medication Refill feature with mobile UI improvements (#30)
* feat: Add Medication Refill feature with UI improvements

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

* fix: Improve mobile tooltips and refill modal layout

- Center tooltips on screen for mobile devices (fixed position)
- Close tooltips automatically when scrolling on touch devices
- Use click-based tooltip activation instead of hover on mobile
- Fix refill modal buttons to display in two rows on mobile
2026-01-17 20:39:18 +01:00
Daniel Volz 269a549563 docs: update release notes guidelines - less is more (#29)
- Add 'Keep it short' principle
- Add DO NOT include list (no technical details)
- Simplify example release notes
- Remove verbose example with implementation details
2026-01-16 22:04:34 +01:00
51 changed files with 7333 additions and 881 deletions
+108 -38
View File
@@ -4,6 +4,7 @@
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository.
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
@@ -190,36 +191,82 @@ gh pr merge --squash --delete-branch
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
### Release Workflow (MANDATORY for minor/major releases)
The `main` branch is protected - releases must go through the automated release script.
**Release Process:**
```bash
# 1. Run release script (creates PR, waits for CI, merges, creates tag)
./scripts/release.sh [patch|minor|major]
# 2. GitHub Actions creates a DRAFT release automatically
# 3. User asks AI to write release notes:
# "Write the release notes for vX.Y.Z"
# 4. AI writes descriptive release notes following the style guide below
# 5. User publishes the draft release with the written notes
```
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
> Do NOT just publish the auto-generated commit list. Follow the process above.
**AI Assistant Release Notes Workflow:**
1. When user asks to write release notes for a version:
- Check commits since previous tag: `git log vPREV..vNEW --oneline`
- Read through the changes to understand what was added/fixed
- Write release notes following the style guide below
- Present the notes to the user for copying to GitHub
### Creating Release Notes
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
> Not just auto-generated commit lists, but a brief descriptive text.
**Structure of a release text:**
**Keep it informative but concise.** Users want to know what changed and where to find it.
1. **Intro** (1-2 sentences): What's new, what was improved?
2. **Features & Changes**: Brief list of key changes
3. **Breaking Changes Warning** (if applicable): See below
4. **Optional**: Acknowledgements, documentation links
**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 adds intake reminder notifications and improves medication stock tracking. Users can now configure nagging reminders for missed doses and receive alerts when medication stock runs low.
This release introduces a medication refill tracking feature and improves the mobile user experience.
### New Features
- 🔔 Intake reminder notifications with configurable nagging intervals
- 📊 Enhanced stock calculation with blister tracking
- 🌐 German translation improvements
### Bug Fixes
- Fixed timezone handling in dose scheduling
- Improved image upload validation
- **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.
### Full Changelog
[All commits since v1.2.0](link)
### Mobile Improvements
- **Centered Tooltips**: Info tooltips now display centered on screen for better readability.
- **Touch-friendly**: Tooltips close automatically when scrolling on touch devices.
### Where to Find It
The refill button appears in the medication detail modal and in the edit form for each medication.
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/v1.2.3...v1.3.0
```
### Breaking Changes Warning (CRITICAL!)
@@ -455,40 +502,61 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
> Users upgrade their Docker containers but keep their existing DB.
> The app must NOT crash if old columns are missing.
### ⚠️ MANDATORY for EVERY New Feature
**Before implementing ANY feature that touches user data or settings:**
1. **Check if new DB columns are needed** - Does the feature require storing new data?
2. **If YES → Follow ALL steps below** - Schema.ts + Drizzle migration + ALTER migration + NULL-safe code
3. **NEVER skip the ALTER migration** - This is the #1 cause of production 500 errors!
**Common mistake:** Adding a column to `schema.ts` and forgetting the ALTER migration in `client.ts`.
The Drizzle migration only works for NEW databases. Existing production databases need the ALTER migration!
### Schema Management with Drizzle Kit
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
- **`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. **Update schema SQL**: Add to these files:
- `backend/src/db/schema.ts` - Drizzle Schema
- `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` for new DBs
- `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` migration
4. **Update test schemas**: All test files with their own schema:
- `backend/src/test/e2e-routes.test.ts`
- `backend/src/test/integration.test.ts`
- `backend/src/test/planner.test.ts`
### Example: Adding a New Column
```typescript
// 1. schema.ts - Drizzle definition
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
// 2. schema-sql.ts - For new databases
"max_nagging_reminders integer NOT NULL DEFAULT 5,"
// 3. client.ts - Migration for existing DBs (IN ensureTablesExist())
await client.execute(`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`).catch(() => {});
// 4. Routes - NULL-safe reading
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
```
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
@@ -504,6 +572,8 @@ If a breaking change is unavoidable:
|---------|----------|
| Backend entry | `backend/src/index.ts` |
| Database schema | `backend/src/db/schema.ts` |
| Drizzle migrations | `backend/drizzle/*.sql` |
| Drizzle config | `backend/drizzle.config.ts` |
| Backend routes | `backend/src/routes/*.ts` |
| Backend services | `backend/src/services/*.ts` |
| Frontend app | `frontend/src/App.tsx` |
+7
View File
@@ -3,6 +3,11 @@ name: Build and Push Docker Images
on:
push:
branches: [main]
paths:
- 'backend/**'
- 'frontend/**'
- 'docker-compose*.yml'
- '.github/workflows/docker-build.yml'
tags: ['v*']
workflow_dispatch:
inputs:
@@ -113,6 +118,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
provenance: false
sbom: false
# =============================================================================
# Create GitHub Release (only on tag push)
+43 -21
View File
@@ -16,41 +16,63 @@ jobs:
with:
fetch-depth: 0
- name: Get previous tag
id: prev_tag
- name: Get version info
id: version
run: |
# Get all tags sorted by version, find the one before current
CURRENT_TAG=${GITHUB_REF#refs/tags/}
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
VERSION=${CURRENT_TAG#v}
echo "tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
# If no previous tag found (first release), use empty
# Get previous tag
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
PREV_TAG=""
fi
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
echo "Current tag: $CURRENT_TAG, Previous tag: $PREV_TAG"
- name: Generate changelog
id: changelog
- name: Generate release template
run: |
PREV_TAG="${{ steps.prev_tag.outputs.previous_tag }}"
cat > release_notes.md << 'EOF'
## What's New
if [ -z "$PREV_TAG" ]; then
# First release - get all commits
CHANGES=$(git log --pretty=format:"- %s" HEAD)
else
# Get commits since last tag
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
fi
<!--
Write 1-2 sentences describing the main changes in this release.
Example: This release introduces a medication refill tracking feature and improves the mobile user experience.
-->
# Write to file for multiline support
echo "$CHANGES" > changelog.txt
### New Features
<!-- List new features with **bold** names and descriptions -->
- **Feature Name**: Description of the feature
### Improvements
<!-- List improvements and fixes -->
- **Improvement**: Description
### Where to Find It
<!-- Tell users where they can access new features -->
---
## Docker Images
```bash
docker pull ghcr.io/danielvolz/medassist-ng-backend:${{ steps.version.outputs.version }}
docker pull ghcr.io/danielvolz/medassist-ng-frontend:${{ steps.version.outputs.version }}
```
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/${{ steps.version.outputs.previous_tag }}...${{ steps.version.outputs.tag }}
EOF
- name: Create Release
- name: Create Draft Release
uses: softprops/action-gh-release@v1
with:
body_path: changelog.txt
body_path: release_notes.md
draft: true
generate_release_notes: false
name: "Release ${{ steps.version.outputs.tag }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+66 -26
View File
@@ -1,33 +1,73 @@
# Node
# ===================
# Dependencies
# ===================
node_modules/
.pnpm-store/
# ===================
# Build outputs
# ===================
dist/
build/
.tmp/
*.tsbuildinfo
# ===================
# Test & Coverage
# ===================
coverage/
.nyc_output/
# ===================
# Environment
# ===================
.env
.env.*
!.env.example
# ===================
# Database & Data
# ===================
*.db
*.sqlite
*.sqlite3
*.db-journal
*.db-wal
*.db-shm
data/
# ===================
# Logs
# ===================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist/
build/
coverage/
.tmp/
# Env
.env
.env.*
!.env.example
# SQLite
*.db
*.sqlite
*.sqlite3
*.db-journal
backend/data/
# Logs
logs/
*.log
# Editor
.vscode/
.idea/
# ===================
# OS files
# ===================
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# ===================
# IDE / Editor
# ===================
.idea/
*.sublime-project
*.sublime-workspace
# Keep shared VS Code settings
# .vscode/ is NOT ignored - settings.json is useful for the team
# ===================
# Misc
# ===================
*.local
.cache/
.turbo/
+5
View File
@@ -0,0 +1,5 @@
{
"vitest.root": "backend",
"vitest.enable": true,
"vitest.commandLine": "npm test --"
}
+86
View File
@@ -28,6 +28,7 @@
> **Think of this app as a helpful tool, but make all health decisions independently!**
- [Features](#features)
- [Screenshots](#screenshots)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Development](#development)
@@ -38,11 +39,91 @@
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
</p>
<a id="screenshots"></a>
<details>
<summary><strong>Screenshots</strong></summary>
<blockquote>
<details>
<summary>Dashboard</summary>
Overview with stock status, reorder reminders, and upcoming schedules.
<img src="docs/screenshots/dashboard-desktop.png" alt="Dashboard" width="100%" />
</details>
<details>
<summary>Medication Detail</summary>
View medication details, stock information, and intake schedule.
<img src="docs/screenshots/medication-detail-modal.png" alt="Medication Detail Modal" width="100%" />
</details>
<details>
<summary>Medications & Edit Form</summary>
Manage your medications with the edit form and refill feature.
<img src="docs/screenshots/medications-edit-desktop.png" alt="Medications Edit Form" width="100%" />
</details>
<details>
<summary>Demand Calculator (Planner)</summary>
Calculate how many pills you need for a specific date range.
<img src="docs/screenshots/planner-desktop.png" alt="Planner - Demand Calculator" width="100%" />
</details>
<details>
<summary>Shared Schedule</summary>
Share your medication schedule with others via a public link.
<img src="docs/screenshots/share-schedule-desktop.png" alt="Shared Schedule" width="100%" />
</details>
<details>
<summary>Mobile Views</summary>
<table>
<tr>
<td align="center" width="33%">
<strong>Dashboard</strong><br>
<img src="docs/screenshots/dashboard-mobile.png" alt="Mobile Dashboard" width="100%" />
</td>
<td align="center" width="33%">
<strong>Medications</strong><br>
<img src="docs/screenshots/medications-mobile.png" alt="Mobile Medications" width="100%" />
</td>
<td align="center" width="33%">
<strong>Schedule</strong><br>
<img src="docs/screenshots/schedule-mobile.png" alt="Mobile Schedule" width="100%" />
</td>
</tr>
</table>
</details>
</blockquote>
</details>
### Smart Inventory
- Track exact stock: packs, blisters, and loose pills
- Display remaining days of supply
- Automatic calculation based on intake schedule
### Medication Refill
- One-click refill with pack or loose pill options
- Complete refill history per medication
- Automatic stock updates after each refill
### Flexible Schedules
- Daily, weekly, or custom intervals per medication
- Independent schedules for each medication
@@ -60,6 +141,11 @@
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
### Data Export & Import
- Export all your data (medications, dose history, settings) as JSON
- Import previously exported data with automatic ID remapping
- Choose whether to include sensitive data in exports
### Notifications
- Email via SMTP
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
+3
View File
@@ -46,6 +46,9 @@ COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# Copy drizzle migrations folder (required for database setup)
COPY drizzle ./drizzle
# Create data directory and set ownership to node user (UID 1000)
RUN mkdir -p /app/data && chown -R node:node /app
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL || "./data/medassist.db",
},
});
+112
View File
@@ -0,0 +1,112 @@
CREATE TABLE `dose_tracking` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_id` text(255) NOT NULL,
`taken_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`marked_by` text(100),
`dismissed` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `medications` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`generic_name` text(100),
`taken_by_json` text DEFAULT '[]' NOT NULL,
`pack_count` integer DEFAULT 1 NOT NULL,
`blisters_per_pack` integer DEFAULT 1 NOT NULL,
`pills_per_blister` integer DEFAULT 1 NOT NULL,
`loose_tablets` integer DEFAULT 0 NOT NULL,
`pill_weight_mg` integer,
`usage_json` text DEFAULT '[]' NOT NULL,
`every_json` text DEFAULT '[]' NOT NULL,
`start_json` text DEFAULT '[]' NOT NULL,
`image_url` text,
`expiry_date` text,
`notes` text,
`intake_reminders_enabled` integer DEFAULT false NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `refill_history` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`medication_id` integer NOT NULL,
`user_id` integer NOT NULL,
`packs_added` integer DEFAULT 0 NOT NULL,
`loose_pills_added` integer DEFAULT 0 NOT NULL,
`refill_date` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `refresh_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token_id` text(255) NOT NULL,
`expires_at` integer NOT NULL,
`rotated_at` integer,
`revoked` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `refresh_tokens_token_id_unique` ON `refresh_tokens` (`token_id`);--> statement-breakpoint
CREATE TABLE `share_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token` text(64) NOT NULL,
`taken_by` text(100) NOT NULL,
`schedule_days` integer DEFAULT 30 NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`expires_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `share_tokens_token_unique` ON `share_tokens` (`token`);--> statement-breakpoint
CREATE TABLE `user_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`email_enabled` integer DEFAULT false NOT NULL,
`notification_email` text,
`email_stock_reminders` integer DEFAULT true NOT NULL,
`email_intake_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_enabled` integer DEFAULT false NOT NULL,
`shoutrrr_url` text,
`shoutrrr_stock_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_intake_reminders` integer DEFAULT true NOT NULL,
`reminder_days_before` integer DEFAULT 7 NOT NULL,
`repeat_daily_reminders` integer DEFAULT false NOT NULL,
`skip_reminders_for_taken_doses` integer DEFAULT false NOT NULL,
`repeat_reminders_enabled` integer DEFAULT false NOT NULL,
`reminder_repeat_interval_minutes` integer DEFAULT 30 NOT NULL,
`max_nagging_reminders` integer DEFAULT 5 NOT NULL,
`low_stock_days` integer DEFAULT 30 NOT NULL,
`normal_stock_days` integer DEFAULT 90 NOT NULL,
`high_stock_days` integer DEFAULT 180 NOT NULL,
`expiry_warning_days` integer DEFAULT 90 NOT NULL,
`language` text(10) DEFAULT 'en' NOT NULL,
`stock_calculation_mode` text(20) DEFAULT 'automatic' NOT NULL,
`last_auto_email_sent` text,
`last_notification_type` text,
`last_notification_channel` text,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_settings_user_id_unique` ON `user_settings` (`user_id`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text(100) NOT NULL,
`password_hash` text(255),
`avatar_url` text(255),
`auth_provider` text(50) DEFAULT 'local' NOT NULL,
`oidc_subject` text(255),
`is_active` integer DEFAULT true NOT NULL,
`last_login_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
@@ -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;
+819
View File
@@ -0,0 +1,819 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+827
View File
@@ -0,0 +1,827 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bcb60728-38c0-4965-adac-829c02240d89",
"prevId": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+834
View File
@@ -0,0 +1,834 @@
{
"version": "6",
"dialect": "sqlite",
"id": "098ee506-e43d-4ccb-bee5-c387905695ab",
"prevId": "bcb60728-38c0-4965-adac-829c02240d89",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+27
View File
@@ -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
}
]
}
+1009 -19
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.1.0",
"version": "1.4.1",
"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",
+62 -19
View File
@@ -1,12 +1,18 @@
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
import { resolve } from "path";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
import { getTableCreationSQL } from "./schema-sql.js";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// =============================================================================
// Exported utility functions for testing
// =============================================================================
@@ -44,23 +50,20 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
}
}
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
export { getTableCreationSQL } from "./schema-sql.js";
/** Run drizzle-kit migrations on the database */
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
try {
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
/** Run table creation migrations on a client */
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const tableCreations = getTableCreationSQL();
/** Run ALTER TABLE migrations for backward compatibility with older databases */
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const errors: string[] = [];
for (const sql of tableCreations) {
try {
await client.execute(sql);
} catch (e: any) {
errors.push(e.message);
}
}
// Run ALTER TABLE migrations for backward compatibility with older databases
// These add new columns to existing tables (silently fail if column already exists)
const alterMigrations = [
// Added in v1.x - repeat reminders and nagging settings
@@ -70,6 +73,12 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
// Added in v1.2.3 - dismiss missed doses without deducting stock
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
// Added in v1.3.x - stock calculation mode (automatic/manual)
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
// Added for stock correction - hidden offset that doesn't affect looseTablets
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
// Added for stock correction - timestamp to ignore consumed doses before correction
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
];
for (const sql of alterMigrations) {
@@ -83,6 +92,30 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo
}
}
// Create tables that might be missing (silently fail if already exists)
const createTableMigrations = [
// Added in v1.3.x - refill history tracking
`CREATE TABLE IF NOT EXISTS refill_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
packs_added INTEGER NOT NULL DEFAULT 0,
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
];
for (const sql of createTableMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
// Silently ignore "table already exists" errors
if (!e.message?.includes("already exists")) {
errors.push(e.message);
}
}
}
return { success: errors.length === 0, errors };
}
@@ -149,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`);
+29 -25
View File
@@ -1,39 +1,45 @@
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { getTableCreationSQL } from "./schema-sql.js";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// =============================================================================
// Exported utility functions for testing
// =============================================================================
/** Get the full migration SQL string (re-exported from schema-sql) */
export { getTableCreationSQL };
/** Split SQL string into individual statements */
/** Split SQL string into individual statements (for backwards compatibility with tests) */
export function splitSQLStatements(sql: string): string[] {
return sql.split(';').filter(s => s.trim().length > 0);
}
/** Execute migration statements on a client */
/** Execute drizzle migrations on a database */
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
const statements = getTableCreationSQL();
const errors: string[] = [];
let executed = 0;
const db = drizzle(client);
for (const stmt of statements) {
try {
await client.execute(stmt);
executed++;
} catch (err: any) {
errors.push(err.message);
}
try {
await migrate(db, { migrationsFolder });
// Count tables as a proxy for "executed" statements
const tables = await client.execute(
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
);
const executed = Number(tables.rows[0].count) || 0;
return { success: true, executed, errors };
} catch (err: any) {
errors.push(err.message);
return { success: false, executed: 0, errors };
}
return { success: errors.length === 0, executed, errors };
}
/** Get a preview of statement (first N characters) */
@@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db";
async function main() {
console.log("Starting database setup...");
console.log("Database URL:", url);
console.log("Migrations folder:", migrationsFolder);
const client = createClient({ url });
const db = drizzle(client);
const statements = getTableCreationSQL();
for (const stmt of statements) {
console.log("Executing:", getStatementPreview(stmt));
await client.execute(stmt);
}
console.log("Running drizzle migrations...");
await migrate(db, { migrationsFolder });
console.log("Database setup complete!");
process.exit(0);
+10
View File
@@ -100,5 +100,15 @@ export function getTableCreationSQL(): string[] {
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
)`,
];
}
+16 -1
View File
@@ -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("[]"),
@@ -68,6 +70,7 @@ export const userSettings = sqliteTable("user_settings", {
lowStockDays: integer("low_stock_days").notNull().default(30),
normalStockDays: integer("normal_stock_days").notNull().default(90),
highStockDays: integer("high_stock_days").notNull().default(180),
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
// UI preferences
language: text("language", { length: 10 }).notNull().default("en"),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
@@ -117,3 +120,15 @@ export const doseTracking = sqliteTable("dose_tracking", {
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'))`),
});
+3
View File
@@ -20,6 +20,7 @@ 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";
@@ -115,6 +116,7 @@ export async function createApp(options?: {
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
return app;
}
@@ -184,6 +186,7 @@ await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
const start = async () => {
try {
+97 -17
View File
@@ -33,6 +33,7 @@ const inventorySchema = z.object({
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
});
const medicationExportSchema = z.object({
@@ -47,6 +48,7 @@ const medicationExportSchema = z.object({
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
image: z.string().nullable().optional(), // base64 data URL or null
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
});
const doseHistorySchema = z.object({
@@ -55,6 +57,8 @@ const doseHistorySchema = z.object({
scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(),
dismissed: z.boolean().default(false),
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
});
const shareLinkSchema = z.object({
@@ -204,8 +208,8 @@ function base64ToImage(base64: string, medicationId: number): string | null {
}
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}"
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number } | null {
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number; person: string | null } | null {
const parts = doseId.split("-");
if (parts.length < 3) return null;
@@ -215,12 +219,16 @@ function parseDoseId(doseId: string): { medicationId: number; blisterIndex: numb
if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null;
return { medicationId, blisterIndex, timestampMs };
// Check if there's a person suffix (4th part onwards, could be multi-part name)
const person = parts.length > 3 ? parts.slice(3).join("-") : null;
return { medicationId, blisterIndex, timestampMs, person };
}
// Build dose ID from parts
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number): string {
return `${medicationId}-${blisterIndex}-${timestampMs}`;
// Build dose ID from parts (with optional person suffix)
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number, person?: string | null): string {
const base = `${medicationId}-${blisterIndex}-${timestampMs}`;
return person ? `${base}-${person}` : base;
}
// =============================================================================
@@ -233,11 +241,12 @@ export async function exportRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /export - Export all user data
// ---------------------------------------------------------------------------
app.get<{ Querystring: { includeSensitive?: string } }>(
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
"/export",
async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
@@ -248,6 +257,21 @@ export async function exportRoutes(app: FastifyInstance) {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !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,
@@ -258,13 +282,15 @@ export async function exportRoutes(app: FastifyInstance) {
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
},
pillWeightMg: med.pillWeightMg,
schedules: parseBlistersForExport(med),
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
image: imageToBase64(med.imageUrl),
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
@@ -278,12 +304,38 @@ export async function exportRoutes(app: FastifyInstance) {
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: new Date(parsed.timestampMs).toISOString(),
takenAt: dose.takenAt?.toISOString() ?? new Date().toISOString(),
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
};
}).filter((d): d is NonNullable<typeof d> => d !== null);
@@ -316,12 +368,29 @@ export async function exportRoutes(app: FastifyInstance) {
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => ({
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt?.toISOString() ?? null,
regenerateToken: true, // Always regenerate tokens on import for security
}));
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 = {
@@ -348,6 +417,13 @@ export async function exportRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
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);
@@ -404,6 +480,8 @@ export async function exportRoutes(app: FastifyInstance) {
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
usageJson,
everyJson,
@@ -435,13 +513,15 @@ export async function exportRoutes(app: FastifyInstance) {
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs);
// Rebuild dose ID with optional person suffix
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
dismissed: dose.dismissed ?? false,
});
}
+10
View File
@@ -1,8 +1,18 @@
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",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
}));
+43 -1
View File
@@ -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;
+124
View File
@@ -0,0 +1,124 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, refillHistory } from "../db/schema.js";
import { eq, and, desc } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
const refillSchema = z.object({
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
}).refine(data => data.packsAdded > 0 || data.loosePillsAdded > 0, {
message: "Must add at least one pack or some loose pills",
});
export async function refillRoutes(app: FastifyInstance) {
// All refill routes require auth
app.addHook("preHandler", requireAuth);
// Helper to get user ID from request
async function getUserId(request: any, reply: any): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// POST /medications/:id/refill - Add stock to medication
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db.select().from(medications).where(
and(eq(medications.id, medId), eq(medications.userId, userId))
);
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded } = parsed.data;
// Update medication stock
const newPackCount = med.packCount + packsAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded;
await db.update(medications)
.set({
packCount: newPackCount,
looseTablets: newLooseTablets,
updatedAt: new Date(),
})
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
// Create refill history entry
const [refill] = await db.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded,
loosePillsAdded,
})
.returning();
// Calculate pills added for response
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = (packsAdded * pillsPerPack) + loosePillsAdded;
return {
success: true,
refill: {
id: refill.id,
packsAdded,
loosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newPackCount * pillsPerPack + newLooseTablets,
},
};
});
// GET /medications/:id/refills - Get refill history for a medication
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db.select().from(medications).where(
and(eq(medications.id, medId), eq(medications.userId, userId))
);
if (!med) return reply.notFound("Medication not found");
// Get refill history, newest first
const refills = await db.select()
.from(refillHistory)
.where(eq(refillHistory.medicationId, medId))
.orderBy(desc(refillHistory.refillDate));
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
return refills.map(r => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: (r.packsAdded * pillsPerPack) + r.loosePillsAdded,
refillDate: r.refillDate,
}));
});
}
+1 -1
View File
@@ -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 -1
View File
@@ -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
+201 -461
View File
@@ -1,45 +1,78 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { mkdirSync, rmSync, existsSync } from "fs";
import { resolve } from "path";
import { resolve, dirname } from "path";
import { tmpdir } from "os";
import { fileURLToPath } from "url";
// Import the exported utility functions from client.ts
import {
buildDbUrl,
getDbPaths,
ensureDataDirectory,
getTableCreationSQL,
runTableMigrations,
runDrizzleMigrations,
runAlterMigrations,
ensureDefaultUser,
} from "../db/client.js";
// Import the exported utility functions from migrate.ts
import {
getTableCreationSQL as getTableCreationSQLFromMigrate,
splitSQLStatements,
executeMigration,
getStatementPreview,
} from "../db/migrate.js";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
describe("Migration Script Utilities", () => {
describe("getTableCreationSQL", () => {
it("should return a non-empty array of SQL statements", () => {
const statements = getTableCreationSQL();
expect(Array.isArray(statements)).toBe(true);
expect(statements.length).toBeGreaterThan(0);
describe("executeMigration", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
client = createClient({ url: ":memory:" });
});
it("should contain all table definitions", () => {
const statements = getTableCreationSQL();
const allSQL = statements.join(" ");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking");
it("should execute all migrations successfully", async () => {
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBeGreaterThan(0);
expect(result.errors).toHaveLength(0);
});
it("should create all tables", async () => {
await executeMigration(client);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
expect(tableNames).toContain("refill_history");
});
it("should be idempotent", async () => {
await executeMigration(client);
const result = await executeMigration(client);
expect(result.success).toBe(true);
});
it("should allow inserting data after migration", async () => {
await executeMigration(client);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT * FROM users");
expect(result.rows).toHaveLength(1);
});
});
@@ -62,11 +95,6 @@ describe("Migration Script Utilities", () => {
expect(statements).toHaveLength(2);
});
it("should handle getTableCreationSQL output correctly", () => {
const statements = getTableCreationSQL();
expect(statements).toHaveLength(6);
});
it("should preserve whitespace within statements", () => {
const sql = "CREATE TABLE test (\n id INTEGER\n);";
const statements = splitSQLStatements(sql);
@@ -103,52 +131,6 @@ describe("Migration Script Utilities", () => {
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
});
});
describe("executeMigration", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
client = createClient({ url: ":memory:" });
});
it("should execute all migrations successfully", async () => {
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBe(6);
expect(result.errors).toHaveLength(0);
});
it("should create all tables", async () => {
await executeMigration(client);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
});
it("should be idempotent", async () => {
await executeMigration(client);
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBe(6);
});
it("should allow inserting data after migration", async () => {
await executeMigration(client);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT * FROM users");
expect(result.rows).toHaveLength(1);
});
});
});
describe("Database Client Utilities", () => {
@@ -218,63 +200,7 @@ describe("Database Client Utilities", () => {
});
});
describe("getTableCreationSQL", () => {
it("should return array of SQL statements", () => {
const statements = getTableCreationSQL();
expect(Array.isArray(statements)).toBe(true);
expect(statements.length).toBe(6);
});
it("should include users table", () => {
const statements = getTableCreationSQL();
const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users"));
expect(usersSQL).toBeDefined();
expect(usersSQL).toContain("username text NOT NULL UNIQUE");
expect(usersSQL).toContain("password_hash text");
expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'");
});
it("should include medications table", () => {
const statements = getTableCreationSQL();
const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications"));
expect(medsSQL).toBeDefined();
expect(medsSQL).toContain("user_id integer NOT NULL");
expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'");
expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE");
});
it("should include user_settings table", () => {
const statements = getTableCreationSQL();
const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings"));
expect(settingsSQL).toBeDefined();
expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0");
expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'");
});
it("should include refresh_tokens table", () => {
const statements = getTableCreationSQL();
const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens"));
expect(tokensSQL).toBeDefined();
expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE");
});
it("should include share_tokens table", () => {
const statements = getTableCreationSQL();
const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens"));
expect(shareSQL).toBeDefined();
expect(shareSQL).toContain("taken_by text NOT NULL");
});
it("should include dose_tracking table", () => {
const statements = getTableCreationSQL();
const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking"));
expect(doseSQL).toBeDefined();
expect(doseSQL).toContain("dose_id text NOT NULL");
expect(doseSQL).toContain("marked_by text");
});
});
describe("runTableMigrations", () => {
describe("runDrizzleMigrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
@@ -282,23 +208,24 @@ describe("Database Client Utilities", () => {
});
it("should create all tables successfully", async () => {
const result = await runTableMigrations(client);
const db = drizzle(client);
const result = await runDrizzleMigrations(db);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should be idempotent (run twice without errors)", async () => {
await runTableMigrations(client);
const result = await runTableMigrations(client);
const db = drizzle(client);
await runDrizzleMigrations(db);
const result = await runDrizzleMigrations(db);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should create all 6 tables", async () => {
await runTableMigrations(client);
it("should create all 7 tables", async () => {
const db = drizzle(client);
await runDrizzleMigrations(db);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
@@ -308,6 +235,29 @@ describe("Database Client Utilities", () => {
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
expect(tableNames).toContain("refill_history");
});
});
describe("runAlterMigrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should run without errors on a fresh database", async () => {
const result = await runAlterMigrations(client);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should be idempotent", async () => {
await runAlterMigrations(client);
const result = await runAlterMigrations(client);
expect(result.success).toBe(true);
});
});
@@ -316,7 +266,8 @@ describe("Database Client Utilities", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await runTableMigrations(client);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should create default user when auth is disabled", async () => {
@@ -386,246 +337,83 @@ describe("Database Client", () => {
});
});
describe("Table Schema Creation", () => {
describe("Table Schema via Drizzle Migrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should create users table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)
`);
// Verify table exists
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create medications table with foreign key", async () => {
// First create users table
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='medications'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create user_settings table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create refresh_tokens table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
rotated_at integer,
revoked integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create share_tokens table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='share_tokens'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create dose_tracking table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='dose_tracking'"
);
expect(tables.rows).toHaveLength(1);
});
it("should enforce unique constraint on username", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
it("should have users table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(users)");
const columnNames = columns.rows.map(r => r.name);
await expect(
client.execute("INSERT INTO users (username) VALUES ('testuser')")
).rejects.toThrow();
expect(columnNames).toContain("id");
expect(columnNames).toContain("username");
expect(columnNames).toContain("password_hash");
expect(columnNames).toContain("auth_provider");
});
it("should enforce unique constraint on refresh token_id", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
);
it("should have medications table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(medications)");
const columnNames = columns.rows.map(r => r.name);
await expect(
client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
)
).rejects.toThrow();
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("name");
expect(columnNames).toContain("taken_by_json");
expect(columnNames).toContain("pack_count");
expect(columnNames).toContain("usage_json");
});
it("should have user_settings table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(user_settings)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("email_enabled");
expect(columnNames).toContain("language");
expect(columnNames).toContain("stock_calculation_mode");
});
it("should have refresh_tokens table", async () => {
const columns = await client.execute("PRAGMA table_info(refresh_tokens)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("token_id");
});
it("should have share_tokens table", async () => {
const columns = await client.execute("PRAGMA table_info(share_tokens)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("token");
expect(columnNames).toContain("taken_by");
});
it("should have dose_tracking table", async () => {
const columns = await client.execute("PRAGMA table_info(dose_tracking)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("dose_id");
expect(columnNames).toContain("marked_by");
});
it("should have refill_history table", async () => {
const columns = await client.execute("PRAGMA table_info(refill_history)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("medication_id");
expect(columnNames).toContain("packs_added");
expect(columnNames).toContain("loose_pills_added");
});
});
@@ -634,15 +422,8 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local',
is_active integer NOT NULL DEFAULT 1,
created_at integer NOT NULL DEFAULT (strftime('%s','now'))
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should use default values for auth_provider", async () => {
@@ -656,16 +437,8 @@ describe("Database Client", () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
expect(result.rows[0].is_active).toBe(1);
});
it("should generate created_at timestamp", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'");
expect(typeof result.rows[0].created_at).toBe("number");
// Should be a reasonable Unix timestamp (after year 2020)
expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800);
// SQLite stores booleans as integers
expect(result.rows[0].is_active).toBeTruthy();
});
});
@@ -674,40 +447,18 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
});
it("should use default notification settings", async () => {
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
expect(result.rows[0].email_enabled).toBe(0);
expect(result.rows[0].shoutrrr_enabled).toBe(0);
// SQLite stores booleans as integers (false = 0)
expect(result.rows[0].email_enabled).toBeFalsy();
expect(result.rows[0].shoutrrr_enabled).toBeFalsy();
});
it("should use default stock threshold settings", async () => {
@@ -717,7 +468,6 @@ describe("Database Client", () => {
expect(result.rows[0].low_stock_days).toBe(30);
expect(result.rows[0].normal_stock_days).toBe(90);
expect(result.rows[0].high_stock_days).toBe(180);
expect(result.rows[0].expiry_warning_days).toBe(90);
});
it("should use default language (en)", async () => {
@@ -747,32 +497,9 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
intake_reminders_enabled integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
});
it("should use default inventory values", async () => {
@@ -795,11 +522,11 @@ describe("Database Client", () => {
expect(result.rows[0].start_json).toBe("[]");
});
it("should default intake_reminders_enabled to false (0)", async () => {
it("should default intake_reminders_enabled to false", async () => {
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'");
expect(result.rows[0].intake_reminders_enabled).toBe(0);
expect(result.rows[0].intake_reminders_enabled).toBeFalsy();
});
});
@@ -810,21 +537,8 @@ describe("Database Client", () => {
client = createClient({ url: ":memory:" });
// Enable foreign keys
await client.execute("PRAGMA foreign_keys = ON");
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should cascade delete medications when user is deleted", async () => {
@@ -845,18 +559,44 @@ describe("Database Client", () => {
});
});
describe("Unique Constraints", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should enforce unique constraint on username", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await expect(
client.execute("INSERT INTO users (username) VALUES ('testuser')")
).rejects.toThrow();
});
it("should enforce unique constraint on refresh token_id", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
);
await expect(
client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
)
).rejects.toThrow();
});
});
describe("Default User Creation (Auth Disabled)", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should be able to create a default user with ID 1", async () => {
+355
View File
@@ -54,6 +54,8 @@ const { shareRoutes } = await import("../routes/share.js");
const { medicationRoutes } = await import("../routes/medications.js");
const { settingsRoutes } = await import("../routes/settings.js");
const { healthRoutes } = await import("../routes/health.js");
const { refillRoutes } = await import("../routes/refills.js");
const { exportRoutes } = await import("../routes/export.js");
// =============================================================================
// Test Setup
@@ -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 '[]',
@@ -142,6 +146,16 @@ async function createSchema(client: Client) {
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
)`,
];
for (const sql of tableCreations) {
@@ -150,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");
@@ -230,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();
});
@@ -1568,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);
});
});
});
+2
View File
@@ -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 '[]',
+394
View File
@@ -0,0 +1,394 @@
/**
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
* Tests adding refills to medication stock and retrieving refill history.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
// Store userId at module level so routes can access it
let currentUserId = 1;
// =============================================================================
// Route Registration
// =============================================================================
async function registerRefillRoutes(ctx: TestContext) {
const { app, client } = ctx;
// POST /medications/:id/refill - Add stock and record history
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
"/medications/:id/refill",
async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
// Validate input
if (packsAdded < 0 || loosePillsAdded < 0) {
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
}
if (packsAdded === 0 && loosePillsAdded === 0) {
return reply.status(400).send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
}
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
const med = medResult.rows[0];
const newPackCount = (med.pack_count as number) + packsAdded;
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
// Update medication stock
await client.execute({
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
args: [newPackCount, newLooseTablets, medId],
});
// Record refill history
await client.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
VALUES (?, ?, ?, ?)`,
args: [medId, userId, packsAdded, loosePillsAdded],
});
return {
success: true,
pillsAdded: totalPillsAdded,
newPackCount,
newLooseTablets,
};
}
);
// GET /medications/:id/refills - Get refill history
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
// Get refill history, newest first
const refillResult = await client.execute({
sql: `SELECT id, packs_added, loose_pills_added, refill_date
FROM refill_history
WHERE medication_id = ? AND user_id = ?
ORDER BY refill_date DESC`,
args: [medId, userId],
});
return {
refills: refillResult.rows.map((r) => ({
id: r.id,
packsAdded: r.packs_added,
loosePillsAdded: r.loose_pills_added,
refillDate: r.refill_date,
})),
};
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Refill API", () => {
let ctx: TestContext;
let userId: number;
let medId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerRefillRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user
userId = await createTestUser(ctx.client, { username: "testuser" });
// Update the module-level userId so routes use the correct one
currentUserId = userId;
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
medId = await createTestMedication(ctx.client, {
userId,
name: "Test Med",
packCount: 1,
blistersPerPack: 10,
pillsPerBlister: 10,
looseTablets: 5,
});
});
// ---------------------------------------------------------------------------
// POST /medications/:id/refill
// ---------------------------------------------------------------------------
describe("POST /medications/:id/refill", () => {
it("should add packs to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
expect(data.newPackCount).toBe(3); // 1 + 2
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT pack_count FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].pack_count).toBe(3);
});
it("should add loose pills to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { loosePillsAdded: 15 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(15);
expect(data.newLooseTablets).toBe(20); // 5 + 15
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].loose_tablets).toBe(20);
});
it("should add both packs and loose pills", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 10 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
expect(data.newPackCount).toBe(2);
expect(data.newLooseTablets).toBe(15);
});
it("should record refill in history", async () => {
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2, loosePillsAdded: 5 },
});
// Check history
const result = await ctx.client.execute({
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].packs_added).toBe(2);
expect(result.rows[0].loose_pills_added).toBe(5);
});
it("should reject refill with zero amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 0 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("At least one");
});
it("should reject refill with negative amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: -1 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("non-negative");
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/99999/refill`,
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// GET /medications/:id/refills
// ---------------------------------------------------------------------------
describe("GET /medications/:id/refills", () => {
it("should return empty array when no refills", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ refills: [] });
});
it("should return refill history newest first", async () => {
// Add two refills with different values so we can identify them
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
await new Promise((r) => setTimeout(r, 1100));
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 20 },
});
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.refills).toHaveLength(2);
// Newest first (loose pills - added second)
expect(data.refills[0].packsAdded).toBe(0);
expect(data.refills[0].loosePillsAdded).toBe(20);
// Older (packs - added first)
expect(data.refills[1].packsAdded).toBe(1);
expect(data.refills[1].loosePillsAdded).toBe(0);
// Each entry should have an id and refillDate
for (const refill of data.refills) {
expect(refill.id).toBeTypeOf("number");
expect(refill.refillDate).toBeTruthy();
}
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/99999/refills`,
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// Cascade Delete Tests
// ---------------------------------------------------------------------------
describe("Cascade Delete", () => {
it("should delete refill history when medication is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(1);
// Delete medication
await ctx.client.execute({
sql: `DELETE FROM medications WHERE id = ?`,
args: [medId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(0);
});
it("should delete refill history when user is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(1);
// Delete user
await ctx.client.execute({
sql: `DELETE FROM users WHERE id = ?`,
args: [userId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(0);
});
});
});
+12 -7
View File
@@ -9,8 +9,15 @@ import sensible from "@fastify/sensible";
import fastifyMultipart from "@fastify/multipart";
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { beforeAll, afterAll, beforeEach } from "vitest";
import { getTableCreationSQL } from "../db/schema-sql.js";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// Type for our test database
export type TestDb = ReturnType<typeof drizzle>;
@@ -61,14 +68,11 @@ export async function buildTestApp(): Promise<TestContext> {
}
/**
* Create test database schema
* Create test database schema using drizzle-kit migrations
*/
async function runTestMigrations(client: Client): Promise<void> {
const tableCreations = getTableCreationSQL();
for (const sql of tableCreations) {
await client.execute(sql);
}
const db = drizzle(client);
await migrate(db, { migrationsFolder });
}
// =============================================================================
@@ -282,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");
+1 -1
View File
@@ -6,7 +6,7 @@ services:
volumes:
- ./backend:/app
- backend_node_modules:/app/node_modules
- ./backend/data:/app/data
- ./data:/app/data
env_file:
- .env
ports:
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+2 -2
View File
@@ -12,8 +12,8 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Allow larger file uploads (for medication images)
client_max_body_size 10M;
# Allow larger file uploads (for medication images and data import/export)
client_max_body_size 50M;
location / {
try_files $uri /index.html;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.1.0",
"version": "1.4.1",
"type": "module",
"scripts": {
"dev": "vite",
+1056 -144
View File
File diff suppressed because it is too large Load Diff
+62 -4
View File
@@ -98,8 +98,8 @@
}
},
"form": {
"editEntry": "Eintrag bearbeiten",
"newEntry": "Neuer Eintrag",
"editEntry": "Medikament bearbeiten",
"newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten",
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
@@ -226,7 +226,7 @@
"pillWeight": "Tablettengewicht",
"expiryDate": "Ablaufdatum",
"intakeSchedule": "Einnahmeplan",
"coverageStatus": "Reichweite",
"coverageStatus": "Bestand",
"daysLeft": "Tage übrig",
"runsOut": "Aufgebraucht",
"notes": "Notizen",
@@ -330,7 +330,8 @@
"fullBlister": "voller Blister",
"fullBlisters": "volle Blister",
"inBlister": "in 1 Blister",
"total": "gesamt"
"total": "gesamt",
"max": "max"
},
"share": {
"button": "Teilen",
@@ -372,6 +373,13 @@
"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!",
@@ -383,5 +391,55 @@
"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"
}
}
+61 -3
View File
@@ -100,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",
@@ -332,7 +332,8 @@
"fullBlister": "full blister",
"fullBlisters": "full blisters",
"inBlister": "in 1 blister",
"total": "total"
"total": "total",
"max": "max"
},
"share": {
"button": "Share",
@@ -374,6 +375,13 @@
"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!",
@@ -385,5 +393,55 @@
"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"
}
}
+723 -76
View File
@@ -431,6 +431,46 @@ button.secondary:hover {
background: var(--bg-secondary);
}
/* Success button (Refill, etc.) */
button.success {
background: var(--success);
color: white;
border: none;
}
button.success:hover {
filter: brightness(1.1);
}
button.success:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Primary/Accent button (New entry, Add intake, etc.) */
button.primary {
background: var(--accent);
color: white;
border: none;
}
button.primary:hover {
background: var(--accent-light);
}
button.primary:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Info button (Edit, secondary actions) */
button.info {
background: #3b82f6;
color: white;
border: none;
}
button.info:hover {
background: #60a5fa;
}
/* Ghost button (Cancel, etc.) */
button.ghost {
background: transparent;
@@ -637,6 +677,7 @@ textarea.auto-resize {
.past-days-toggle {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
@@ -662,6 +703,7 @@ textarea.auto-resize {
.past-days-icon {
font-size: 0.7rem;
opacity: 0.6;
flex-shrink: 0;
}
.past-days-label {
font-weight: 500;
@@ -669,6 +711,8 @@ textarea.auto-resize {
.past-days-count {
opacity: 0.6;
font-size: 0.85rem;
white-space: nowrap;
flex-shrink: 0;
}
.past-days-warning {
margin-left: auto;
@@ -1388,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;
@@ -1663,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;
@@ -1976,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;
}
@@ -1989,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) */
@@ -2090,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 {
@@ -2284,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;
@@ -2885,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); }
@@ -2932,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 {
@@ -3283,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);
@@ -3589,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
============================================================================= */
@@ -3717,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)
============================================================================= */
@@ -4007,78 +4673,59 @@ h3 .reminder-icon.info-tooltip {
width: 100%;
}
/* Export/Import Section */
.card:has(.export-import-grid) {
overflow: visible;
}
.card:has(.export-import-grid) .card-head {
overflow: visible;
}
.export-import-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (max-width: 640px) {
.export-import-grid {
grid-template-columns: 1fr;
}
}
.export-import-card {
/* 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: var(--card-radius);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.export-import-card h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.export-import-desc {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
line-height: 1.5;
flex: 1;
}
.export-import-card button,
.export-import-file-btn {
margin-top: auto;
align-self: flex-start;
}
.export-import-file-btn {
cursor: pointer;
display: inline-block;
padding: 0.7rem 1.25rem;
border-radius: var(--btn-radius);
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
font-weight: 600;
font-size: 0.9rem;
border-radius: 12px;
transition: all 0.2s ease;
}
.export-import-file-btn:hover {
background: var(--bg-hover);
.action-card:hover {
border-color: var(--border-primary);
}
.export-import-file-btn input {
display: none;
.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;
}
}
+7
View File
@@ -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,
+99 -11
View File
@@ -7,6 +7,9 @@
# ./scripts/release.sh minor # 1.0.0 -> 1.1.0 (new features)
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
# ./scripts/release.sh 1.2.3 # explicit version
#
# This script creates a PR for the version bump (required due to branch protection),
# waits for CI, merges it, and then creates a signed tag for the release.
# =============================================================================
set -e
@@ -18,11 +21,28 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# GitHub repo
GITHUB_REPO="DanielVolz/medassist-ng"
# Get script directory and project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Check for gh CLI
if ! command -v gh &> /dev/null; then
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
echo "Install it with: brew install gh"
exit 1
fi
# Check gh authentication
if ! gh auth status &> /dev/null; then
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
echo "Run: gh auth login"
exit 1
fi
# Check for uncommitted changes
if [[ -n $(git status --porcelain) ]]; then
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
@@ -30,6 +50,11 @@ if [[ -n $(git status --porcelain) ]]; then
exit 1
fi
# Make sure we're on main and up to date
echo -e "${BLUE}Updating main branch...${NC}"
git checkout main
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
# Get current version from backend/package.json
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
@@ -74,6 +99,19 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
# Branch name for the release
RELEASE_BRANCH="chore/release-${NEW_VERSION}"
# Check if branch already exists
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
git branch -D "${RELEASE_BRANCH}"
fi
# Create release branch
echo -e "${BLUE}Creating release branch...${NC}"
git checkout -b "${RELEASE_BRANCH}"
# Update version in package.json files
echo -e "${BLUE}Updating package.json files...${NC}"
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
@@ -84,23 +122,73 @@ echo -e "${BLUE}Committing version bump...${NC}"
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
git commit -m "chore: release v${NEW_VERSION}"
# Check if tag exists
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists. Overwriting...${NC}"
git tag -d "v${NEW_VERSION}"
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
# Push branch to GitHub
echo -e "${BLUE}Pushing release branch to GitHub...${NC}"
git push -u origin "${RELEASE_BRANCH}" 2>/dev/null || git push -u github "${RELEASE_BRANCH}"
# Create PR
echo -e "${BLUE}Creating Pull Request...${NC}"
PR_URL=$(gh pr create \
--repo "${GITHUB_REPO}" \
--head "${RELEASE_BRANCH}" \
--title "chore: release v${NEW_VERSION}" \
--body "## Release v${NEW_VERSION}
Automated version bump for release v${NEW_VERSION}.
This PR was created by the release script." \
2>&1)
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
# Extract PR number
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
# Wait for CI checks
echo -e "${BLUE}Waiting for CI checks to complete...${NC}"
if ! gh pr checks "${PR_NUMBER}" --repo "${GITHUB_REPO}" --watch; then
echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}"
exit 1
fi
# Create and push tag
echo -e "${GREEN}CI checks passed!${NC}"
# Merge PR
echo -e "${BLUE}Merging PR...${NC}"
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
# Switch back to main and pull
echo -e "${BLUE}Updating main branch with merged changes...${NC}"
git checkout main
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
# Check if tag exists and delete it
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
git tag -d "v${NEW_VERSION}"
fi
# Check if remote tag exists
if git ls-remote --tags origin "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}" || \
git ls-remote --tags github "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}"
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
git push github ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
fi
# Create signed tag
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
# Push
echo -e "${BLUE}Pushing to origin (GitHub)...${NC}"
git push origin main
git push origin "v${NEW_VERSION}"
# Push tag
echo -e "${BLUE}Pushing tag to GitHub...${NC}"
git push origin "v${NEW_VERSION}" 2>/dev/null || git push github "v${NEW_VERSION}"
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
echo -e "Track progress: ${YELLOW}https://github.com/DanielVolz/medassist-ng/actions${NC}"
echo -e "Track progress: ${YELLOW}https://github.com/${GITHUB_REPO}/actions${NC}"
echo -e "Release page: ${YELLOW}https://github.com/${GITHUB_REPO}/releases/tag/v${NEW_VERSION}${NC}"