Compare commits

...

8 Commits

Author SHA1 Message Date
Daniel Volz 288e075786 fix: browser back gesture closes modal instead of navigating (#36)
* fix: browser back gesture closes modal instead of navigating

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

* feat: add back-swipe support for all modals

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

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

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

* fix: improve schedule row layout for mobile screens

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

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

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

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

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

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

* fix: Improve mobile tooltips and refill modal layout

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

Allows users to acknowledge missed doses without deducting stock.
Closes #28
2026-01-16 21:56:35 +01:00
35 changed files with 4694 additions and 738 deletions
+70 -38
View File
@@ -195,31 +195,51 @@ gh pr merge --squash --delete-branch
> ⚠️ **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 +475,50 @@ 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.
### 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 +534,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)
+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/))
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL || "./data/medassist.db",
},
});
+112
View File
@@ -0,0 +1,112 @@
CREATE TABLE `dose_tracking` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_id` text(255) NOT NULL,
`taken_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`marked_by` text(100),
`dismissed` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `medications` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`generic_name` text(100),
`taken_by_json` text DEFAULT '[]' NOT NULL,
`pack_count` integer DEFAULT 1 NOT NULL,
`blisters_per_pack` integer DEFAULT 1 NOT NULL,
`pills_per_blister` integer DEFAULT 1 NOT NULL,
`loose_tablets` integer DEFAULT 0 NOT NULL,
`pill_weight_mg` integer,
`usage_json` text DEFAULT '[]' NOT NULL,
`every_json` text DEFAULT '[]' NOT NULL,
`start_json` text DEFAULT '[]' NOT NULL,
`image_url` text,
`expiry_date` text,
`notes` text,
`intake_reminders_enabled` integer DEFAULT false NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `refill_history` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`medication_id` integer NOT NULL,
`user_id` integer NOT NULL,
`packs_added` integer DEFAULT 0 NOT NULL,
`loose_pills_added` integer DEFAULT 0 NOT NULL,
`refill_date` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `refresh_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token_id` text(255) NOT NULL,
`expires_at` integer NOT NULL,
`rotated_at` integer,
`revoked` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `refresh_tokens_token_id_unique` ON `refresh_tokens` (`token_id`);--> statement-breakpoint
CREATE TABLE `share_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token` text(64) NOT NULL,
`taken_by` text(100) NOT NULL,
`schedule_days` integer DEFAULT 30 NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`expires_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `share_tokens_token_unique` ON `share_tokens` (`token`);--> statement-breakpoint
CREATE TABLE `user_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`email_enabled` integer DEFAULT false NOT NULL,
`notification_email` text,
`email_stock_reminders` integer DEFAULT true NOT NULL,
`email_intake_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_enabled` integer DEFAULT false NOT NULL,
`shoutrrr_url` text,
`shoutrrr_stock_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_intake_reminders` integer DEFAULT true NOT NULL,
`reminder_days_before` integer DEFAULT 7 NOT NULL,
`repeat_daily_reminders` integer DEFAULT false NOT NULL,
`skip_reminders_for_taken_doses` integer DEFAULT false NOT NULL,
`repeat_reminders_enabled` integer DEFAULT false NOT NULL,
`reminder_repeat_interval_minutes` integer DEFAULT 30 NOT NULL,
`max_nagging_reminders` integer DEFAULT 5 NOT NULL,
`low_stock_days` integer DEFAULT 30 NOT NULL,
`normal_stock_days` integer DEFAULT 90 NOT NULL,
`high_stock_days` integer DEFAULT 180 NOT NULL,
`expiry_warning_days` integer DEFAULT 90 NOT NULL,
`language` text(10) DEFAULT 'en' NOT NULL,
`stock_calculation_mode` text(20) DEFAULT 'automatic' NOT NULL,
`last_auto_email_sent` text,
`last_notification_type` text,
`last_notification_channel` text,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_settings_user_id_unique` ON `user_settings` (`user_id`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text(100) NOT NULL,
`password_hash` text(255),
`avatar_url` text(255),
`auth_provider` text(50) DEFAULT 'local' NOT NULL,
`oidc_subject` text(255),
`is_active` integer DEFAULT true NOT NULL,
`last_login_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+819
View File
@@ -0,0 +1,819 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1768600500759,
"tag": "0000_init",
"breakpoints": true
}
]
}
+1009 -19
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -24,7 +24,7 @@
"@libsql/client": "^0.10.0",
"argon2": "^0.40.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.32.2",
"drizzle-orm": "^0.45.1",
"fastify": "^5.0.0",
"nodemailer": "^7.0.11",
"openid-client": "^6.8.1",
@@ -35,6 +35,7 @@
"@types/nodemailer": "^6.4.21",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^4.0.16",
"drizzle-kit": "^0.31.8",
"supertest": "^7.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
+34 -19
View File
@@ -1,12 +1,18 @@
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
import { resolve } from "path";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import dotenv from "dotenv";
import { getTableCreationSQL } from "./schema-sql.js";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// =============================================================================
// Exported utility functions for testing
// =============================================================================
@@ -44,23 +50,20 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
}
}
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
export { getTableCreationSQL } from "./schema-sql.js";
/** Run drizzle-kit migrations on the database */
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
try {
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
/** Run table creation migrations on a client */
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const tableCreations = getTableCreationSQL();
/** Run ALTER TABLE migrations for backward compatibility with older databases */
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const errors: string[] = [];
for (const sql of tableCreations) {
try {
await client.execute(sql);
} catch (e: any) {
errors.push(e.message);
}
}
// Run ALTER TABLE migrations for backward compatibility with older databases
// These add new columns to existing tables (silently fail if column already exists)
const alterMigrations = [
// Added in v1.x - repeat reminders and nagging settings
@@ -68,6 +71,8 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
// Added in v1.2.3 - dismiss missed doses without deducting stock
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
@@ -147,9 +152,19 @@ export const db = drizzle(client);
// Auto-run migrations (self-healing database)
async function runMigrations() {
const result = await runTableMigrations(client);
if (result.errors.length > 0) {
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
// Run drizzle-kit generated migrations
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
const migrateResult = await runDrizzleMigrations(db);
if (!migrateResult.success) {
console.error(`[DB] Migration error:`, migrateResult.error);
} else {
console.log(`[DB] Drizzle migrations completed`);
}
// Run ALTER TABLE migrations for backward compatibility
const alterResult = await runAlterMigrations(client);
if (alterResult.errors.length > 0) {
alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err));
}
console.log(`[DB] Tables verified/created`);
+29 -25
View File
@@ -1,39 +1,45 @@
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { getTableCreationSQL } from "./schema-sql.js";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// =============================================================================
// Exported utility functions for testing
// =============================================================================
/** Get the full migration SQL string (re-exported from schema-sql) */
export { getTableCreationSQL };
/** Split SQL string into individual statements */
/** Split SQL string into individual statements (for backwards compatibility with tests) */
export function splitSQLStatements(sql: string): string[] {
return sql.split(';').filter(s => s.trim().length > 0);
}
/** Execute migration statements on a client */
/** Execute drizzle migrations on a database */
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
const statements = getTableCreationSQL();
const errors: string[] = [];
let executed = 0;
const db = drizzle(client);
for (const stmt of statements) {
try {
await client.execute(stmt);
executed++;
} catch (err: any) {
errors.push(err.message);
}
try {
await migrate(db, { migrationsFolder });
// Count tables as a proxy for "executed" statements
const tables = await client.execute(
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
);
const executed = Number(tables.rows[0].count) || 0;
return { success: true, executed, errors };
} catch (err: any) {
errors.push(err.message);
return { success: false, executed: 0, errors };
}
return { success: errors.length === 0, executed, errors };
}
/** Get a preview of statement (first N characters) */
@@ -54,15 +60,13 @@ const url = "file:./data/medassist-ng.db";
async function main() {
console.log("Starting database setup...");
console.log("Database URL:", url);
console.log("Migrations folder:", migrationsFolder);
const client = createClient({ url });
const db = drizzle(client);
const statements = getTableCreationSQL();
for (const stmt of statements) {
console.log("Executing:", getStatementPreview(stmt));
await client.execute(stmt);
}
console.log("Running drizzle migrations...");
await migrate(db, { migrationsFolder });
console.log("Database setup complete!");
process.exit(0);
+11
View File
@@ -97,6 +97,17 @@ export function getTableCreationSQL(): string[] {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
medication_id integer NOT NULL,
user_id integer NOT NULL,
packs_added integer NOT NULL DEFAULT 0,
loose_pills_added integer NOT NULL DEFAULT 0,
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
+14
View File
@@ -68,6 +68,7 @@ export const userSettings = sqliteTable("user_settings", {
lowStockDays: integer("low_stock_days").notNull().default(30),
normalStockDays: integer("normal_stock_days").notNull().default(90),
highStockDays: integer("high_stock_days").notNull().default(180),
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
// UI preferences
language: text("language", { length: 10 }).notNull().default("en"),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
@@ -115,4 +116,17 @@ export const doseTracking = sqliteTable("dose_tracking", {
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
});
// =============================================================================
// Refill History - Tracks when medication stock was refilled
// =============================================================================
export const refillHistory = sqliteTable("refill_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
packsAdded: integer("packs_added").notNull().default(0),
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
});
+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 {
+104 -1
View File
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, shareTokens } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: any, reply: any): Promise<number> {
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
dismissed: d.dismissed ?? false,
})),
};
}
@@ -127,6 +132,103 @@ export async function doseRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
"/doses/dismiss",
{ preHandler: requireAuth },
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = dismissDosesSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
});
}
const { doseIds } = parsed.data;
// Insert dismissed records for each dose that doesn't exist yet
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists (taken or dismissed)
const [existing] = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
if (existing) {
// Already exists - update to dismissed if not already
if (!existing.dismissed) {
await db.update(doseTracking)
.set({ dismissed: true })
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
dismissedCount++;
}
} else {
// Create new dismissed record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null,
dismissed: true,
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
}
);
// ---------------------------------------------------------------------------
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
// ---------------------------------------------------------------------------
app.delete(
"/doses/dismiss",
{ preHandler: requireAuth },
async (request, reply) => {
const userId = await getUserId(request, reply);
// Delete all dismissed-only records (not taken ones)
// For taken+dismissed, just remove the dismissed flag
const dismissed = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.dismissed, true)
)
);
for (const d of dismissed) {
if (d.markedBy !== null || d.takenAt) {
// This was also marked as taken - just remove dismissed flag
await db.update(doseTracking)
.set({ dismissed: false })
.where(eq(doseTracking.id, d.id));
} else {
// This was only dismissed - delete it
await db.delete(doseTracking)
.where(eq(doseTracking.id, d.id));
}
}
return { success: true, clearedCount: dismissed.length };
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
// ---------------------------------------------------------------------------
@@ -151,6 +253,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
dismissed: d.dismissed ?? false,
})),
};
}
+124
View File
@@ -0,0 +1,124 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, refillHistory } from "../db/schema.js";
import { eq, and, desc } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
const refillSchema = z.object({
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
}).refine(data => data.packsAdded > 0 || data.loosePillsAdded > 0, {
message: "Must add at least one pack or some loose pills",
});
export async function refillRoutes(app: FastifyInstance) {
// All refill routes require auth
app.addHook("preHandler", requireAuth);
// Helper to get user ID from request
async function getUserId(request: any, reply: any): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// POST /medications/:id/refill - Add stock to medication
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db.select().from(medications).where(
and(eq(medications.id, medId), eq(medications.userId, userId))
);
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded } = parsed.data;
// Update medication stock
const newPackCount = med.packCount + packsAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded;
await db.update(medications)
.set({
packCount: newPackCount,
looseTablets: newLooseTablets,
updatedAt: new Date(),
})
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
// Create refill history entry
const [refill] = await db.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded,
loosePillsAdded,
})
.returning();
// Calculate pills added for response
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = (packsAdded * pillsPerPack) + loosePillsAdded;
return {
success: true,
refill: {
id: refill.id,
packsAdded,
loosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newPackCount * pillsPerPack + newLooseTablets,
},
};
});
// GET /medications/:id/refills - Get refill history for a medication
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db.select().from(medications).where(
and(eq(medications.id, medId), eq(medications.userId, userId))
);
if (!med) return reply.notFound("Medication not found");
// Get refill history, newest first
const refills = await db.select()
.from(refillHistory)
.where(eq(refillHistory.medicationId, medId))
.orderBy(desc(refillHistory.refillDate));
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
return refills.map(r => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: (r.packsAdded * pillsPerPack) + r.loosePillsAdded,
refillDate: r.refillDate,
}));
});
}
+201 -461
View File
@@ -1,45 +1,78 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { mkdirSync, rmSync, existsSync } from "fs";
import { resolve } from "path";
import { resolve, dirname } from "path";
import { tmpdir } from "os";
import { fileURLToPath } from "url";
// Import the exported utility functions from client.ts
import {
buildDbUrl,
getDbPaths,
ensureDataDirectory,
getTableCreationSQL,
runTableMigrations,
runDrizzleMigrations,
runAlterMigrations,
ensureDefaultUser,
} from "../db/client.js";
// Import the exported utility functions from migrate.ts
import {
getTableCreationSQL as getTableCreationSQLFromMigrate,
splitSQLStatements,
executeMigration,
getStatementPreview,
} from "../db/migrate.js";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
describe("Migration Script Utilities", () => {
describe("getTableCreationSQL", () => {
it("should return a non-empty array of SQL statements", () => {
const statements = getTableCreationSQL();
expect(Array.isArray(statements)).toBe(true);
expect(statements.length).toBeGreaterThan(0);
describe("executeMigration", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
client = createClient({ url: ":memory:" });
});
it("should contain all table definitions", () => {
const statements = getTableCreationSQL();
const allSQL = statements.join(" ");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS users");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS medications");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS user_settings");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS refresh_tokens");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS share_tokens");
expect(allSQL).toContain("CREATE TABLE IF NOT EXISTS dose_tracking");
it("should execute all migrations successfully", async () => {
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBeGreaterThan(0);
expect(result.errors).toHaveLength(0);
});
it("should create all tables", async () => {
await executeMigration(client);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
expect(tableNames).toContain("refill_history");
});
it("should be idempotent", async () => {
await executeMigration(client);
const result = await executeMigration(client);
expect(result.success).toBe(true);
});
it("should allow inserting data after migration", async () => {
await executeMigration(client);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT * FROM users");
expect(result.rows).toHaveLength(1);
});
});
@@ -62,11 +95,6 @@ describe("Migration Script Utilities", () => {
expect(statements).toHaveLength(2);
});
it("should handle getTableCreationSQL output correctly", () => {
const statements = getTableCreationSQL();
expect(statements).toHaveLength(6);
});
it("should preserve whitespace within statements", () => {
const sql = "CREATE TABLE test (\n id INTEGER\n);";
const statements = splitSQLStatements(sql);
@@ -103,52 +131,6 @@ describe("Migration Script Utilities", () => {
expect(preview).toBe("CREATE TABLE IF NOT EXISTS use...");
});
});
describe("executeMigration", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
client = createClient({ url: ":memory:" });
});
it("should execute all migrations successfully", async () => {
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBe(6);
expect(result.errors).toHaveLength(0);
});
it("should create all tables", async () => {
await executeMigration(client);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
expect(tableNames).toContain("users");
expect(tableNames).toContain("medications");
expect(tableNames).toContain("user_settings");
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
});
it("should be idempotent", async () => {
await executeMigration(client);
const result = await executeMigration(client);
expect(result.success).toBe(true);
expect(result.executed).toBe(6);
});
it("should allow inserting data after migration", async () => {
await executeMigration(client);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT * FROM users");
expect(result.rows).toHaveLength(1);
});
});
});
describe("Database Client Utilities", () => {
@@ -218,63 +200,7 @@ describe("Database Client Utilities", () => {
});
});
describe("getTableCreationSQL", () => {
it("should return array of SQL statements", () => {
const statements = getTableCreationSQL();
expect(Array.isArray(statements)).toBe(true);
expect(statements.length).toBe(6);
});
it("should include users table", () => {
const statements = getTableCreationSQL();
const usersSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS users"));
expect(usersSQL).toBeDefined();
expect(usersSQL).toContain("username text NOT NULL UNIQUE");
expect(usersSQL).toContain("password_hash text");
expect(usersSQL).toContain("auth_provider text NOT NULL DEFAULT 'local'");
});
it("should include medications table", () => {
const statements = getTableCreationSQL();
const medsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS medications"));
expect(medsSQL).toBeDefined();
expect(medsSQL).toContain("user_id integer NOT NULL");
expect(medsSQL).toContain("taken_by_json text NOT NULL DEFAULT '[]'");
expect(medsSQL).toContain("FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE");
});
it("should include user_settings table", () => {
const statements = getTableCreationSQL();
const settingsSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS user_settings"));
expect(settingsSQL).toBeDefined();
expect(settingsSQL).toContain("email_enabled integer NOT NULL DEFAULT 0");
expect(settingsSQL).toContain("language text NOT NULL DEFAULT 'en'");
});
it("should include refresh_tokens table", () => {
const statements = getTableCreationSQL();
const tokensSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS refresh_tokens"));
expect(tokensSQL).toBeDefined();
expect(tokensSQL).toContain("token_id text NOT NULL UNIQUE");
});
it("should include share_tokens table", () => {
const statements = getTableCreationSQL();
const shareSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS share_tokens"));
expect(shareSQL).toBeDefined();
expect(shareSQL).toContain("taken_by text NOT NULL");
});
it("should include dose_tracking table", () => {
const statements = getTableCreationSQL();
const doseSQL = statements.find(s => s.includes("CREATE TABLE IF NOT EXISTS dose_tracking"));
expect(doseSQL).toBeDefined();
expect(doseSQL).toContain("dose_id text NOT NULL");
expect(doseSQL).toContain("marked_by text");
});
});
describe("runTableMigrations", () => {
describe("runDrizzleMigrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(() => {
@@ -282,23 +208,24 @@ describe("Database Client Utilities", () => {
});
it("should create all tables successfully", async () => {
const result = await runTableMigrations(client);
const db = drizzle(client);
const result = await runDrizzleMigrations(db);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should be idempotent (run twice without errors)", async () => {
await runTableMigrations(client);
const result = await runTableMigrations(client);
const db = drizzle(client);
await runDrizzleMigrations(db);
const result = await runDrizzleMigrations(db);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should create all 6 tables", async () => {
await runTableMigrations(client);
it("should create all 7 tables", async () => {
const db = drizzle(client);
await runDrizzleMigrations(db);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%' ORDER BY name"
);
const tableNames = tables.rows.map(r => r.name);
@@ -308,6 +235,29 @@ describe("Database Client Utilities", () => {
expect(tableNames).toContain("refresh_tokens");
expect(tableNames).toContain("share_tokens");
expect(tableNames).toContain("dose_tracking");
expect(tableNames).toContain("refill_history");
});
});
describe("runAlterMigrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should run without errors on a fresh database", async () => {
const result = await runAlterMigrations(client);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should be idempotent", async () => {
await runAlterMigrations(client);
const result = await runAlterMigrations(client);
expect(result.success).toBe(true);
});
});
@@ -316,7 +266,8 @@ describe("Database Client Utilities", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await runTableMigrations(client);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should create default user when auth is disabled", async () => {
@@ -386,246 +337,83 @@ describe("Database Client", () => {
});
});
describe("Table Schema Creation", () => {
describe("Table Schema via Drizzle Migrations", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should create users table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)
`);
// Verify table exists
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create medications table with foreign key", async () => {
// First create users table
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='medications'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create user_settings table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create refresh_tokens table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
rotated_at integer,
revoked integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create share_tokens table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='share_tokens'"
);
expect(tables.rows).toHaveLength(1);
});
it("should create dose_tracking table", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const tables = await client.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='dose_tracking'"
);
expect(tables.rows).toHaveLength(1);
});
it("should enforce unique constraint on username", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
it("should have users table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(users)");
const columnNames = columns.rows.map(r => r.name);
await expect(
client.execute("INSERT INTO users (username) VALUES ('testuser')")
).rejects.toThrow();
expect(columnNames).toContain("id");
expect(columnNames).toContain("username");
expect(columnNames).toContain("password_hash");
expect(columnNames).toContain("auth_provider");
});
it("should enforce unique constraint on refresh token_id", async () => {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
);
it("should have medications table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(medications)");
const columnNames = columns.rows.map(r => r.name);
await expect(
client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
)
).rejects.toThrow();
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("name");
expect(columnNames).toContain("taken_by_json");
expect(columnNames).toContain("pack_count");
expect(columnNames).toContain("usage_json");
});
it("should have user_settings table with correct columns", async () => {
const columns = await client.execute("PRAGMA table_info(user_settings)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("email_enabled");
expect(columnNames).toContain("language");
expect(columnNames).toContain("stock_calculation_mode");
});
it("should have refresh_tokens table", async () => {
const columns = await client.execute("PRAGMA table_info(refresh_tokens)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("user_id");
expect(columnNames).toContain("token_id");
});
it("should have share_tokens table", async () => {
const columns = await client.execute("PRAGMA table_info(share_tokens)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("token");
expect(columnNames).toContain("taken_by");
});
it("should have dose_tracking table", async () => {
const columns = await client.execute("PRAGMA table_info(dose_tracking)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("dose_id");
expect(columnNames).toContain("marked_by");
});
it("should have refill_history table", async () => {
const columns = await client.execute("PRAGMA table_info(refill_history)");
const columnNames = columns.rows.map(r => r.name);
expect(columnNames).toContain("id");
expect(columnNames).toContain("medication_id");
expect(columnNames).toContain("packs_added");
expect(columnNames).toContain("loose_pills_added");
});
});
@@ -634,15 +422,8 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local',
is_active integer NOT NULL DEFAULT 1,
created_at integer NOT NULL DEFAULT (strftime('%s','now'))
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should use default values for auth_provider", async () => {
@@ -656,16 +437,8 @@ describe("Database Client", () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT is_active FROM users WHERE username = 'testuser'");
expect(result.rows[0].is_active).toBe(1);
});
it("should generate created_at timestamp", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
const result = await client.execute("SELECT created_at FROM users WHERE username = 'testuser'");
expect(typeof result.rows[0].created_at).toBe("number");
// Should be a reasonable Unix timestamp (after year 2020)
expect(Number(result.rows[0].created_at)).toBeGreaterThan(1577836800);
// SQLite stores booleans as integers
expect(result.rows[0].is_active).toBeTruthy();
});
});
@@ -674,40 +447,18 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
});
it("should use default notification settings", async () => {
await client.execute("INSERT INTO user_settings (user_id) VALUES (1)");
const result = await client.execute("SELECT * FROM user_settings WHERE user_id = 1");
expect(result.rows[0].email_enabled).toBe(0);
expect(result.rows[0].shoutrrr_enabled).toBe(0);
// SQLite stores booleans as integers (false = 0)
expect(result.rows[0].email_enabled).toBeFalsy();
expect(result.rows[0].shoutrrr_enabled).toBeFalsy();
});
it("should use default stock threshold settings", async () => {
@@ -717,7 +468,6 @@ describe("Database Client", () => {
expect(result.rows[0].low_stock_days).toBe(30);
expect(result.rows[0].normal_stock_days).toBe(90);
expect(result.rows[0].high_stock_days).toBe(180);
expect(result.rows[0].expiry_warning_days).toBe(90);
});
it("should use default language (en)", async () => {
@@ -747,32 +497,9 @@ describe("Database Client", () => {
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
intake_reminders_enabled integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
});
it("should use default inventory values", async () => {
@@ -795,11 +522,11 @@ describe("Database Client", () => {
expect(result.rows[0].start_json).toBe("[]");
});
it("should default intake_reminders_enabled to false (0)", async () => {
it("should default intake_reminders_enabled to false", async () => {
await client.execute("INSERT INTO medications (user_id, name) VALUES (1, 'Test Med')");
const result = await client.execute("SELECT intake_reminders_enabled FROM medications WHERE name = 'Test Med'");
expect(result.rows[0].intake_reminders_enabled).toBe(0);
expect(result.rows[0].intake_reminders_enabled).toBeFalsy();
});
});
@@ -810,21 +537,8 @@ describe("Database Client", () => {
client = createClient({ url: ":memory:" });
// Enable foreign keys
await client.execute("PRAGMA foreign_keys = ON");
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE
)
`);
await client.execute(`
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should cascade delete medications when user is deleted", async () => {
@@ -845,18 +559,44 @@ describe("Database Client", () => {
});
});
describe("Unique Constraints", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should enforce unique constraint on username", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await expect(
client.execute("INSERT INTO users (username) VALUES ('testuser')")
).rejects.toThrow();
});
it("should enforce unique constraint on refresh token_id", async () => {
await client.execute("INSERT INTO users (username) VALUES ('testuser')");
await client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
);
await expect(
client.execute(
"INSERT INTO refresh_tokens (user_id, token_id, expires_at) VALUES (1, 'token123', 1735689600)"
)
).rejects.toThrow();
});
});
describe("Default User Creation (Auth Disabled)", () => {
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
client = createClient({ url: ":memory:" });
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
auth_provider text NOT NULL DEFAULT 'local'
)
`);
const db = drizzle(client);
await migrate(db, { migrationsFolder });
});
it("should be able to create a default user with ID 1", async () => {
+136
View File
@@ -80,6 +80,45 @@ async function registerDoseRoutes(ctx: TestContext) {
return { success: true };
});
// POST /doses/dismiss - Dismiss missed doses without deducting stock
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
const userId = 1;
const { doseIds } = request.body || {};
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
return reply.status(400).send({ error: "doseIds array is required" });
}
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
// Update to dismissed if not already
if (!existing.rows[0].dismissed) {
await client.execute({
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
args: [existing.rows[0].id],
});
dismissedCount++;
}
} else {
// Insert new dismissed record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
args: [userId, doseId],
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
});
}
// =============================================================================
@@ -361,4 +400,101 @@ describe("Dose Tracking API", () => {
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
});
// ---------------------------------------------------------------------------
// Dismiss Doses Tests (POST /doses/dismiss)
// ---------------------------------------------------------------------------
describe("POST /doses/dismiss", () => {
it("should dismiss multiple doses", async () => {
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
args: [userId],
});
expect(result.rows.length).toBe(2);
});
it("should not double-count already dismissed doses", async () => {
const doseId = "1-0-1735344000000";
// Dismiss once
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Dismiss again
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
});
it("should reject empty doseIds array", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [] },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should reject missing doseIds", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
const doseId = "1-0-1735344000000";
// First mark as taken
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Then dismiss it
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
// Verify it's now dismissed
const result = await ctx.client.execute({
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
expect(result.rows[0].dismissed).toBe(1);
});
});
});
+354
View File
@@ -54,6 +54,8 @@ const { shareRoutes } = await import("../routes/share.js");
const { medicationRoutes } = await import("../routes/medications.js");
const { settingsRoutes } = await import("../routes/settings.js");
const { healthRoutes } = await import("../routes/health.js");
const { refillRoutes } = await import("../routes/refills.js");
const { exportRoutes } = await import("../routes/export.js");
// =============================================================================
// Test Setup
@@ -139,6 +141,17 @@ async function createSchema(client: Client) {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
medication_id integer NOT NULL,
user_id integer NOT NULL,
packs_added integer NOT NULL DEFAULT 0,
loose_pills_added integer NOT NULL DEFAULT 0,
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
@@ -149,6 +162,7 @@ async function createSchema(client: Client) {
}
async function clearData(client: Client) {
await client.execute("DELETE FROM refill_history");
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
await client.execute("DELETE FROM user_settings");
@@ -229,6 +243,8 @@ describe("E2E Tests with Real Routes", () => {
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(healthRoutes);
await app.register(refillRoutes);
await app.register(exportRoutes);
await app.ready();
});
@@ -1567,4 +1583,342 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(204);
});
});
// ---------------------------------------------------------------------------
// Real Refill Routes Tests
// ---------------------------------------------------------------------------
describe("Real /medications/:id/refill routes", () => {
it("should add refill to medication stock", async () => {
// Create medication first
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Refill Test Med",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
// Add refill
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 10 },
});
expect(refillResponse.statusCode).toBe(200);
const data = refillResponse.json();
expect(data.success).toBe(true);
expect(data.newStock.packCount).toBe(3); // 2 + 1
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
});
it("should return 400 when no packs or pills added", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Refill Test Med 2",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
const response = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 0 },
});
expect(response.statusCode).toBe(400);
});
it("should return 404 for non-existent medication", async () => {
const response = await app.inject({
method: "POST",
url: "/medications/99999/refill",
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(404);
});
it("should return 400 for invalid medication id", async () => {
const response = await app.inject({
method: "POST",
url: "/medications/invalid/refill",
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(400);
});
});
describe("Real /medications/:id/refills routes (history)", () => {
it("should return empty array when no refills", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "No Refill Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
const response = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([]);
});
it("should return refill history after adding refills", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "With Refills Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
// Add two refills
await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 5 },
});
const response = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const refills = response.json();
expect(refills).toHaveLength(2);
// Check both refills exist (order may vary)
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
expect(hasPackRefill).toBe(true);
expect(hasLooseRefill).toBe(true);
});
it("should return 404 for non-existent medication", async () => {
const response = await app.inject({
method: "GET",
url: "/medications/99999/refills",
});
expect(response.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// Real Export/Import Routes Tests
// ---------------------------------------------------------------------------
describe("Real /export routes", () => {
it("should export empty data when no medications", async () => {
const response = await app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.version).toBeDefined();
expect(data.exportedAt).toBeDefined();
expect(data.medications).toEqual([]);
});
it("should export medications with correct structure", async () => {
// Create a medication
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Export Test Med",
genericName: "Test Generic",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
pillWeightMg: 500,
notes: "Test notes",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const response = await app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.medications).toHaveLength(1);
const med = data.medications[0];
expect(med.name).toBe("Export Test Med");
expect(med.genericName).toBe("Test Generic");
expect(med.inventory.packCount).toBe(2);
expect(med.inventory.blistersPerPack).toBe(3);
expect(med.inventory.pillsPerBlister).toBe(10);
expect(med.inventory.looseTablets).toBe(5);
expect(med.pillWeightMg).toBe(500);
expect(med.notes).toBe("Test notes");
expect(med.schedules).toHaveLength(1);
});
it("should include settings when user has settings", async () => {
// Create settings first
await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: true,
notificationEmail: "test@example.com",
},
});
const response = await app.inject({
method: "GET",
url: "/export",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.settings).toBeDefined();
expect(data.settings.emailEnabled).toBe(true);
});
});
describe("Real /import routes", () => {
it("should import medications from export format", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Med",
genericName: "Imported Generic",
takenBy: ["Person A"],
inventory: {
packCount: 3,
blistersPerPack: 2,
pillsPerBlister: 14,
looseTablets: 7,
},
pillWeightMg: 250,
schedules: [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z", remind: true }
],
notes: "Imported notes",
intakeRemindersEnabled: true,
}
],
};
const response = await app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
const result = response.json();
expect(result.success).toBe(true);
expect(result.imported.medications).toBe(1);
// Verify medication was created
const medsResponse = await app.inject({
method: "GET",
url: "/medications",
});
const meds = medsResponse.json();
expect(meds).toHaveLength(1);
expect(meds[0].name).toBe("Imported Med");
});
it("should return 400 for invalid import data", async () => {
const response = await app.inject({
method: "POST",
url: "/import",
payload: { invalid: "data" },
});
expect(response.statusCode).toBe(400);
});
it("should replace existing medications on import", async () => {
// First create a medication
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Existing Med",
packCount: 5,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
// Verify it exists
let medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].name).toBe("Existing Med");
expect(medsResponse.json()[0].packCount).toBe(5);
// Import will REPLACE all data
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Med",
inventory: { packCount: 10, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
}
],
};
const response = await app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(response.statusCode).toBe(200);
const result = response.json();
expect(result.success).toBe(true);
expect(result.imported.medications).toBe(1);
// Verify: old med is gone, new med exists
medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].name).toBe("Imported Med");
expect(medsResponse.json()[0].packCount).toBe(10);
});
});
});
+1
View File
@@ -136,6 +136,7 @@ async function createSchema(client: Client) {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
+394
View File
@@ -0,0 +1,394 @@
/**
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
* Tests adding refills to medication stock and retrieving refill history.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
buildTestApp,
closeTestApp,
clearTestData,
createTestUser,
createTestMedication,
TestContext,
} from "./setup.js";
// Store userId at module level so routes can access it
let currentUserId = 1;
// =============================================================================
// Route Registration
// =============================================================================
async function registerRefillRoutes(ctx: TestContext) {
const { app, client } = ctx;
// POST /medications/:id/refill - Add stock and record history
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
"/medications/:id/refill",
async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
// Validate input
if (packsAdded < 0 || loosePillsAdded < 0) {
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
}
if (packsAdded === 0 && loosePillsAdded === 0) {
return reply.status(400).send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
}
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
const med = medResult.rows[0];
const newPackCount = (med.pack_count as number) + packsAdded;
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
// Update medication stock
await client.execute({
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
args: [newPackCount, newLooseTablets, medId],
});
// Record refill history
await client.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
VALUES (?, ?, ?, ?)`,
args: [medId, userId, packsAdded, loosePillsAdded],
});
return {
success: true,
pillsAdded: totalPillsAdded,
newPackCount,
newLooseTablets,
};
}
);
// GET /medications/:id/refills - Get refill history
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
// Get refill history, newest first
const refillResult = await client.execute({
sql: `SELECT id, packs_added, loose_pills_added, refill_date
FROM refill_history
WHERE medication_id = ? AND user_id = ?
ORDER BY refill_date DESC`,
args: [medId, userId],
});
return {
refills: refillResult.rows.map((r) => ({
id: r.id,
packsAdded: r.packs_added,
loosePillsAdded: r.loose_pills_added,
refillDate: r.refill_date,
})),
};
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Refill API", () => {
let ctx: TestContext;
let userId: number;
let medId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerRefillRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user
userId = await createTestUser(ctx.client, { username: "testuser" });
// Update the module-level userId so routes use the correct one
currentUserId = userId;
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
medId = await createTestMedication(ctx.client, {
userId,
name: "Test Med",
packCount: 1,
blistersPerPack: 10,
pillsPerBlister: 10,
looseTablets: 5,
});
});
// ---------------------------------------------------------------------------
// POST /medications/:id/refill
// ---------------------------------------------------------------------------
describe("POST /medications/:id/refill", () => {
it("should add packs to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
expect(data.newPackCount).toBe(3); // 1 + 2
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT pack_count FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].pack_count).toBe(3);
});
it("should add loose pills to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { loosePillsAdded: 15 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(15);
expect(data.newLooseTablets).toBe(20); // 5 + 15
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].loose_tablets).toBe(20);
});
it("should add both packs and loose pills", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 10 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
expect(data.newPackCount).toBe(2);
expect(data.newLooseTablets).toBe(15);
});
it("should record refill in history", async () => {
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2, loosePillsAdded: 5 },
});
// Check history
const result = await ctx.client.execute({
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].packs_added).toBe(2);
expect(result.rows[0].loose_pills_added).toBe(5);
});
it("should reject refill with zero amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 0 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("At least one");
});
it("should reject refill with negative amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: -1 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("non-negative");
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/99999/refill`,
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// GET /medications/:id/refills
// ---------------------------------------------------------------------------
describe("GET /medications/:id/refills", () => {
it("should return empty array when no refills", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ refills: [] });
});
it("should return refill history newest first", async () => {
// Add two refills with different values so we can identify them
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
await new Promise((r) => setTimeout(r, 1100));
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 20 },
});
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.refills).toHaveLength(2);
// Newest first (loose pills - added second)
expect(data.refills[0].packsAdded).toBe(0);
expect(data.refills[0].loosePillsAdded).toBe(20);
// Older (packs - added first)
expect(data.refills[1].packsAdded).toBe(1);
expect(data.refills[1].loosePillsAdded).toBe(0);
// Each entry should have an id and refillDate
for (const refill of data.refills) {
expect(refill.id).toBeTypeOf("number");
expect(refill.refillDate).toBeTruthy();
}
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/99999/refills`,
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// Cascade Delete Tests
// ---------------------------------------------------------------------------
describe("Cascade Delete", () => {
it("should delete refill history when medication is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(1);
// Delete medication
await ctx.client.execute({
sql: `DELETE FROM medications WHERE id = ?`,
args: [medId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(0);
});
it("should delete refill history when user is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(1);
// Delete user
await ctx.client.execute({
sql: `DELETE FROM users WHERE id = ?`,
args: [userId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(0);
});
});
});
+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");
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

+661 -84
View File
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -38,7 +38,14 @@
"pastDaysCount": "{{count}} Tag",
"pastDaysCount_other": "{{count}} Tage",
"missedDoses": "{{count}} verpasste Dosis",
"missedDoses_other": "{{count}} verpasste Dosen"
"missedDoses_other": "{{count}} verpasste Dosen",
"clearMissed": "Verpasste löschen",
"clearMissedConfirmTitle": "Verpasste Dosen löschen?",
"clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
"clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
"clearMissedConfirm": "Ja, löschen",
"clearMissedCancel": "Abbrechen",
"clearMissedSuccess": "{{count}} verpasste Dosen gelöscht"
},
"reminders": {
"active": "Automatische Erinnerungen aktiv",
@@ -91,8 +98,8 @@
}
},
"form": {
"editEntry": "Eintrag bearbeiten",
"newEntry": "Neuer Eintrag",
"editEntry": "Medikament bearbeiten",
"newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten",
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
@@ -219,7 +226,7 @@
"pillWeight": "Tablettengewicht",
"expiryDate": "Ablaufdatum",
"intakeSchedule": "Einnahmeplan",
"coverageStatus": "Reichweite",
"coverageStatus": "Bestand",
"daysLeft": "Tage übrig",
"runsOut": "Aufgebraucht",
"notes": "Notizen",
@@ -376,5 +383,21 @@
"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"
}
}
+26 -3
View File
@@ -40,7 +40,14 @@
"pastDaysCount": "{{count}} day",
"pastDaysCount_other": "{{count}} days",
"missedDoses": "{{count}} missed dose",
"missedDoses_other": "{{count}} missed doses"
"missedDoses_other": "{{count}} missed doses",
"clearMissed": "Clear missed",
"clearMissedConfirmTitle": "Clear Missed Doses?",
"clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.",
"clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.",
"clearMissedConfirm": "Yes, Clear",
"clearMissedCancel": "Cancel",
"clearMissedSuccess": "Cleared {{count}} missed doses"
},
"reminders": {
"active": "Automatic reminders active",
@@ -93,8 +100,8 @@
}
},
"form": {
"editEntry": "Edit entry",
"newEntry": "New entry",
"editEntry": "Edit medication",
"newEntry": "New medication",
"badge": "Packs + loose pills",
"commercialName": "Commercial Name",
"genericName": "Generic Name",
@@ -378,5 +385,21 @@
"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"
}
}
+435 -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;
@@ -690,6 +734,35 @@ textarea.auto-resize {
background: rgba(234, 179, 8, 0.08);
}
/* Past days header container - toggle + clear button */
.past-days-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.past-days-header .past-days-toggle {
flex: 1;
}
.clear-missed-btn {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
font-weight: 500;
background: rgba(234, 179, 8, 0.15);
color: var(--warning);
border: 1px solid var(--warning);
border-radius: var(--btn-radius);
cursor: pointer;
white-space: nowrap;
transition: background 150ms ease, transform 100ms ease;
}
.clear-missed-btn:hover {
background: rgba(234, 179, 8, 0.25);
transform: translateY(-1px);
}
.clear-missed-btn:active {
transform: translateY(0);
}
/* Past day blocks styling */
.day-block.past {
opacity: 0.7;
@@ -1359,13 +1432,21 @@ textarea.auto-resize {
}
.setting-row.language-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 1.5rem;
gap: 1rem;
}
.setting-row.language-row .setting-label {
flex: 0 0 auto;
min-width: 100px;
}
.language-select {
width: auto;
min-width: 160px;
flex: 1 1 auto;
min-width: 140px;
max-width: 200px;
padding: 0.6rem 2rem 0.6rem 0.75rem;
font-size: 1rem;
@@ -1634,11 +1715,42 @@ textarea.auto-resize {
}
.info-tooltip:hover::after,
.info-tooltip:hover::before {
.info-tooltip:hover::before,
.info-tooltip:focus::after,
.info-tooltip:focus::before,
.info-tooltip.tooltip-active::after,
.info-tooltip.tooltip-active::before {
opacity: 1;
visibility: visible;
}
/* Mobile tooltip - disable hover, use click only */
@media (max-width: 640px) {
.info-tooltip:hover::after,
.info-tooltip:hover::before {
opacity: 0;
visibility: hidden;
}
.info-tooltip.tooltip-active::after {
opacity: 1;
visibility: visible;
position: fixed;
top: 50%;
left: 50%;
bottom: auto;
right: auto;
transform: translate(-50%, -50%);
max-width: calc(100vw - 32px);
width: max-content;
z-index: 9999;
}
.info-tooltip::before {
display: none;
}
}
/* Channels Overview */
.channels-overview {
display: flex;
@@ -1947,7 +2059,8 @@ textarea.auto-resize {
.schedule-row {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.85rem;
}
@@ -1960,11 +2073,27 @@ textarea.auto-resize {
.schedule-label {
color: var(--text-secondary);
flex-shrink: 0;
min-width: 0;
}
.schedule-value {
color: var(--text-primary);
font-weight: 500;
text-align: right;
word-break: break-word;
}
/* Mobile: stack schedule rows vertically when text is long */
@media (max-width: 400px) {
.schedule-row {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.schedule-value {
text-align: left;
}
}
/* Legacy support for old channel-btn (can remove later) */
@@ -2061,10 +2190,13 @@ textarea.auto-resize {
font-size: 0.9rem;
}
/* Compact Setting Row */
/* Compact Setting Row - for inline toggles without card styling */
.setting-row.compact {
padding: 0.75rem 1rem;
padding: 0.75rem 0;
margin-top: 0.5rem;
background: transparent;
border: none;
border-radius: 0;
}
.setting-row.compact .setting-label {
@@ -2255,6 +2387,9 @@ textarea.auto-resize {
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
.med-avatar.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
.med-avatar.clickable:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
/* Table/Timeline cells with avatar */
.cell-with-avatar {
display: flex;
@@ -2856,14 +2991,22 @@ textarea.auto-resize {
}
.lightbox-image {
max-width: 90vw;
max-height: 85vh;
max-width: 50vw;
max-height: 50vh;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: zoomIn 0.3s ease;
}
/* Mobile: larger lightbox image */
@media (max-width: 768px) {
.lightbox-image {
max-width: 90vw;
max-height: 70vh;
}
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
@@ -2903,6 +3046,22 @@ textarea.auto-resize {
.med-detail-footer {
padding: 1rem 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.med-detail-footer > button {
flex: 1 1 auto;
}
.med-detail-footer .footer-actions {
flex: 1 1 auto;
justify-content: flex-end;
}
.med-detail-footer button {
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
}
.med-detail-grid {
@@ -3254,7 +3413,17 @@ h3 .reminder-icon.info-tooltip {
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
}
.user-menu:hover .user-dropdown {
/* Only use hover on devices that support it (not touch) */
@media (hover: hover) and (pointer: fine) {
.user-menu:hover .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
}
/* Click-based open for all devices */
.user-menu.open .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
@@ -3688,6 +3857,215 @@ h3 .reminder-icon.info-tooltip {
align-items: center;
}
/* =============================================================================
Refill Modal & History
============================================================================= */
.refill-modal {
max-width: 500px;
padding: 1.5rem;
}
.refill-modal h2 {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.refill-med-name {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.refill-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.refill-form label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-weight: 500;
}
.refill-form input {
padding: 0.75rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 1rem;
}
.refill-footer-right {
display: flex;
align-items: center;
gap: 1rem;
}
.refill-footer-right .refill-preview {
height: 42px;
}
/* Refill modal footer mobile */
@media (max-width: 640px) {
.refill-modal .modal-footer {
flex-direction: column;
gap: 0.75rem;
}
.refill-modal .modal-footer > button,
.refill-modal .modal-footer .refill-footer-right {
width: 100%;
}
.refill-modal .modal-footer .refill-footer-right {
justify-content: space-between;
}
.refill-modal .modal-footer .refill-footer-right button {
flex: 1;
}
}
/* Inline refill form in edit modal */
.refill-form-inline {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.refill-form-inline label {
flex: 1;
min-width: 100px;
}
.refill-form-inline label input {
width: 100%;
}
.refill-form-inline button {
flex-shrink: 0;
margin-bottom: 0;
align-self: flex-end;
height: 42px;
padding: 0 1rem;
min-width: 110px;
}
.refill-preview {
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0 0.75rem;
background: transparent;
border: 1px dashed var(--success);
border-radius: 6px;
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
align-self: flex-end;
box-sizing: border-box;
}
.refill-section {
border-left: 3px solid var(--success);
background: var(--bg-secondary);
margin-top: 1.5rem;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 8px 8px 0;
}
.refill-section .refill-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem 0;
}
.refill-section .refill-form-inline button {
background: var(--success);
color: white;
border: none;
}
.refill-section .refill-form-inline button:hover:not(:disabled) {
background: var(--success-hover, #3aa865);
filter: brightness(1.1);
}
.refill-section .refill-form-inline button:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Clickable section header (for expand/collapse) */
.section-header-clickable {
cursor: pointer;
user-select: none;
}
.section-header-clickable:hover {
color: var(--accent);
}
/* Refill history in detail modal */
.refill-history-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.refill-history-header .collapse-icon {
font-size: 0.75rem;
color: var(--text-secondary);
}
.refill-history-header .refill-count {
font-weight: normal;
font-size: 0.875rem;
color: var(--text-secondary);
}
.refill-history-list {
margin-top: 0.75rem;
}
.refill-history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-primary);
}
.refill-history-item:last-child {
border-bottom: none;
}
.refill-date {
font-size: 0.875rem;
color: var(--text-secondary);
}
.refill-details {
font-size: 0.875rem;
font-weight: 500;
color: var(--success);
}
/* Nested modal overlay */
.modal-overlay.nested {
background: rgba(0, 0, 0, 0.6);
}
/* =============================================================================
Shared Schedule Page (Public)
============================================================================= */
@@ -3978,78 +4356,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;
}
}